From 864da3b1cbd4a25435abc64db2f80766b2e30181 Mon Sep 17 00:00:00 2001 From: not-matthias Date: Thu, 27 Aug 2026 19:05:14 +0200 Subject: [PATCH 1/3] fix(core): load the native addon on every Node major The addon shipped ABI-pinned prebuilds for Node 22 and 24 only, so on any other major node-gyp-build found no candidate, the binding silently fell back to the no-op stub and every benchmark failed with "Native core module is not bound". Only one V8 entry point stood in the way of a single Node-API prebuild: v8::String::Utf8Value gained a defaulted argument in Node 24 and WriteUtf8 gave way to WriteUtf8V2 in Node 26, so no string conversion symbol resolves on all three. Every other V8 symbol the perf-map handler needs is stable across 22, 24 and 26. Route the conversion through Node-API and ship one node.napi.node again. prebuildify 6 defaults --name to the package name and no longer appends the napi tag, which node-gyp-build 4.6 rejects, hence the explicit --name. --- packages/core/package.json | 2 +- .../src/native_core/linux_perf/linux_perf.cc | 2 +- .../src/native_core/linux_perf/linux_perf.h | 3 +- .../linux_perf/linux_perf_listener.cc | 8 +++-- .../core/src/native_core/linux_perf/utils.h | 32 +++++++++++++++---- packages/core/src/nodeVersion.ts | 7 ++-- 6 files changed, 38 insertions(+), 16 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index f89e40f9..a1998ec6 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -17,7 +17,7 @@ "gypfile": true, "scripts": { "build": "rollup -c", - "build-native-addon": "prebuildify --name node --strip --no-napi --target 22.0.0 --target 24.0.0", + "build-native-addon": "prebuildify --name node.napi --napi --strip --target 22.0.0", "build-tracer-client": "openapi --client axios --input ./tracer.spec.json --name MongoTracer --output ./src/generated/openapi", "test": "jest --passWithNoTests --silent", "test/integ": "jest --passWithNoTests --silent -c jest.config.integ.js", diff --git a/packages/core/src/native_core/linux_perf/linux_perf.cc b/packages/core/src/native_core/linux_perf/linux_perf.cc index 40e5cbec..dff6bb86 100644 --- a/packages/core/src/native_core/linux_perf/linux_perf.cc +++ b/packages/core/src/native_core/linux_perf/linux_perf.cc @@ -22,7 +22,7 @@ LinuxPerf::LinuxPerf(const Napi::CallbackInfo &info) Napi::Value LinuxPerf::Start(const Napi::CallbackInfo &info) { if (handler == nullptr) { v8::Isolate *isolate = v8::Isolate::GetCurrent(); - handler = new LinuxPerfHandler(isolate); + handler = new LinuxPerfHandler(isolate, info.Env()); handler->Enable(); return Napi::Boolean::New(info.Env(), true); } diff --git a/packages/core/src/native_core/linux_perf/linux_perf.h b/packages/core/src/native_core/linux_perf/linux_perf.h index d1f4bb77..d7807b67 100644 --- a/packages/core/src/native_core/linux_perf/linux_perf.h +++ b/packages/core/src/native_core/linux_perf/linux_perf.h @@ -10,7 +10,7 @@ namespace codspeed_native { class LinuxPerfHandler : public v8::CodeEventHandler { public: - explicit LinuxPerfHandler(v8::Isolate *isolate); + LinuxPerfHandler(v8::Isolate *isolate, napi_env env); ~LinuxPerfHandler() override; void Handle(v8::CodeEvent *code_event) override; @@ -19,6 +19,7 @@ class LinuxPerfHandler : public v8::CodeEventHandler { std::ofstream mapFile; std::string FormatName(v8::CodeEvent *code_event); v8::Isolate *isolate_; + napi_env env_; }; class LinuxPerf : public Napi::ObjectWrap { diff --git a/packages/core/src/native_core/linux_perf/linux_perf_listener.cc b/packages/core/src/native_core/linux_perf/linux_perf_listener.cc index 471bbddd..4f6a4560 100644 --- a/packages/core/src/native_core/linux_perf/linux_perf_listener.cc +++ b/packages/core/src/native_core/linux_perf/linux_perf_listener.cc @@ -5,9 +5,10 @@ namespace codspeed_native { -LinuxPerfHandler::LinuxPerfHandler(v8::Isolate *isolate) +LinuxPerfHandler::LinuxPerfHandler(v8::Isolate *isolate, napi_env env) : v8::CodeEventHandler(isolate) { isolate_ = isolate; + env_ = env; int pid = static_cast(uv_os_getpid()); mapFile.open("/tmp/perf-" + std::to_string(pid) + ".map"); } @@ -17,7 +18,7 @@ LinuxPerfHandler::~LinuxPerfHandler() { mapFile.close(); } std::string LinuxPerfHandler::FormatName(v8::CodeEvent *code_event) { std::string name = std::string(code_event->GetComment()); if (name.empty()) { - name = v8LocalStringToString(code_event->GetFunctionName()); + name = v8LocalStringToString(env_, code_event->GetFunctionName()); } return name; } @@ -27,7 +28,8 @@ void LinuxPerfHandler::Handle(v8::CodeEvent *code_event) { << code_event->GetCodeSize() << " "; mapFile << v8::CodeEvent::GetCodeEventTypeName(code_event->GetCodeType()) << ":" << FormatName(code_event) << " " - << v8LocalStringToString(code_event->GetScriptName()) << std::dec + << v8LocalStringToString(env_, code_event->GetScriptName()) + << std::dec << ":" << code_event->GetScriptLine() << ":" << code_event->GetScriptColumn() << std::endl; } diff --git a/packages/core/src/native_core/linux_perf/utils.h b/packages/core/src/native_core/linux_perf/utils.h index 796df4d0..0951c72d 100644 --- a/packages/core/src/native_core/linux_perf/utils.h +++ b/packages/core/src/native_core/linux_perf/utils.h @@ -2,14 +2,34 @@ #define LINUX_PERF_UTILS_H #include "v8-profiler.h" +#include +#include +// The string conversions are the only part of the V8 C++ API used here that is +// not ABI-stable: Utf8Value's constructor gained a defaulted argument in Node +// 24, and WriteUtf8 gave way to WriteUtf8V2 in Node 26, so none of them +// resolves on every major. Node-API is versioned and does not move, and +// napi_value is layout-compatible with v8::Local by construction. static inline std::string -v8LocalStringToString(v8::Local v8String) { - // Utf8Value NUL-terminates, so the c-string constructor stops at the first - // embedded NUL, as callers expect for symbol names. It yields nullptr when - // the conversion throws. - v8::String::Utf8Value value(v8::Isolate::GetCurrent(), v8String); - return *value ? std::string(*value) : std::string(); +v8LocalStringToString(napi_env env, v8::Local v8String) { + if (v8String.IsEmpty()) { + return std::string(); + } + + napi_value value = reinterpret_cast(*v8String); + size_t length = 0; + if (napi_get_value_string_utf8(env, value, nullptr, 0, &length) != napi_ok) { + return std::string(); + } + + std::string result(length + 1, '\0'); + if (napi_get_value_string_utf8(env, value, result.data(), result.size(), + &length) != napi_ok) { + return std::string(); + } + + result.resize(length); + return result; } #endif // LINUX_PERF_UTILS_H diff --git a/packages/core/src/nodeVersion.ts b/packages/core/src/nodeVersion.ts index 6e78dc0c..eedfabf1 100644 --- a/packages/core/src/nodeVersion.ts +++ b/packages/core/src/nodeVersion.ts @@ -1,8 +1,7 @@ /** - * Majors the native addon ships prebuilds for, as listed in the - * `build-native-addon` targets in package.json. Prebuilds are matched on the - * exact ABI version, so on any other major the addon only loads when it has - * been compiled from source locally. + * Majors CodSpeed is tested against. The native addon itself is built as a + * single Node-API binary and loads on any major, so this only gates the + * warning about measurement stability. */ export const SUPPORTED_NODE_MAJORS = [22, 24]; From b5c15efbf0dc3f82398a529360d1769dc5066a69 Mon Sep 17 00:00:00 2001 From: not-matthias Date: Thu, 27 Aug 2026 19:05:23 +0200 Subject: [PATCH 2/3] feat(core): report why the native core failed to bind The require error was swallowed into a debug log, so a failed binding gave no clue whether the prebuild was missing, incompatible or broken. Carry the error to setupCore and include the runtime it was rejected for. --- packages/core/src/index.ts | 6 +++++- packages/core/src/native_core/index.ts | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 11204859..63ac394f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -16,8 +16,12 @@ export const setupCore = () => { warnOnUnsupportedNodeVersion(); if (!native_core.isBound) { + const reason = + native_core.bindError instanceof Error + ? native_core.bindError.message + : String(native_core.bindError); throw new Error( - "Native core module is not bound, CodSpeed integration will not work properly", + `Native core module is not bound, CodSpeed integration will not work properly (Node ${process.version}, ABI ${process.versions.modules}, ${process.platform}-${process.arch}): ${reason}`, ); } diff --git a/packages/core/src/native_core/index.ts b/packages/core/src/native_core/index.ts index 365f544e..d0ad3587 100644 --- a/packages/core/src/native_core/index.ts +++ b/packages/core/src/native_core/index.ts @@ -9,6 +9,7 @@ interface NativeCore { interface NativeCoreWithBindingStatus extends NativeCore { isBound: boolean; + bindError?: unknown; } let native_core: NativeCoreWithBindingStatus; @@ -76,6 +77,7 @@ try { MARKER_TYPE_BENCHMARK_END: 3, }, isBound: false, + bindError: e, }; } From 56514ef0ceb4abc9a6082cf4c41a75fd398811d4 Mon Sep 17 00:00:00 2001 From: not-matthias Date: Thu, 27 Aug 2026 19:05:24 +0200 Subject: [PATCH 3/3] ci: check the prebuilt addon binds on every supported Node major The existing jobs compile the addon with the same Node they then run it under, so an incompatible prebuild cannot show up there. Build one prebuild set and load it from each major a consumer may run, calling setupCore so that lazily bound V8 symbols are resolved rather than only opening the file. --- .github/workflows/ci.yml | 40 +++++++++++++++++++++++++ packages/core/tests/index.integ.test.ts | 21 ++++++++++++- 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e1453d3..bd4b91d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,46 @@ jobs: - run: pnpm install --frozen-lockfile --prefer-offline - run: pnpm turbo run lint typecheck test + native-abi: + runs-on: "ubuntu-latest" + name: Native addon ABI compatibility + env: + # The flags the runner passes in a real benchmark process. They cannot go + # through NODE_OPTIONS (--allow-natives-syntax is rejected there), so jest + # runs in-band under a flagged node instead of forking workers. + NODE_OPTS: "--interpreted-frames-native-stack --allow-natives-syntax" + steps: + - uses: "actions/checkout@v4" + with: + fetch-depth: 0 + submodules: true + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v6 + with: + cache: pnpm + node-version-file: .nvmrc + - name: Restore turbo cache + uses: ./.github/actions/turbo-cache + with: + key-suffix: native-abi + - run: pnpm install --frozen-lockfile --prefer-offline + # Build one prebuild set, then load it without rebuilding under each + # runtime via the jest integ test. The other jobs compile the addon + # with the Node version that loads it, so they cannot detect an ABI + # mismatch. + - run: pnpm turbo run build --filter=@codspeed/core + + - uses: actions/setup-node@v6 + with: + node-version: "22" + - run: node ${{ env.NODE_OPTS }} "$(node -p 'require.resolve("jest/bin/jest")')" -c jest.config.integ.js --runInBand tests/index.integ.test.ts + working-directory: packages/core + - uses: actions/setup-node@v6 + with: + node-version: "24" + - run: node ${{ env.NODE_OPTS }} "$(node -p 'require.resolve("jest/bin/jest")')" -c jest.config.integ.js --runInBand tests/index.integ.test.ts + working-directory: packages/core + list-examples: runs-on: "ubuntu-latest" name: List examples diff --git a/packages/core/tests/index.integ.test.ts b/packages/core/tests/index.integ.test.ts index f57c3f2f..99c9deb3 100644 --- a/packages/core/tests/index.integ.test.ts +++ b/packages/core/tests/index.integ.test.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-require-imports */ -export {}; // Make this a module +import fs from "fs"; beforeEach(() => { jest.resetModules(); @@ -10,6 +10,25 @@ describe("with bindings", () => { const isBound = require("..").isBound as boolean; expect(isBound).toBe(true); }); + + // Symbols in the addon are bound lazily, so a prebuild built against another + // ABI loads without complaint and only dies once a V8 entry point runs. + it("should write the perf map when the core is set up", () => { + const { setupCore, teardownCore } = require("..") as { + setupCore: () => void; + teardownCore: () => void; + }; + setupCore(); + teardownCore(); + + const perfMap = `/tmp/perf-${process.pid}.map`; + const entries = fs + .readFileSync(perfMap, "utf8") + .split("\n") + .filter(Boolean); + fs.unlinkSync(perfMap); + expect(entries.length).toBeGreaterThan(0); + }); }); describe("without bindings", () => {