Skip to content

Commit b772670

Browse files
committed
initial commit
1 parent b83429f commit b772670

File tree

9 files changed

+312
-2
lines changed

9 files changed

+312
-2
lines changed

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
node_modules/
2+
build/
3+
yarn.lock
4+
package-lock.json

README.md

Lines changed: 108 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,109 @@
1-
# sentry-javascript-node-native-stacktrace
1+
# `cross-thread-stack-trace`
22

3-
Native Node module to capture stack traces across threads
3+
Native Node module to capture stack traces across threads. This allows capturing
4+
main thread stack traces from a worker thread, even if the main thread event
5+
loop is blocked.
6+
7+
Main thread:
8+
9+
```ts
10+
const { setMainIsolate } = require("cross-thread-stack-trace");
11+
12+
setMainIsolate();
13+
```
14+
15+
Worker thread:
16+
17+
```ts
18+
const { captureStackTrace } = require("cross-thread-stack-trace");
19+
20+
const stack = captureStackTrace();
21+
console.log(stack);
22+
```
23+
24+
Build the module and run the test:
25+
26+
```
27+
npm i && npm test
28+
```
29+
30+
Results in:
31+
32+
```js
33+
[
34+
{
35+
function: "from",
36+
filename: "node:buffer",
37+
lineno: 298,
38+
colno: 28,
39+
},
40+
{
41+
function: "pbkdf2Sync",
42+
filename: "node:internal/crypto/pbkdf2",
43+
lineno: 78,
44+
colno: 17,
45+
},
46+
{
47+
function: "longWork",
48+
filename:
49+
"/Users/tim/Documents/Repositories/cross-thread-stack-trace/test.js",
50+
lineno: 15,
51+
colno: 29,
52+
},
53+
{
54+
function: "?",
55+
filename:
56+
"/Users/tim/Documents/Repositories/cross-thread-stack-trace/test.js",
57+
lineno: 19,
58+
colno: 1,
59+
},
60+
{
61+
function: "?",
62+
filename: "node:internal/modules/cjs/loader",
63+
lineno: 1730,
64+
colno: 14,
65+
},
66+
{
67+
function: "?",
68+
filename: "node:internal/modules/cjs/loader",
69+
lineno: 1895,
70+
colno: 10,
71+
},
72+
{
73+
function: "?",
74+
filename: "node:internal/modules/cjs/loader",
75+
lineno: 1465,
76+
colno: 32,
77+
},
78+
{
79+
function: "?",
80+
filename: "node:internal/modules/cjs/loader",
81+
lineno: 1282,
82+
colno: 12,
83+
},
84+
{
85+
function: "traceSync",
86+
filename: "node:diagnostics_channel",
87+
lineno: 322,
88+
colno: 14,
89+
},
90+
{
91+
function: "wrapModuleLoad",
92+
filename: "node:internal/modules/cjs/loader",
93+
lineno: 235,
94+
colno: 24,
95+
},
96+
{
97+
function: "executeUserEntryPoint",
98+
filename: "node:internal/modules/run_main",
99+
lineno: 170,
100+
colno: 5,
101+
},
102+
{
103+
function: "?",
104+
filename: "node:internal/main/run_main_module",
105+
lineno: 36,
106+
colno: 49,
107+
},
108+
];
109+
```

binding.gyp

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"targets": [
3+
{
4+
"target_name": "cross-thread-stack-trace",
5+
"sources": [ "module.cc" ]
6+
}
7+
]
8+
}

index.d.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
2+
export declare function setMainIsolate(): void;
3+
4+
export type StackFrame = {
5+
function: string;
6+
filename: string;
7+
lineno: number;
8+
colno: number;
9+
};
10+
11+
export declare function captureStackTrace(): StackFrame[];

index.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
const native = require('./build/release/cross-thread-stack-trace.node');
2+
3+
exports.setMainIsolate = native.setMainIsolate;
4+
exports.captureStackTrace = function () {
5+
return JSON.parse(native.captureStackTrace());
6+
};

module.cc

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
#include <node.h>
2+
#include <mutex>
3+
4+
using namespace v8;
5+
using namespace node;
6+
7+
static v8::Isolate *main_thread_isolate;
8+
9+
static std::mutex interrupt_mutex;
10+
static std::condition_variable interrupt_cv;
11+
static bool interrupt_done = false;
12+
13+
static const int kMaxStackFrames = 255;
14+
static const int kMaxStackJsonSize = 10240;
15+
16+
static void ExecutionInterrupted(Isolate *isolate, void *data)
17+
{
18+
char *buffer = static_cast<char *>(data);
19+
20+
v8::RegisterState state;
21+
v8::SampleInfo info;
22+
void *samples[kMaxStackFrames];
23+
24+
uint32_t pos = 0;
25+
26+
// Initialise the register state
27+
state.pc = nullptr;
28+
state.fp = &state;
29+
state.sp = &state;
30+
31+
isolate->GetStackSample(state, samples, kMaxStackFrames, &info);
32+
33+
Local<StackTrace> stack = StackTrace::CurrentStackTrace(isolate, 255, StackTrace::kDetailed);
34+
if (stack.IsEmpty())
35+
{
36+
snprintf(buffer, kMaxStackJsonSize, "[]");
37+
return;
38+
}
39+
40+
pos += snprintf(&buffer[pos], kMaxStackJsonSize, "[");
41+
int count = stack->GetFrameCount();
42+
43+
for (int i = 0; i < count; i++)
44+
{
45+
Local<StackFrame> frame = stack->GetFrame(isolate, i);
46+
Local<String> fn_name = frame->GetFunctionName();
47+
48+
if (frame->IsEval())
49+
{
50+
fn_name = String::NewFromUtf8(isolate, "[eval]", NewStringType::kInternalized).ToLocalChecked();
51+
}
52+
else if (fn_name.IsEmpty() || fn_name->Length() == 0)
53+
{
54+
fn_name = String::NewFromUtf8(isolate, "?", NewStringType::kInternalized).ToLocalChecked();
55+
}
56+
else if (frame->IsConstructor())
57+
{
58+
fn_name = String::NewFromUtf8(isolate, "[constructor]", NewStringType::kInternalized).ToLocalChecked();
59+
}
60+
61+
String::Utf8Value function_name(isolate, fn_name);
62+
String::Utf8Value script_name(isolate, frame->GetScriptName());
63+
const int line_number = frame->GetLineNumber();
64+
const int column = frame->GetColumn();
65+
66+
pos += snprintf(&buffer[pos], kMaxStackJsonSize,
67+
"{\"function\":\"%s\",\"filename\":\"%s\",\"lineno\":%d,\"colno\":%d}",
68+
*function_name,
69+
*script_name,
70+
line_number,
71+
column);
72+
73+
if (i < count - 1)
74+
{
75+
pos += snprintf(&buffer[pos], kMaxStackJsonSize, ",");
76+
}
77+
}
78+
79+
pos += snprintf(&buffer[pos], kMaxStackJsonSize, "]");
80+
81+
{
82+
std::lock_guard<std::mutex> lock(interrupt_mutex);
83+
interrupt_done = true;
84+
}
85+
interrupt_cv.notify_one();
86+
}
87+
88+
void CaptureStackTrace(const FunctionCallbackInfo<Value> &args)
89+
{
90+
char buffer[kMaxStackJsonSize] = {0};
91+
92+
if (auto isolate = main_thread_isolate)
93+
{
94+
// Reset the interrupt_done flag
95+
{
96+
std::lock_guard<std::mutex> lock(interrupt_mutex);
97+
interrupt_done = false;
98+
}
99+
100+
isolate->RequestInterrupt(ExecutionInterrupted, buffer);
101+
102+
// Wait for the interrupt to complete
103+
std::unique_lock<std::mutex> lock(interrupt_mutex);
104+
interrupt_cv.wait(lock, []
105+
{ return interrupt_done; });
106+
}
107+
108+
Local<String> result = String::NewFromUtf8(args.GetIsolate(), buffer, NewStringType::kNormal).ToLocalChecked();
109+
args.GetReturnValue().Set(result);
110+
}
111+
112+
void SetMainIsolate(const FunctionCallbackInfo<Value> &args)
113+
{
114+
main_thread_isolate = args.GetIsolate();
115+
}
116+
117+
extern "C" NODE_MODULE_EXPORT void
118+
NODE_MODULE_INITIALIZER(Local<Object> exports,
119+
Local<Value> module,
120+
Local<Context> context)
121+
{
122+
Isolate *isolate = context->GetIsolate();
123+
124+
exports->Set(context,
125+
String::NewFromUtf8(isolate, "captureStackTrace", NewStringType::kInternalized).ToLocalChecked(),
126+
FunctionTemplate::New(isolate, CaptureStackTrace)->GetFunction(context).ToLocalChecked())
127+
.Check();
128+
129+
exports->Set(context,
130+
String::NewFromUtf8(isolate, "setMainIsolate", NewStringType::kInternalized).ToLocalChecked(),
131+
FunctionTemplate::New(isolate, SetMainIsolate)->GetFunction(context).ToLocalChecked())
132+
.Check();
133+
}

package.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"name": "cross-thread-stack-trace",
3+
"version": "0.1.0",
4+
"main": "index.js",
5+
"types": "index.d.ts",
6+
"license": "MIT",
7+
"scripts": {
8+
"install": "node-gyp configure && node-gyp build",
9+
"test": "node test.js"
10+
},
11+
"volta": {
12+
"node": "22.15.1"
13+
}
14+
}

test.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
const { Worker } = require('node:worker_threads');
2+
const crypto = require('node:crypto');
3+
4+
const { setMainIsolate } = require('.');
5+
6+
setMainIsolate();
7+
8+
const worker = new Worker('./worker.js');
9+
worker.on('exit', (code) => console.log(`Worker stopped with exit code ${code}`));
10+
worker.on('error', (error) => console.error('Worker error:', error));
11+
12+
function longWork() {
13+
for (let i = 0; i < 100; i++) {
14+
const salt = crypto.randomBytes(128).toString('base64');
15+
const hash = crypto.pbkdf2Sync('myPassword', salt, 10000, 512, 'sha512');
16+
}
17+
}
18+
19+
longWork();

worker.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
const { captureStackTrace } = require('.');
2+
3+
setTimeout(() => {
4+
console.time('captureStackTrace');
5+
const result = captureStackTrace();
6+
console.timeEnd('captureStackTrace');
7+
console.log(result);
8+
}, 500);
9+

0 commit comments

Comments
 (0)