Skip to content

Commit 9fb88ba

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 3ae9403 commit 9fb88ba

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
@@ -125,6 +125,12 @@ export async function initFuzzing(
125125
getJazzerJsGlobal<vm.Context>("vmContext") ?? globalThis,
126126
);
127127

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

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 {
@@ -109,6 +123,8 @@ export const load: LoadFn = async function load(url, context, nextLoad) {
109123
// ── Instrumentation ──────────────────────────────────────────────
110124

111125
function instrumentModule(code: string, filename: string): string | null {
126+
drainHookUpdates();
127+
112128
const fuzzerCoverage = esmCodeCoverage();
113129

114130
const plugins: PluginItem[] = [fuzzerCoverage.plugin, compareHooks];
@@ -121,6 +137,14 @@ function instrumentModule(code: string, filename: string): string | null {
121137
plugins.push(sourceCodeCoverage(filename));
122138
}
123139

140+
// Apply function hooks if the main thread has sent hook definitions
141+
// and any of them target functions in this file. The instrumented
142+
// code calls HookManager.callHook(id, ...) at runtime, which
143+
// resolves to the real hook function on the main thread.
144+
if (loaderHookManager.hasFunctionsToHook(filename)) {
145+
plugins.push(functionHooks(filename));
146+
}
147+
124148
let transformed: ReturnType<typeof transformSync>;
125149
try {
126150
transformed = transformSync(code, {
@@ -168,6 +192,55 @@ function instrumentModule(code: string, filename: string): string | null {
168192
return preambleLines.join("\n") + "\n" + transformed.code;
169193
}
170194

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

173246
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)