Skip to content

Commit 3f5c888

Browse files
committed
Get it working
1 parent c62c0f2 commit 3f5c888

10 files changed

Lines changed: 211 additions & 71 deletions

File tree

.github/workflows/ci.yml

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
21
name: "CI: Build & Test"
32
on:
43
push:
@@ -9,7 +8,7 @@ on:
98

109
permissions:
1110
contents: read
12-
packages: read # Required for GHCR
11+
packages: read # Required for GHCR
1312

1413
jobs:
1514
job_lint:
@@ -408,9 +407,36 @@ jobs:
408407
- name: Run tests
409408
run: yarn test
410409

410+
job_test_bindings_recompile:
411+
name: Test (recompile from source) (v${{ matrix.node }}) ${{ matrix.os }}
412+
needs: [job_build]
413+
runs-on: ${{ matrix.os }}
414+
env:
415+
DELETE_BINARIES: true
416+
strategy:
417+
fail-fast: false
418+
matrix:
419+
os: [ubuntu-latest, macos-latest]
420+
node: [18, 26]
421+
steps:
422+
- name: Check out current commit
423+
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
424+
- name: Set up Node
425+
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
426+
with:
427+
node-version: ${{ matrix.node }}
428+
- name: Install dependencies
429+
run: yarn install --ignore-engines --ignore-scripts --frozen-lockfile
430+
- name: Download Tarball
431+
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
432+
with:
433+
name: ${{ github.sha }}
434+
- name: Run tests
435+
run: yarn test
436+
411437
job_required_jobs_passed:
412438
name: All required jobs passed
413-
needs: [job_lint, job_test_bindings]
439+
needs: [job_lint, job_test_bindings, job_test_bindings_recompile]
414440
# Always run this, even if a dependent job failed
415441
if: always()
416442
runs-on: ubuntu-latest

package.json

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"sentry"
1717
],
1818
"scripts": {
19+
"install": "node -p \"'@sentry/node-native-stacktrace@' + require('./package.json').version\"",
1920
"lint": "yarn lint:eslint && yarn lint:clang",
2021
"lint:eslint": "eslint . --format stylish",
2122
"lint:clang": "node scripts/clang-format.mjs",
@@ -24,21 +25,23 @@
2425
"fix:clang": "node scripts/clang-format.mjs --fix",
2526
"build": "yarn clean && yarn build:lib && yarn build:bindings:configure && yarn build:bindings",
2627
"build:lib": "tsc",
27-
"build:copy-binary": "node -e \"import { copyBinary } from './lib/index.js'; copyBinary();\"",
28+
"build:copy-binary": "node -e \"const { copyBinary } = require('./lib/copy-binary.js'); copyBinary();\"",
2829
"build:bindings:configure": "node-gyp configure",
2930
"build:bindings:configure:arm64": "node-gyp configure --arch=arm64 --target_arch=arm64",
30-
"build:bindings": "node-gyp build && yarn build:copy-binary",
31-
"build:bindings:arm64": "node-gyp build --arch=arm64 && yarn build:copy-binary",
31+
"build:bindings": "yarn build:lib && node-gyp build && yarn build:copy-binary",
32+
"build:bindings:arm64": "yarn build:lib && node-gyp build --arch=arm64 && yarn build:copy-binary",
3233
"build:tarball": "npm pack",
3334
"clean": "node-gyp clean && rm -rf lib && rm -rf build && rm -f *.tgz",
3435
"test": "node ./test/prepare.mjs && vitest run --poolOptions.forks.singleFork --silent=false --disable-console-intercept"
3536
},
37+
"gypfile": false,
3638
"engines": {
3739
"node": ">=18"
3840
},
3941
"dependencies": {
4042
"detect-libc": "^2.0.4",
41-
"node-abi": "^3.89.0"
43+
"node-abi": "^3.92.0",
44+
"node-gyp": "^11.5.0"
4245
},
4346
"devDependencies": {
4447
"@sentry-internal/eslint-config-sdk": "^9.22.0",
@@ -47,7 +50,6 @@
4750
"clang-format": "^1.8.0",
4851
"cross-env": "^7.0.3",
4952
"eslint": "^7.0.0",
50-
"node-gyp": "^11.2.0",
5153
"typescript": "^5.8.3",
5254
"vitest": "^3.1.4"
5355
},

src/constants.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
2+
import * as os from 'node:os';
3+
import { versions } from 'node:process';
4+
import * as libc from 'detect-libc';
5+
import { getAbi } from 'node-abi';
6+
7+
export const stdlib = libc.familySync();
8+
export const platform = process.env['BUILD_PLATFORM'] || os.platform();
9+
export const arch = process.env['BUILD_ARCH'] || os.arch();
10+
export const abi = getAbi(versions.node, 'node');
11+
export const identifier = [platform, arch, stdlib, abi].filter(c => c !== undefined && c !== null).join('-');

src/copy-binary.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/* eslint-disable no-console */
2+
import * as fs from 'node:fs';
3+
import * as path from 'node:path';
4+
import { identifier } from './constants';
5+
6+
const source = path.join(__dirname, '..', 'build', 'Release', 'stack-trace.node');
7+
const target = path.join(__dirname, '..', 'lib', `stack-trace-${identifier}.node`);
8+
9+
/**
10+
* Copies the compiled binary from the build directory to the lib directory with the correct name based on the current platform and Node version.
11+
*
12+
* @hidden We only use this for copying the binary after building, it is not intended to be used by end users.
13+
*/
14+
export function copyBinary(): void {
15+
const build = path.resolve(__dirname, '..', 'lib');
16+
if (!fs.existsSync(build)) {
17+
fs.mkdirSync(build, { recursive: true });
18+
}
19+
20+
if (!fs.existsSync(source)) {
21+
throw new Error(`Source file does not exist: ${ source}`);
22+
} else {
23+
if (fs.existsSync(target)) {
24+
console.log('Target file already exists, overwriting it');
25+
fs.unlinkSync(target);
26+
}
27+
console.log('Copying', source, 'to', target);
28+
fs.copyFileSync(source, target);
29+
}
30+
}

src/index.ts

Lines changed: 39 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,12 @@
11
/* eslint-disable no-console */
22
import type { AsyncLocalStorage } from 'node:async_hooks';
33
import { spawnSync } from 'node:child_process';
4-
import * as fs from 'node:fs';
5-
import * as os from 'node:os';
64
import * as path from 'node:path';
7-
import { env, versions } from 'node:process';
5+
import { env } from 'node:process';
86
import { threadId } from 'node:worker_threads';
9-
import * as libc from 'detect-libc';
10-
import { getAbi } from 'node-abi';
11-
12-
const stdlib = libc.familySync();
13-
const platform = process.env['BUILD_PLATFORM'] || os.platform();
14-
const arch = process.env['BUILD_ARCH'] || os.arch();
15-
const abi = getAbi(versions.node, 'node');
16-
const identifier = [platform, arch, stdlib, abi].filter(c => c !== undefined && c !== null).join('-');
7+
import { abi, arch, identifier, platform, stdlib } from './constants';
8+
import { copyBinary } from './copy-binary';
9+
import { withRebuildLock } from './rebuild-lock';
1710

1811
type AsyncStorageArgs = {
1912
/** The AsyncLocalStorage instance used to fetch the store */
@@ -56,60 +49,34 @@ interface Native {
5649
getThreadsLastSeen(): Record<string, number>;
5750
}
5851

59-
/**
60-
* Copies the compiled binary from the build directory to the lib directory with the correct name based on the current platform and Node version.
61-
*
62-
* @hidden We only use this for copying the binary after building, it is not intended to be used by end users.
63-
*/
64-
export function copyBinary(): void {
65-
const build = path.resolve(__dirname, '..', 'lib');
66-
if (!fs.existsSync(build)) {
67-
fs.mkdirSync(build, { recursive: true });
68-
}
69-
70-
if (!fs.existsSync(source)) {
71-
console.log('Source file does not exist:', source);
72-
process.exit(1);
73-
} else {
74-
if (fs.existsSync(target)) {
75-
console.log('Target file already exists, overwriting it');
76-
fs.unlinkSync(target);
77-
}
78-
console.log('Copying', source, 'to', target);
79-
fs.copyFileSync(source, target);
80-
}
81-
}
82-
83-
const source = path.join(__dirname, '..', 'build', 'Release', 'stack-trace.node');
84-
const target = path.join(__dirname, '..', 'lib', `stack-trace-${identifier}.node`);
85-
8652
function clean(err: Buffer): string {
8753
return err.toString().trim();
8854
}
8955

9056
function recompileFromSource(): void {
9157
const cwd = path.join(__dirname, '..');
58+
// Resolve node-gyp from its package entry so it's found even when
59+
// node_modules/.bin/ is not in PATH (e.g. outside of npm run scripts).
60+
const nodeGyp = require.resolve('node-gyp/bin/node-gyp.js');
9261
console.log('Compiling from source...');
93-
let spawn = spawnSync('node-gyp', ['configure'], {
62+
let spawn = spawnSync(process.execPath, [nodeGyp, 'configure'], {
9463
cwd,
9564
stdio: ['inherit', 'inherit', 'pipe'],
9665
env: process.env,
97-
shell: true,
9866
});
9967
if (spawn.status !== 0) {
10068
console.log('Failed to configure gyp');
101-
console.log(clean(spawn.stderr));
69+
if (spawn.stderr) console.log(clean(spawn.stderr));
10270
return;
10371
}
104-
spawn = spawnSync('node-gyp', ['build'], {
72+
spawn = spawnSync(process.execPath, [nodeGyp, 'build'], {
10573
cwd,
10674
stdio: ['inherit', 'inherit', 'pipe'],
10775
env: process.env,
108-
shell: true,
10976
});
11077
if (spawn.status !== 0) {
11178
console.log('Failed to build bindings');
112-
console.log(clean(spawn.stderr));
79+
if (spawn.stderr) console.log(clean(spawn.stderr));
11380
return;
11481
}
11582

@@ -280,7 +247,7 @@ function getNativeModule(): Native {
280247
try {
281248
return require('../build/Release/stack-trace.node');
282249
} catch (e) {
283-
console.warn('The \'@sentry-internal/node-native-stacktrace\' binary could not be found. Use \'@electron/rebuild\' to ensure the native module is built for Electron.');
250+
console.warn('The \'@sentry/node-native-stacktrace\' binary could not be found. Use \'@electron/rebuild\' to ensure the native module is built for Electron.');
284251
throw e;
285252
}
286253
}
@@ -290,13 +257,15 @@ function getNativeModule(): Native {
290257
return nativeModule;
291258
}
292259

293-
try {
294-
recompileFromSource();
295-
} catch (e) {
296-
console.warn('Failed to compile from source:', e);
297-
}
260+
withRebuildLock(() => {
261+
try {
262+
recompileFromSource();
263+
} catch (e) {
264+
console.warn('Failed to compile from source:', e);
265+
}
266+
});
298267

299-
// Try again after attempting to recompile, in case the binary is now available.
268+
// Try again after recompile (or after another caller finished theirs).
300269
nativeModule = tryLoad();
301270

302271
if (nativeModule) {
@@ -308,7 +277,26 @@ function getNativeModule(): Native {
308277

309278
const native = getNativeModule();
310279

280+
/**
281+
* Registers the current thread with the native module.
282+
*
283+
* This should be called on every thread that you want to capture stack traces from.
284+
*
285+
* @param threadName The name of the thread
286+
*
287+
* threadName defaults to the `threadId` if not provided.
288+
*/
311289
export function registerThread(threadName?: string): void;
290+
/**
291+
* Registers the current thread with the native module.
292+
*
293+
* This should be called on every thread that you want to capture stack traces from.
294+
*
295+
* @param storageOrThread Either the name of the thread, or an object containing an AsyncLocalStorage instance and optional storage key.
296+
* @param threadName The name of the thread, if the first argument is an object.
297+
*
298+
* threadName defaults to the `threadId` if not provided.
299+
*/
312300
export function registerThread(storageOrThread: AsyncStorageArgs | string, threadName?: string): void;
313301
/**
314302
* Registers the current thread with the native module.

src/rebuild-lock.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import * as fs from 'fs';
2+
import * as path from 'path';
3+
4+
const lockFile = path.join(__dirname, '..', '.rebuild-lock');
5+
6+
function isProcessAlive(pid: number): boolean {
7+
try {
8+
process.kill(pid, 0);
9+
return true;
10+
} catch (e) {
11+
// EPERM: process exists but we can't signal it — treat as alive
12+
// ESRCH: no such process — stale lock
13+
return (e as NodeJS.ErrnoException).code === 'EPERM';
14+
}
15+
}
16+
17+
function tryAcquire(): number | undefined {
18+
let fd: number | undefined;
19+
try {
20+
fd = fs.openSync(lockFile, 'wx');
21+
fs.writeSync(fd, String(process.pid));
22+
return fd;
23+
} catch {
24+
try { if (fd !== undefined) fs.closeSync(fd); } catch { /* ignore */ }
25+
return undefined;
26+
}
27+
}
28+
29+
function release(fd: number): void {
30+
try { fs.closeSync(fd); } catch { /* ignore */ }
31+
try { fs.unlinkSync(lockFile); } catch { /* ignore */ }
32+
}
33+
34+
function clearStaleLock(): void {
35+
try {
36+
const pid = parseInt(fs.readFileSync(lockFile, 'utf8'), 10);
37+
if (!isNaN(pid) && !isProcessAlive(pid)) {
38+
fs.unlinkSync(lockFile);
39+
}
40+
} catch { /* ignore — another waiter may have already cleared it */ }
41+
}
42+
43+
function waitForLockRelease(): void {
44+
const timer = new Int32Array(new SharedArrayBuffer(4));
45+
while (fs.existsSync(lockFile)) {
46+
clearStaleLock();
47+
Atomics.wait(timer, 0, 0, 250);
48+
}
49+
}
50+
51+
/**
52+
* Runs `work` while holding an exclusive lock on `lockFile`. If another caller
53+
* holds the lock, blocks until it is released (or until a stale lock from a
54+
* crashed process is detected and cleared).
55+
*/
56+
export function withRebuildLock(work: () => void): void {
57+
let fd = tryAcquire();
58+
59+
if (fd === undefined) {
60+
waitForLockRelease();
61+
// Try once more after waiting — another waiter may have cleared a stale
62+
// lock and then done the rebuild itself, so we may not need to rebuild.
63+
fd = tryAcquire();
64+
}
65+
66+
if (fd !== undefined) {
67+
try {
68+
work();
69+
} finally {
70+
release(fd);
71+
}
72+
}
73+
}

test/e2e.test.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ const __dirname = import.meta.dirname || new URL('.', import.meta.url).pathname;
77
const NODE_MAJOR_VERSION = parseInt(process.versions.node.split('.')[0], 10);
88

99
// macOS emulated x64 in CI is very slow!
10-
const timeout = process.env.CI && process.platform === 'darwin' ? 60000 : 20000;
10+
const timeout = (process.env.CI && process.platform === 'darwin') || process.env.DELETE_BINARIES ? 60000 : 20000;
1111

1212
async function runTest(...paths) {
1313
console.time('Test Run');

test/prepare.mjs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { execSync, spawnSync } from 'node:child_process';
2-
import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2+
import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
33
import { createRequire } from 'node:module';
44
import { dirname, join, relative } from 'node:path';
55
import { fileURLToPath } from 'node:url';
@@ -40,6 +40,16 @@ function installTarballAsDependency(root) {
4040

4141
console.log('Installing dependencies...');
4242
execSync('yarn install', { cwd: root, stdio: 'inherit' });
43+
44+
// For recompile from source tests, we need to ensure the pre-built binaries are removed
45+
if (process.env.DELETE_BINARIES) {
46+
const unpackedDir = join(root, 'node_modules', '@sentry/node-native-stacktrace', 'lib');
47+
readdirSync(unpackedDir).forEach(file => {
48+
if (file.endsWith('.node')) {
49+
rmSync(join(unpackedDir, file));
50+
}
51+
});
52+
}
4353
}
4454

4555
installTarballAsDependency(__dirname);

0 commit comments

Comments
 (0)