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/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/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, }; } 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]; 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`);