From 49d26219fefad5818c2c07a51b149263cb2ff3ad Mon Sep 17 00:00:00 2001 From: not-matthias Date: Thu, 27 Aug 2026 19:05:14 +0200 Subject: [PATCH 1/4] 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 007ce8dc..f0404ddb 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..d6f913cc 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, '\0'); + if (napi_get_value_string_utf8(env, value, &result[0], length + 1, &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 3949b24b51bfaed2f403f90ffce0b54062d62712 Mon Sep 17 00:00:00 2001 From: not-matthias Date: Thu, 27 Aug 2026 19:05:23 +0200 Subject: [PATCH 2/4] 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 d344cb849ae6153791410f9266581f24588fb111 Mon Sep 17 00:00:00 2001 From: not-matthias Date: Thu, 27 Aug 2026 19:05:24 +0200 Subject: [PATCH 3/4] 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 | 35 +++++++++++++++++++++++++++++++ scripts/assert-native-binding.cjs | 29 +++++++++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100755 scripts/assert-native-binding.cjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e1453d3..dbdae54c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,41 @@ 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. + 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 + # One prebuild set, built once, then loaded by every supported Node + # major. The other jobs compile the addon with the same Node they run it + # under, so they cannot see 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 }} scripts/assert-native-binding.cjs + - uses: actions/setup-node@v6 + with: + node-version: "24" + - run: node ${{ env.NODE_OPTS }} scripts/assert-native-binding.cjs + list-examples: runs-on: "ubuntu-latest" name: List examples diff --git a/scripts/assert-native-binding.cjs b/scripts/assert-native-binding.cjs new file mode 100755 index 00000000..70fb3e8f --- /dev/null +++ b/scripts/assert-native-binding.cjs @@ -0,0 +1,29 @@ +#!/usr/bin/env node +// Loads the prebuilt native addon through the same resolution a consumer gets +// and runs the perf-map handler. Symbols in the addon are bound lazily, so a +// prebuild that is incompatible with the running ABI loads without complaint +// and only dies once a V8 entry point is actually called. +const fs = require("fs"); +const path = require("path"); + +const core = require( + path.join(__dirname, "..", "packages", "core", "dist", "index.cjs.js"), +); + +const runtime = `Node ${process.version} (ABI ${process.versions.modules}, ${process.platform}-${process.arch})`; + +core.setupCore(); +core.teardownCore(); + +const perfMap = path.join("/tmp", `perf-${process.pid}.map`); +const entries = fs + .readFileSync(perfMap, "utf8") + .split("\n") + .filter((line) => line.length > 0); +fs.unlinkSync(perfMap); + +if (entries.length === 0) { + throw new Error(`${runtime}: the perf map handler produced no entries`); +} + +console.log(`${runtime}: bound, ${entries.length} perf map entries`); From 9f111ed6852850d3bf1a9be8d5a81d38d44f59d0 Mon Sep 17 00:00:00 2001 From: not-matthias Date: Fri, 28 Aug 2026 11:28:33 +0200 Subject: [PATCH 4/4] fixup! fix(core): load the native addon on every Node major --- packages/core/src/native_core/linux_perf/utils.h | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/src/native_core/linux_perf/utils.h b/packages/core/src/native_core/linux_perf/utils.h index d6f913cc..0951c72d 100644 --- a/packages/core/src/native_core/linux_perf/utils.h +++ b/packages/core/src/native_core/linux_perf/utils.h @@ -22,9 +22,9 @@ v8LocalStringToString(napi_env env, v8::Local v8String) { return std::string(); } - std::string result(length, '\0'); - if (napi_get_value_string_utf8(env, value, &result[0], length + 1, &length) != - napi_ok) { + std::string result(length + 1, '\0'); + if (napi_get_value_string_utf8(env, value, result.data(), result.size(), + &length) != napi_ok) { return std::string(); }