Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,47 @@
- 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:

Check warning

Code scanning / CodeQL

Workflow does not contain permissions Medium

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {contents: read}
runs-on: "ubuntu-latest"
name: List examples
outputs:
Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
);
}

Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/native_core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ interface NativeCore {

interface NativeCoreWithBindingStatus extends NativeCore {
isBound: boolean;
bindError?: unknown;
}

let native_core: NativeCoreWithBindingStatus;
Expand Down Expand Up @@ -76,6 +77,7 @@ try {
MARKER_TYPE_BENCHMARK_END: 3,
},
isBound: false,
bindError: e,
};
}

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/native_core/linux_perf/linux_perf.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/native_core/linux_perf/linux_perf.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<LinuxPerf> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<int>(uv_os_getpid());
mapFile.open("/tmp/perf-" + std::to_string(pid) + ".map");
}
Expand All @@ -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;
}
Expand All @@ -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;
}
Expand Down
32 changes: 26 additions & 6 deletions packages/core/src/native_core/linux_perf/utils.h
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,34 @@
#define LINUX_PERF_UTILS_H

#include "v8-profiler.h"
#include <js_native_api.h>
#include <string>

// 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<v8::Value> by construction.
static inline std::string
v8LocalStringToString(v8::Local<v8::String> 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<v8::String> v8String) {
if (v8String.IsEmpty()) {
return std::string();
}

napi_value value = reinterpret_cast<napi_value>(*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
7 changes: 3 additions & 4 deletions packages/core/src/nodeVersion.ts
Original file line number Diff line number Diff line change
@@ -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];

Expand Down
21 changes: 20 additions & 1 deletion packages/core/tests/index.integ.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-require-imports */
export {}; // Make this a module
import fs from "fs";

beforeEach(() => {
jest.resetModules();
Expand All @@ -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", () => {
Expand Down
Loading