-
Notifications
You must be signed in to change notification settings - Fork 5k
node:inspector: owner-refcount the shared CPU profiler; drop post() command errors without a callback #36019
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -29,11 +29,33 @@ | |
|
|
||
| namespace Bun { | ||
|
|
||
| // Store the profiling start time in microseconds since Unix epoch | ||
| static thread_local double s_profilingStartTime = 0.0; | ||
| // Set sampling interval to 1ms (1000 microseconds) to match Node.js | ||
| static thread_local int s_samplingInterval = 1000; | ||
| static thread_local bool s_isProfilerRunning = false; | ||
|
|
||
| // A single JSC::SamplingProfiler exists per VM, but multiple independent | ||
| // profile windows can be open at once (e.g. `--cpu-prof` at the CLI plus one | ||
| // or more `node:inspector` Sessions). Each caller acquires an owner, which | ||
| // records its own start timestamp. The underlying sampler only runs while at | ||
| // least one owner is live, and stopping one owner snapshots the shared trace | ||
| // buffer filtered to that owner's window without disturbing the others. | ||
| struct ProfilerOwner { | ||
| uint32_t id; | ||
| // Wall-clock start in microseconds since epoch, used as profile.startTime. | ||
| double startTimeMicros; | ||
| // Monotonic timestamp at acquisition, used to filter traces. | ||
| MonotonicTime startTimestamp; | ||
| }; | ||
|
|
||
| static thread_local WTF::Vector<ProfilerOwner> s_profilerOwners; | ||
| static thread_local uint32_t s_nextProfilerOwnerId = 1; | ||
| // Owner held on behalf of the legacy void-returning start/stop entry points | ||
| // (CLI `--cpu-prof` and per-Worker profiling). 0 means not held. | ||
| static thread_local uint32_t s_implicitProfilerOwner = 0; | ||
| // Traces drained from the SamplingProfiler while more than one owner is live. | ||
| // The referenced executables/callees stay alive via the profiler's | ||
| // m_liveCellPointers (visited during GC) until clearData() runs when the last | ||
| // owner releases. | ||
| static thread_local WTF::Vector<JSC::SamplingProfiler::StackTrace> s_retainedTraces; | ||
|
|
||
| void setSamplingInterval(int intervalMicroseconds) | ||
| { | ||
|
|
@@ -42,25 +64,33 @@ | |
|
|
||
| bool isCPUProfilerRunning() | ||
| { | ||
| return s_isProfilerRunning; | ||
| return !s_profilerOwners.isEmpty(); | ||
| } | ||
|
|
||
| uint32_t acquireCPUProfilerOwner(JSC::VM& vm) | ||
| { | ||
| uint32_t id = s_nextProfilerOwnerId++; | ||
| MonotonicTime now = MonotonicTime::now(); | ||
| double startTimeMicros = now.approximate<WTF::WallTime>().secondsSinceEpoch().value() * 1000000.0; | ||
| bool firstOwner = s_profilerOwners.isEmpty(); | ||
| s_profilerOwners.append({ id, startTimeMicros, now }); | ||
|
|
||
| if (firstOwner) { | ||
| auto stopwatch = WTF::Stopwatch::create(); | ||
| stopwatch->start(); | ||
| JSC::SamplingProfiler& samplingProfiler = vm.ensureSamplingProfiler(WTF::move(stopwatch)); | ||
| samplingProfiler.setTimingInterval(WTF::Seconds::fromMicroseconds(s_samplingInterval)); | ||
| samplingProfiler.noticeCurrentThreadAsJSCExecutionThread(); | ||
| samplingProfiler.start(); | ||
| } | ||
| return id; | ||
| } | ||
|
|
||
| void startCPUProfiler(JSC::VM& vm) | ||
| { | ||
| // Capture the wall clock time when profiling starts (before creating stopwatch) | ||
| // This will be used as the profile's startTime | ||
| s_profilingStartTime = MonotonicTime::now().approximate<WTF::WallTime>().secondsSinceEpoch().value() * 1000000.0; | ||
|
|
||
| // Create a stopwatch and start it | ||
| auto stopwatch = WTF::Stopwatch::create(); | ||
| stopwatch->start(); | ||
|
|
||
| JSC::SamplingProfiler& samplingProfiler = vm.ensureSamplingProfiler(WTF::move(stopwatch)); | ||
| samplingProfiler.setTimingInterval(WTF::Seconds::fromMicroseconds(s_samplingInterval)); | ||
| samplingProfiler.noticeCurrentThreadAsJSCExecutionThread(); | ||
| samplingProfiler.start(); | ||
| s_isProfilerRunning = true; | ||
| if (s_implicitProfilerOwner == 0) | ||
| s_implicitProfilerOwner = acquireCPUProfilerOwner(vm); | ||
| } | ||
|
Check failure on line 93 in src/jsc/bindings/BunCPUProfiler.cpp
|
||
|
|
||
| struct ProfileNode { | ||
| int id; | ||
|
|
@@ -270,13 +300,11 @@ | |
| } | ||
|
|
||
| // Helper to generate a minimal valid cpuprofile JSON with no samples | ||
| static WTF::String generateEmptyProfileJSON() | ||
| static WTF::String generateEmptyProfileJSON(double startTimeMicros) | ||
| { | ||
| // Return a minimal valid Chrome DevTools CPU profile format | ||
| // Use s_profilingStartTime if available, otherwise fall back to current time | ||
| long long timestamp; | ||
| if (s_profilingStartTime > 0) | ||
| timestamp = static_cast<long long>(s_profilingStartTime); | ||
| if (startTimeMicros > 0) | ||
| timestamp = static_cast<long long>(startTimeMicros); | ||
| else | ||
| timestamp = static_cast<long long>(WTF::WallTime::now().secondsSinceEpoch().value() * 1000000.0); | ||
|
|
||
|
|
@@ -289,53 +317,34 @@ | |
| return sb.toString(); | ||
| } | ||
|
|
||
| // Unified function that stops the profiler and generates requested output formats | ||
| void stopCPUProfiler(JSC::VM& vm, WTF::String* outJSON, WTF::String* outText) | ||
| // Builds the Chrome DevTools JSON and/or Markdown text report from the | ||
| // already-drained traces, including only those with timestamp >= minTimestamp | ||
| // and using startTimeMicros as the profile's startTime baseline. Must be | ||
| // called under JSLockHolder + DeferGC; frames reference live GC cells. | ||
| static void buildProfileOutput(JSC::VM& vm, WTF::Vector<JSC::SamplingProfiler::StackTrace>& stackTraces, | ||
| MonotonicTime minTimestamp, double startTimeMicros, WTF::String* outJSON, WTF::String* outText) | ||
| { | ||
| s_isProfilerRunning = false; | ||
|
|
||
| JSC::SamplingProfiler* profiler = vm.samplingProfiler(); | ||
| if (!profiler) { | ||
| if (outJSON) *outJSON = WTF::String(); | ||
| if (outText) *outText = WTF::String(); | ||
| return; | ||
| } | ||
|
|
||
| // JSLock is re-entrant, so always acquiring it handles both JS and shutdown contexts | ||
| JSC::JSLockHolder locker(vm); | ||
|
|
||
| // Defer GC while we're working with stack traces | ||
| JSC::DeferGC deferGC(vm); | ||
|
|
||
| // Pause the profiler while holding the lock | ||
| auto& lock = profiler->getLock(); | ||
| WTF::Locker profilerLocker { lock }; | ||
| profiler->pause(); | ||
|
|
||
| // releaseStackTraces() calls processUnverifiedStackTraces() internally | ||
| auto stackTraces = profiler->releaseStackTraces(); | ||
| profiler->clearData(); | ||
|
|
||
| // If neither output is requested, we're done | ||
| if (!outJSON && !outText) | ||
| return; | ||
|
|
||
| if (stackTraces.isEmpty()) { | ||
| if (outJSON) *outJSON = generateEmptyProfileJSON(); | ||
| if (outText) *outText = "No samples collected.\n"_s; | ||
| return; | ||
| } | ||
|
|
||
| // Sort traces by timestamp once for both formats | ||
| // Sort traces by timestamp once for both formats, including only those in | ||
| // this owner's window. | ||
| WTF::Vector<size_t> sortedIndices; | ||
| sortedIndices.reserveInitialCapacity(stackTraces.size()); | ||
| for (size_t i = 0; i < stackTraces.size(); i++) { | ||
| sortedIndices.append(i); | ||
| if (stackTraces[i].timestamp >= minTimestamp) | ||
| sortedIndices.append(i); | ||
| } | ||
| std::sort(sortedIndices.begin(), sortedIndices.end(), [&stackTraces](size_t a, size_t b) { | ||
| return stackTraces[a].timestamp < stackTraces[b].timestamp; | ||
| }); | ||
|
|
||
| if (sortedIndices.isEmpty()) { | ||
| if (outJSON) *outJSON = generateEmptyProfileJSON(startTimeMicros); | ||
| if (outText) *outText = "No samples collected.\n"_s; | ||
| return; | ||
| } | ||
|
|
||
| // Generate JSON format if requested | ||
| if (outJSON) { | ||
| // Map from stack frame signature to node ID | ||
|
|
@@ -357,8 +366,8 @@ | |
| WTF::Vector<int> samples; | ||
| WTF::Vector<long long> timeDeltas; | ||
|
|
||
| double startTime = s_profilingStartTime; | ||
| double lastTime = s_profilingStartTime; | ||
| double startTime = startTimeMicros; | ||
| double lastTime = startTimeMicros; | ||
|
|
||
| for (size_t idx : sortedIndices) { | ||
| auto& stackTrace = stackTraces[idx]; | ||
|
|
@@ -617,14 +626,14 @@ | |
|
|
||
| // Generate text format if requested | ||
| if (outText) { | ||
| double startTime = s_profilingStartTime; | ||
| double lastTime = s_profilingStartTime; | ||
| double startTime = startTimeMicros; | ||
| double lastTime = startTimeMicros; | ||
| double endTime = startTime; | ||
|
|
||
| WTF::HashMap<WTF::String, FunctionStats> functionStatsMap; | ||
|
|
||
| long long totalTimeUs = 0; | ||
| int totalSamples = static_cast<int>(stackTraces.size()); | ||
| int totalSamples = static_cast<int>(sortedIndices.size()); | ||
|
|
||
| for (size_t idx : sortedIndices) { | ||
| auto& stackTrace = stackTraces[idx]; | ||
|
|
@@ -928,6 +937,85 @@ | |
| } | ||
| } | ||
|
|
||
| void releaseCPUProfilerOwner(JSC::VM& vm, uint32_t ownerId, WTF::String* outJSON, WTF::String* outText) | ||
| { | ||
| size_t ownerIndex = WTF::notFound; | ||
| for (size_t i = 0; i < s_profilerOwners.size(); i++) { | ||
| if (s_profilerOwners[i].id == ownerId) { | ||
| ownerIndex = i; | ||
| break; | ||
| } | ||
| } | ||
| if (ownerIndex == WTF::notFound) { | ||
| if (outJSON) *outJSON = WTF::String(); | ||
| if (outText) *outText = WTF::String(); | ||
| return; | ||
| } | ||
| ProfilerOwner owner = s_profilerOwners[ownerIndex]; | ||
| s_profilerOwners.removeAt(ownerIndex); | ||
| bool lastOwner = s_profilerOwners.isEmpty(); | ||
|
|
||
| JSC::SamplingProfiler* profiler = vm.samplingProfiler(); | ||
| if (!profiler) { | ||
| if (outJSON) *outJSON = generateEmptyProfileJSON(owner.startTimeMicros); | ||
| if (outText) *outText = "No samples collected.\n"_s; | ||
| s_retainedTraces.clear(); | ||
| return; | ||
| } | ||
|
|
||
| // JSLock is re-entrant, so always acquiring it handles both JS and shutdown contexts | ||
| JSC::JSLockHolder locker(vm); | ||
| JSC::DeferGC deferGC(vm); | ||
|
|
||
| auto& lock = profiler->getLock(); | ||
| WTF::Locker profilerLocker { lock }; | ||
|
|
||
| // Drain newly-collected traces into the retained buffer. releaseStackTraces() | ||
| // moves m_stackTraces out but leaves m_liveCellPointers intact, so the | ||
| // executables/callees referenced by s_retainedTraces stay GC-live until the | ||
| // final owner's clearData() below. | ||
| { | ||
| auto newTraces = profiler->releaseStackTraces(); | ||
| s_retainedTraces.reserveCapacity(s_retainedTraces.size() + newTraces.size()); | ||
| for (auto& trace : newTraces) | ||
| s_retainedTraces.append(WTF::move(trace)); | ||
| } | ||
|
Check failure on line 982 in src/jsc/bindings/BunCPUProfiler.cpp
|
||
|
Comment on lines
+973
to
+982
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 The GC-safety of Extended reasoning...What the bug isThis PR introduces But Code path that triggers it
Why existing code doesn't prevent itThe Why this is new in this PRBefore this PR, ImpactUser-reachable UAF. FixRoute |
||
|
|
||
| buildProfileOutput(vm, s_retainedTraces, owner.startTimestamp, owner.startTimeMicros, outJSON, outText); | ||
|
|
||
| if (lastOwner) { | ||
| profiler->pause(); | ||
| profiler->clearData(); | ||
| s_retainedTraces.clear(); | ||
| } else { | ||
| // Bound memory: drop traces older than every remaining owner's window. | ||
| // StackTrace is move-constructible but not move-assignable, so rebuild | ||
| // rather than compact in place. | ||
| MonotonicTime minStart = s_profilerOwners[0].startTimestamp; | ||
| for (size_t i = 1; i < s_profilerOwners.size(); i++) | ||
| minStart = std::min(minStart, s_profilerOwners[i].startTimestamp); | ||
| WTF::Vector<JSC::SamplingProfiler::StackTrace> survivors; | ||
| survivors.reserveInitialCapacity(s_retainedTraces.size()); | ||
| for (auto& t : s_retainedTraces) { | ||
| if (t.timestamp >= minStart) | ||
| survivors.append(WTF::move(t)); | ||
| } | ||
| s_retainedTraces = WTF::move(survivors); | ||
| } | ||
| } | ||
|
|
||
| void stopCPUProfiler(JSC::VM& vm, WTF::String* outJSON, WTF::String* outText) | ||
| { | ||
| if (s_implicitProfilerOwner == 0) { | ||
| if (outJSON) *outJSON = WTF::String(); | ||
| if (outText) *outText = WTF::String(); | ||
| return; | ||
| } | ||
| uint32_t owner = s_implicitProfilerOwner; | ||
| s_implicitProfilerOwner = 0; | ||
| releaseCPUProfilerOwner(vm, owner, outJSON, outText); | ||
| } | ||
|
|
||
| } // namespace Bun | ||
|
|
||
| extern "C" void Bun__startCPUProfiler(JSC::VM* vm) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔴 The
if (!Bun::isCPUProfilerRunning()) Bun::startCPUProfiler(...)guard inJSWorker.cpp(jsWorkerPrototypeFunction_startCpuProfileInternalBody, line 818) was not updated for the new semantics:isCPUProfilerRunning()now returns true when any owner (including an explicitnode:inspectorSession owner) exists, soworker.startCpuProfile()skips acquiring the implicit owner andhandle.stop()then hits the news_implicitProfilerOwner == 0early-return, giving the parentkEmptyCpuProfileJSON— a regression from before this PR. Drop the guard (and the mirror at line 843);startCPUProfiler()is now internally idempotent ons_implicitProfilerOwner.Extended reasoning...
What the bug is
This PR changes the semantics of two helpers in
BunCPUProfiler.cpp:startCPUProfiler()used to mean "unconditionally start the VM sampler"; it now means "acquire the implicit owner (s_implicitProfilerOwner) if not already held".isCPUProfilerRunning()used to read a singles_isProfilerRunningbool; it now returns!s_profilerOwners.isEmpty(), i.e. true when any owner exists — implicit or explicit (anode:inspectorSession that calledProfiler.start).The header comment at
BunCPUProfiler.h:25-27explicitly names "per-Worker profiling" as an implicit-owner client. But that caller was not updated.src/jsc/bindings/webcore/JSWorker.cpp:818-819still does:and the stop side at
:843-844:Under the old single-bool model this guard just avoided a redundant re-start. Under the new model it prevents the Worker's implicit owner from ever being acquired whenever any other owner exists on that thread.
Step-by-step trigger
All profiler state is
thread_localandpostTaskToWorkerGlobalScoperuns the lambda on the worker's JS thread, so a Session inside the worker and the parent'sworker.startCpuProfile()share the sames_profilerOwners/s_implicitProfilerOwner.node:inspectorSession and callsProfiler.start. This routes tojsFunction_startCPUProfiler→acquireCPUProfilerOwner, which appends an explicit owner tos_profilerOwners.s_implicitProfilerOwnerstays0.worker.startCpuProfile(). The posted task atJSWorker.cpp:818evaluatesisCPUProfilerRunning()→!s_profilerOwners.isEmpty()→ true, so it skipsstartCPUProfiler().s_implicitProfilerOwnerstays0.handle.stop(). The posted task atJSWorker.cpp:843seesisCPUProfilerRunning()== true and callsstopCPUProfiler().stopCPUProfiler()checkss_implicitProfilerOwner == 0, hits the early-return, and writes*outJSON = WTF::String().JSWorker.cpp:845-846,result.isEmpty()is true, so the parent receiveskEmptyCpuProfileJSON— zero samples.Why existing code doesn't prevent it
The guard at
:818was correct pre-PR becauseisCPUProfilerRunning()andstartCPUProfiler()operated on the same single bool. Post-PR they operate on different state: the guard checks "any owner" but the call it guards only manipulates the implicit owner. Nothing else on this path touchess_implicitProfilerOwner.Impact
Before this PR the same sequence returned a populated profile (the old
stopCPUProfilerunconditionally drained the shared buffer). After this PR it silently returns an empty stub. This is exactly the Session×implicit-owner interaction the PR is fixing for Session×Session and Session×--cpu-prof, but the per-Worker path — which the PR's own header comment names as an implicit-owner client — is left with a stale guard that defeats it. Per REVIEW.md: "Fix the whole class in the same PR… every caller of a changed helper. Prefer moving the guard into the shared helper."Fix
Drop both guards —
startCPUProfiler()is now internally idempotent ons_implicitProfilerOwner, andstopCPUProfiler()already handles the "not held" case: