From b4c54046b30945e5f102a276342800654b12a690 Mon Sep 17 00:00:00 2001 From: Bryan English Date: Thu, 5 Mar 2026 12:21:21 -0500 Subject: [PATCH 1/2] diagnostics_channel: add USDT probes Add a shared USDT publish probe for string-named channels. An attached tracer activates event production without JavaScript subscribers, including SQLite queries, permission events and opted-in FIPS indicators. Keep real subscriber and store lifecycles separate from tracer interest. Do not replay queued FIPS events to subscribers that arrived later. Read the semaphore view through the binding on each check so startup snapshot restoration cannot leave a stale copy. Use a native enabled check where the view is a constant-one placeholder. Keep unsupported builds inert. Signed-off-by: Bryan English Assisted-by: Pi using GLM-5.3 --- configure.py | 41 ++ doc/api/diagnostics_channel.md | 90 +++ lib/diagnostics_channel.js | 52 +- node.gyp | 41 ++ src/crypto/crypto_util.cc | 65 ++- src/node_diagnostics_channel.cc | 120 +++- src/node_diagnostics_channel.h | 35 ++ src/node_provider.d | 3 + src/node_sqlite.cc | 90 ++- src/node_sqlite.h | 12 + src/node_usdt.h | 70 +++ src/permission/permission.cc | 4 +- test/common/usdt.js | 18 + .../diagnostics-channel-usdt-publish.js | 15 + ...agnostics-channel-crypto-fips-indicator.js | 56 +- .../test-diagnostics-channel-usdt-bpftrace.js | 65 +++ .../parallel/test-diagnostics-channel-usdt.js | 535 ++++++++++++++++++ .../test-sqlite-diagnostic-channel.js | 208 ++++++- 18 files changed, 1487 insertions(+), 33 deletions(-) create mode 100644 src/node_provider.d create mode 100644 src/node_usdt.h create mode 100644 test/common/usdt.js create mode 100644 test/fixtures/diagnostics-channel-usdt-publish.js create mode 100644 test/parallel/test-diagnostics-channel-usdt-bpftrace.js create mode 100644 test/parallel/test-diagnostics-channel-usdt.js diff --git a/configure.py b/configure.py index 8b3332a461f4..c7413be0c8e9 100755 --- a/configure.py +++ b/configure.py @@ -1066,6 +1066,12 @@ default=None, help='do not install the bundled Amaro (TypeScript utils)') +parser.add_argument('--without-dtrace', + action='store_true', + dest='without_dtrace', + default=None, + help='build without DTrace/USDT probe support') + parser.add_argument('--without-lief', action='store_true', dest='without_lief', @@ -1360,6 +1366,31 @@ def B(value): def to_utf8(s): return s if isinstance(s, str) else s.decode("utf-8") +def has_working_dtrace_h(): + """Check whether a dtrace tool that supports -h is available. + + Supported on Linux (SystemTap dtrace wrapper), macOS, FreeBSD, and + illumos/SmartOS (native DTrace). Non-Linux platforms require -xnolibs + to avoid loading standard D libraries during header generation.""" + dtrace = shutil.which('dtrace') + if dtrace is None: + return False + # -xnolibs is required on macOS/FreeBSD/illumos (native DTrace) to avoid + # loading standard D libraries. Linux (SystemTap wrapper) does not + # recognise this flag, so only pass it on non-Linux platforms. + cmd = [dtrace, '-h', '-s', '/dev/stdin', '-o', '/dev/null'] + if sys.platform != 'linux': + cmd.insert(2, '-xnolibs') + try: + proc = subprocess.run( + cmd, + input=b'provider _test { probe _test(); };', + capture_output=True, timeout=10) + return proc.returncode == 0 + except (OSError, subprocess.TimeoutExpired) as e: + warn('dtrace probe check failed: %s' % e) + return False + def pkg_config(pkg): """Run pkg-config on the specified package Returns ("-l flags", "-I flags", "-L flags", "version") @@ -2180,6 +2211,16 @@ def configure_node(o): print('Warning! Loading builtin modules from disk is for development') o['variables']['node_builtin_modules_path'] = options.node_builtin_modules_path + o['variables']['node_no_usdt'] = b(options.without_dtrace) + use_dtrace = not options.without_dtrace and has_working_dtrace_h() + o['variables']['node_use_dtrace'] = b(use_dtrace) + if options.without_dtrace: + print('USDT probes: disabled (--without-dtrace)') + elif use_dtrace: + print('USDT probes: enabled (dtrace -h, semaphore support)') + else: + print('USDT probes: fallback (sys/sdt.h) or disabled') + def configure_napi(output): version = getnapibuildversion.get_napi_version() output['variables']['napi_build_version'] = version diff --git a/doc/api/diagnostics_channel.md b/doc/api/diagnostics_channel.md index e63f23829f90..7f7ad345553c 100644 --- a/doc/api/diagnostics_channel.md +++ b/doc/api/diagnostics_channel.md @@ -113,6 +113,10 @@ added: Check if there are active subscribers to the named channel. This is helpful if the message you want to send might be expensive to prepare. +For a string name this also returns `true` when a USDT tracer is attached to +the process, even if the channel has no subscribers and no channel object +exists yet. Symbol names are unaffected by tracers. + This API is optional but helpful when trying to publish messages from very performance-sensitive code. @@ -362,6 +366,10 @@ added: Check if there are active subscribers to this channel. This is helpful if the message you want to send might be expensive to prepare. +This also returns `true` when a USDT tracer is attached to the process and +the channel has a string name, even without subscribers. Symbol names are +unaffected by tracers. + This API is optional but helpful when trying to publish messages from very performance-sensitive code. @@ -1529,6 +1537,88 @@ another async task is triggered internally which fails and then the sync part of the function then throws and error two `error` events will be emitted, one for the sync error and one for the async error. +### USDT probes + + + +> Stability: 1 - Experimental + +Node.js exposes a USDT (User-Level Statically Defined Tracing) probe for +diagnostics channel publish events, enabling external observability tools +such as `bpftrace`, DTrace, and `perf` to trace channel activity. An attached +tracer counts as interest in every string-named channel, since there is one +shared probe. `hasSubscribers` returns `true` for those channels and +publishers that check it produce their events, even without JavaScript +subscribers. Subscriber and store lifecycles are unaffected, and tracing +helpers like `tracingChannel.traceSync` produce their events without store +scoping when no store is bound. + +Attaching or detaching a tracer takes effect at the next subscriber or +publish check. It cannot reconstruct an event for work that already started, +such as a span in flight or a database statement already running. Detaching +stops future probe-only production but never removes real subscribers. + +#### Probe: `node:dc__publish` + +Fired when a message is published to a string-named diagnostics channel. +When published from native (C++) code and a tracer is attached, the probe +fires regardless of subscriber state. When published from JavaScript, the +probe fires if the channel has active subscribers or a tracer is attached. + +* `arg0` {const char\*} The channel name (UTF-8). +* `arg1` {const void\*} An opaque pointer to the V8 message object, or `NULL` + if the published message is not a JavaScript object (e.g., a string, number, + or `null`). **Warning:** This pointer is unstable and must NOT be + dereferenced by tracing scripts. V8's garbage collector may move the + underlying object at any time. The pointer is valid only for the + duration of the probe callback and must not be stored or compared + across separate probe firings. + +#### Platform support + +At `./configure` time, Node.js checks for a working `dtrace` tool and +uses `dtrace -h` to generate a probe header. Pass `--without-dtrace` to +`./configure` to disable probe support entirely. + +* **Linux**: Install the `systemtap-sdt-dev` package (Debian/Ubuntu) or + `systemtap-sdt-devel` (Fedora/RHEL) before building Node.js. The + SystemTap `dtrace` wrapper generates a header with semaphore support, + giving the probe zero overhead when no tracer is attached. +* **macOS**: Supported natively via DTrace. The probe instruction is + patched to a no-op by the kernel when no tracer is attached, but the + JS-to-C++ call for `emitPublishProbe` is still incurred on every + publish to a string-named channel with subscribers. +* **FreeBSD**: Supported natively via DTrace, with the same + characteristics as macOS. +* **illumos/SmartOS**: Supported natively via DTrace, with the same + characteristics as macOS. + +If `dtrace` is not found but `` is available, the probe falls +back to always-enabled mode. On platforms where neither is available, +the probe compiles to a no-op with zero runtime overhead. + +#### Example: bpftrace (Linux) + +```bash +sudo bpftrace -e ' + usdt:./out/Release/node:node:dc__publish { + printf("channel: %s\n", str(arg0)); + } +' -c './out/Release/node app.js' +``` + +#### Example: DTrace (macOS/FreeBSD) + +```bash +sudo dtrace -n ' + node*:::dc-publish { + printf("channel: %s\n", copyinstr(arg0)); + } +' -c './out/Release/node app.js' +``` + ### Built-in Channels #### Console diff --git a/lib/diagnostics_channel.js b/lib/diagnostics_channel.js index 54c25839e611..5954174de219 100644 --- a/lib/diagnostics_channel.js +++ b/lib/diagnostics_channel.js @@ -37,6 +37,19 @@ const dc_binding = internalBinding('diagnostics_channel'); const { WeakReference, kEmptyObject } = require('internal/util'); const { isPromise } = require('internal/util/types'); +// One shared publish probe means a tracer is interested in every +// string-named channel. Like publish(), the semaphore view is read through +// the binding on every call, so a view captured while building a startup +// snapshot cannot go stale after it is deserialized. On tiers where the +// view is a constant-one placeholder the binding exposes probeEnabled() +// and that query alone decides. +function isTracerAttached() { + const probeSemaphore = dc_binding.probeSemaphore; + if (probeSemaphore === undefined || probeSemaphore[0] === 0) return false; + return dc_binding.probeEnabled === undefined || + dc_binding.probeEnabled(); +} + // Can't delete when weakref count reaches 0 as it could increment again. // Only GC can be used as a valid time to clean up the channels map. class WeakRefMap extends SafeMap { @@ -188,6 +201,17 @@ class ActiveChannel { } publish(data) { + // Read the semaphore through the binding on every publish. The view is + // created once per context and wraps static native memory, but a cached + // reference (for example one captured while building a startup snapshot) + // would be a stale copy after the snapshot is deserialized, keeping + // probes disabled even while a tracer is attached. + const probeSemaphore = dc_binding.probeSemaphore; + if (probeSemaphore !== undefined && + probeSemaphore[0] > 0 && + typeof this.name === 'string') { + dc_binding.emitPublishProbe(this.name, data); + } const subscribers = this._subscribers; for (let i = 0; i < (subscribers?.length || 0); i++) { try { @@ -248,16 +272,32 @@ class Channel { } get hasSubscribers() { - return false; + // An attached tracer counts as interest in string-named channels. Symbol + // names never carry the shared probe. + return typeof this.name === 'string' && isTracerAttached(); } - publish() {} + publish(data) { + // Mirrors ActiveChannel.publish: no native call without tracer + // interest, and emitPublishProbe rechecks the enabled state on + // placeholder tiers where the view is a constant one. + if (typeof this.name !== 'string') return; + const probeSemaphore = dc_binding.probeSemaphore; + if (probeSemaphore !== undefined && probeSemaphore[0] > 0) { + dc_binding.emitPublishProbe(this.name, data); + } + } runStores(data, fn, thisArg, ...args) { + // No stores are bound on an inactive channel, so reaching the publish + // probe is the only effect tracer interest can have here. + this.publish(data); return ReflectApply(fn, thisArg, args); } - withStoreScope() { + withStoreScope(data) { + // Publish when the scope begins, matching the active channel scope. + this.publish(data); // Return no-op disposable for inactive channels return { [SymbolDispose]() {}, @@ -288,9 +328,11 @@ function unsubscribe(name, subscription) { function hasSubscribers(name) { const channel = channels.get(name); - if (!channel) return false; + if (channel) return channel.hasSubscribers; - return channel.hasSubscribers; + // A tracer is interested even in a string name that has no channel object + // yet. Symbol names never carry the shared probe. + return typeof name === 'string' && isTracerAttached(); } const boundedEvents = [ diff --git a/node.gyp b/node.gyp index 52f421f3181c..e4ea17b362a4 100644 --- a/node.gyp +++ b/node.gyp @@ -46,6 +46,8 @@ 'node_use_dtls%': 'false', 'node_use_sqlite%': 'true', 'node_use_ffi%': 'false', + 'node_use_dtrace%': 'false', + 'node_no_usdt%': 'false', 'node_use_v8_platform%': 'true', 'node_enable_v8_vtunejit%': 'false', 'node_v8_options%': '', @@ -276,6 +278,8 @@ 'src/node_metadata.h', 'src/node_mutex.h', 'src/node_diagnostics_channel.h', + 'src/node_usdt.h', + 'src/node_provider.d', 'src/node_modules.h', 'src/node_object_wrap.h', 'src/node_options.h', @@ -901,6 +905,43 @@ 'WARNING_CFLAGS': [ '-Werror' ], }, }], + [ 'node_no_usdt=="true"', { + 'defines': [ 'NODE_NO_USDT=1' ], + }], + [ 'node_use_dtrace=="true"', { + 'defines': [ 'NODE_HAVE_DTRACE=1' ], + 'conditions': [ + [ 'OS=="linux"', { + 'actions': [ + { + 'action_name': 'node_dtrace_header', + 'inputs': [ 'src/node_provider.d' ], + 'outputs': [ '<(SHARED_INTERMEDIATE_DIR)/node_provider.h' ], + 'action': [ + 'dtrace', '-h', + '-s', 'src/node_provider.d', + '-o', '<(SHARED_INTERMEDIATE_DIR)/node_provider.h', + ], + }, + ], + }, { + # macOS, FreeBSD, illumos: native DTrace requires -xnolibs + # to avoid loading kernel D libraries during header generation. + 'actions': [ + { + 'action_name': 'node_dtrace_header', + 'inputs': [ 'src/node_provider.d' ], + 'outputs': [ '<(SHARED_INTERMEDIATE_DIR)/node_provider.h' ], + 'action': [ + 'dtrace', '-h', '-xnolibs', + '-s', 'src/node_provider.d', + '-o', '<(SHARED_INTERMEDIATE_DIR)/node_provider.h', + ], + }, + ], + }], + ], + }], [ 'node_builtin_modules_path!=""', { 'defines': [ 'NODE_BUILTIN_MODULES_PATH="<(node_builtin_modules_path)"' ], }], diff --git a/src/crypto/crypto_util.cc b/src/crypto/crypto_util.cc index 568325a99462..5f8b1bc0b60c 100644 --- a/src/crypto/crypto_util.cc +++ b/src/crypto/crypto_util.cc @@ -226,6 +226,9 @@ struct FipsIndicatorEvent { bool blocked; uint32_t count = 1; uint32_t dropped = 0; + // Subscription epoch at queue time, used by Drain() to keep queued events + // from reaching a subscriber that arrived later. + uint64_t subscription_generation = 0; }; Local GetFipsIndicatorEventTemplate(Environment* env) { @@ -294,6 +297,9 @@ class FipsIndicatorState final { { Mutex::ScopedLock lock(mutex_); + // An attached tracer may still be waiting for the queued events, so + // keep them and their per-event generations. + if (diagnostics_channel::IsProbeEnabled()) return; events_.clear(); dropped_events_ = 0; } @@ -319,25 +325,41 @@ class FipsIndicatorState final { const int result = reject_unapproved_.load(std::memory_order_acquire) ? 0 : previous_result; - if (!active_.load(std::memory_order_acquire)) return result; + if (!active_.load(std::memory_order_acquire) && + !diagnostics_channel::IsProbeEnabled()) { + return result; + } const bool blocked = result == 0; { Mutex::ScopedLock lock(mutex_); - if (env_ != nullptr && active_.load(std::memory_order_relaxed)) { + // Recheck interest under the lock: it may have changed since the fast + // path above. env_ keeps the queue gated on the + // --enable-fips-indicator-events opt-in. + if (env_ != nullptr && (active_.load(std::memory_order_relaxed) || + diagnostics_channel::IsProbeEnabled())) { const std::string operation_string = operation == nullptr ? "" : operation; const std::string reason_string = reason == nullptr ? "" : reason; + const uint64_t subscription_generation = + subscription_generation_.load(std::memory_order_relaxed); const auto existing = std::find_if( events_.begin(), events_.end(), [&](const FipsIndicatorEvent& event) { return event.operation == operation_string && - event.reason == reason_string && event.blocked == blocked; + event.reason == reason_string && + event.blocked == blocked && + event.subscription_generation == subscription_generation; }); if (existing == events_.end()) { if (events_.size() < kMaxPendingFipsIndicatorEvents) { - events_.push_back({operation_string, reason_string, blocked}); + events_.push_back({operation_string, + reason_string, + blocked, + 1, + 0, + subscription_generation}); } else if (dropped_events_ != UINT32_MAX) { dropped_events_++; } @@ -368,14 +390,21 @@ class FipsIndicatorState final { dropped_events_ = 0; dispatch_scheduled_ = false; } - if (events.empty() || !channel_ || !channel_->HasSubscribers()) return; + if (events.empty() || !channel_) return; + const bool has_subscribers = channel_->HasSubscribers(); + // Without a subscriber or a tracer, nobody is waiting for the batch. + if (!has_subscribers && !diagnostics_channel::IsProbeEnabled()) return; Isolate* isolate = env->isolate(); HandleScope handle_scope(isolate); Local context = env->context(); - const uint64_t subscription_generation = subscription_generation_; + const uint64_t subscription_generation = + subscription_generation_.load(std::memory_order_relaxed); for (const auto& event : events) { - if (subscription_generation_ != subscription_generation) return; + if (subscription_generation_.load(std::memory_order_relaxed) != + subscription_generation) { + return; + } MaybeLocal values[] = { OneByteString(isolate, event.operation), OneByteString(isolate, event.reason), @@ -389,8 +418,22 @@ class FipsIndicatorState final { .ToLocal(&value)) { return; } - channel_->Publish(env, value); - if (subscription_generation_ != subscription_generation) return; + if (!has_subscribers || + event.subscription_generation == subscription_generation) { + // Without subscribers, Publish() only emits the probe. + channel_->Publish(env, value); + } else if (diagnostics_channel::IsProbeEnabled()) { + // Queued before the current subscription epoch: an attached tracer + // still observes the publish, a subscriber that arrived later + // must not. + NODE_DC_PUBLISH_PROBE( + kFipsIndicatorChannel.data(), + static_cast(*value)); + } + if (subscription_generation_.load(std::memory_order_relaxed) != + subscription_generation) { + return; + } } } @@ -417,7 +460,9 @@ class FipsIndicatorState final { std::deque events_; uint32_t dropped_events_ = 0; bool dispatch_scheduled_ = false; - uint64_t subscription_generation_ = 0; + // Read from OpenSSL indicator callbacks on foreign threads, so the epoch + // counter is atomic. + std::atomic subscription_generation_{0}; }; } // namespace diff --git a/src/node_diagnostics_channel.cc b/src/node_diagnostics_channel.cc index 2593f6eab90f..ad6f60c2ba59 100644 --- a/src/node_diagnostics_channel.cc +++ b/src/node_diagnostics_channel.cc @@ -1,4 +1,5 @@ #include "node_diagnostics_channel.h" +#include "node_usdt.h" #include "base_object-inl.h" #include "env-inl.h" @@ -8,9 +9,23 @@ #include +#if defined(NODE_HAVE_DTRACE) && defined(STAP_HAS_SEMAPHORES) +// Definition of the USDT probe semaphore declared in the dtrace-generated +// node_provider.h. STAP_HAS_SEMAPHORES is only defined by the SystemTap +// dtrace wrapper (Linux), where the .probes ELF section attribute is valid. +// On macOS/FreeBSD/illumos (native DTrace) there is no semaphore variable; +// the kernel handles probe enabling directly. +// The generated header declares this symbol with C++ linkage (no extern "C" +// wrapper), so this definition must also use C++ linkage to ensure the +// linker resolves the same mangled symbol. +unsigned short node_dc__publish_semaphore + __attribute__((section(".probes"))); +#endif + namespace node { namespace diagnostics_channel { +using v8::ArrayBuffer; using v8::Context; using v8::Function; using v8::FunctionCallbackInfo; @@ -23,6 +38,7 @@ using v8::Object; using v8::ObjectTemplate; using v8::SnapshotCreator; using v8::String; +using v8::Uint16Array; using v8::Value; BindingData::BindingData(Realm* realm, @@ -125,6 +141,9 @@ void BindingData::Deserialize(Local context, BindingData* binding = realm->AddBindingData( holder, static_cast(info)); CHECK_NOT_NULL(binding); +#if NODE_HAVE_USDT + SetupProbeSemaphore(Isolate::GetCurrent(), holder); +#endif } void BindingData::SetChannelStatusCallback(uint32_t index, @@ -153,12 +172,47 @@ void BindingData::NotifyChannelInactive( if (it != binding->channel_status_callbacks_.end()) it->second(false); } +#if NODE_HAVE_USDT +void BindingData::SetupProbeSemaphore(Isolate* isolate, Local target) { + // Expose the USDT probe semaphore as a Uint16Array so JS can check whether + // a tracer is attached without crossing the JS/C++ boundary. +#ifdef V8_ENABLE_SANDBOX + // The real semaphore is a static symbol outside the sandbox and cannot + // back a JS-visible ArrayBuffer. Use an always-enabled, V8-allocated + // semaphore instead: JS then always calls emitPublishProbe(), which + // checks NODE_DC_PUBLISH_ENABLED() and returns early when no tracer is + // attached (the same semantics as the macOS tier). + auto backing = ArrayBuffer::NewBackingStore(isolate, sizeof(uint16_t)); + *static_cast(backing->Data()) = 1; // NOLINT(runtime/int) +#else + auto backing = ArrayBuffer::NewBackingStore( + NodeDCPublishSemaphore(), + sizeof(unsigned short), + [](void*, size_t, void*) {}, // no-op deleter — memory is static + nullptr); +#endif + Local ab = ArrayBuffer::New(isolate, std::move(backing)); + Local semaphore = Uint16Array::New(ab, 0, 1); + target + ->Set(isolate->GetCurrentContext(), + FIXED_ONE_BYTE_STRING(isolate, "probeSemaphore"), + semaphore) + .Check(); +} +#endif + void BindingData::CreatePerIsolateProperties(IsolateData* isolate_data, Local target) { Isolate* isolate = isolate_data->isolate(); SetMethod(isolate, target, "linkNativeChannel", LinkNativeChannel); SetMethod(isolate, target, "notifyChannelActive", NotifyChannelActive); SetMethod(isolate, target, "notifyChannelInactive", NotifyChannelInactive); +#if NODE_HAVE_USDT + SetMethod(isolate, target, "emitPublishProbe", EmitPublishProbe); +#endif +#if NODE_DC_SEMAPHORE_PLACEHOLDER + SetMethod(isolate, target, "probeEnabled", ProbeEnabled); +#endif } void BindingData::CreatePerContextProperties(Local target, @@ -168,6 +222,9 @@ void BindingData::CreatePerContextProperties(Local target, Realm* realm = Realm::GetCurrent(context); BindingData* const binding = realm->AddBindingData(target); if (binding == nullptr) return; +#if NODE_HAVE_USDT + SetupProbeSemaphore(realm->isolate(), target); +#endif } void BindingData::RegisterExternalReferences( @@ -175,7 +232,35 @@ void BindingData::RegisterExternalReferences( registry->Register(LinkNativeChannel); registry->Register(NotifyChannelActive); registry->Register(NotifyChannelInactive); +#if NODE_HAVE_USDT + registry->Register(EmitPublishProbe); +#endif +#if NODE_DC_SEMAPHORE_PLACEHOLDER + registry->Register(ProbeEnabled); +#endif +} + +#if NODE_HAVE_USDT +void BindingData::EmitPublishProbe(const FunctionCallbackInfo& args) { + CHECK_GE(args.Length(), 2); + CHECK(args[0]->IsString()); + if (!NODE_DC_PUBLISH_ENABLED()) return; + Isolate* isolate = args.GetIsolate(); + Utf8Value name(isolate, args[0]); + const void* msg = args[1]->IsObject() + ? static_cast(*args[1].As()) + : nullptr; + NODE_DC_PUBLISH_PROBE(*name, msg); +} +#endif + +#if NODE_DC_SEMAPHORE_PLACEHOLDER +// The JS semaphore view is a constant one on these tiers, so JS calls this +// to learn whether a tracer is actually attached. +void BindingData::ProbeEnabled(const FunctionCallbackInfo& args) { + args.GetReturnValue().Set(IsProbeEnabled()); } +#endif Channel::Channel(Environment* env, Local wrap, @@ -279,15 +364,34 @@ void Channel::CachePublishFn(Isolate* isolate, Local js_channel) { } void Channel::Publish(Environment* env, Local message) { - if (!HasSubscribers()) return; + // Fire the USDT probe on code paths that return before reaching JS. + // When JS IS reached, ActiveChannel.publish() fires the probe itself. + // Tracers observe every publish attempt that reaches this point. + auto fire_usdt_probe = [&]() { + if (NODE_DC_PUBLISH_ENABLED()) { + NODE_DC_PUBLISH_PROBE( + name_.c_str(), + message->IsObject() ? static_cast(*message.As()) + : nullptr); + } + }; - if (binding_data_ == nullptr) return; + if (!HasSubscribers()) { + fire_usdt_probe(); + return; + } - if (js_channel_.IsEmpty()) return; + if (js_channel_.IsEmpty()) { + fire_usdt_probe(); + return; + } // Publishing is not possible during shutdown or GC. DCHECK(env->can_call_into_js()); - if (!env->can_call_into_js()) return; + if (!env->can_call_into_js()) { + fire_usdt_probe(); + return; + } Isolate* isolate = env->isolate(); HandleScope handle_scope(isolate); @@ -298,12 +402,16 @@ void Channel::Publish(Environment* env, Local message) { // publish_fn_ is eagerly cached by Link() when the channel already has // subscribers at link time. For channels linked before any JS subscriber - // existed, cache it here on the first publish — happens exactly once. + // existed, cache it here on the first publish after linking. if (publish_fn_.IsEmpty()) { CachePublishFn(isolate, js_channel); - if (publish_fn_.IsEmpty()) return; + if (publish_fn_.IsEmpty()) { + fire_usdt_probe(); + return; + } } + // When JS is reached, ActiveChannel.publish() fires the probe. Local argv[] = {message}; USE(publish_fn_.Get(isolate)->Call(context, js_channel, 1, argv)); } diff --git a/src/node_diagnostics_channel.h b/src/node_diagnostics_channel.h index c8c1a79994b2..3dce3ffb91d6 100644 --- a/src/node_diagnostics_channel.h +++ b/src/node_diagnostics_channel.h @@ -11,6 +11,7 @@ #include "aliased_buffer.h" #include "base_object.h" #include "node_snapshotable.h" +#include "node_usdt.h" namespace node { class ExternalReferenceRegistry; @@ -19,6 +20,24 @@ namespace diagnostics_channel { class Channel; +// True when an external USDT tracer is attached to the shared publish +// probe, so the interest is process-wide. Never cached. +inline bool IsProbeEnabled() { + return NODE_DC_PUBLISH_ENABLED(); +} + +// The JS-visible semaphore view is a constant-one placeholder whenever the +// real static semaphore cannot back a JS ArrayBuffer (sandboxed builds) or +// the platform has no semaphore variable (native DTrace, such as macOS). +// Those tiers expose the native probeEnabled truth query on the binding so +// JS never reads the placeholder value as proof that a tracer is attached. +// Ordinary Linux keeps the cheap real semaphore path with no extra query. +#if NODE_HAVE_USDT && (defined(V8_ENABLE_SANDBOX) || !NODE_USDT_HAVE_SEMAPHORE) +#define NODE_DC_SEMAPHORE_PLACEHOLDER 1 +#else +#define NODE_DC_SEMAPHORE_PLACEHOLDER 0 +#endif + class BindingData : public SnapshotableObject { public: static constexpr size_t kInitialChannelCapacity = 1024; @@ -52,6 +71,12 @@ class BindingData : public SnapshotableObject { static void LinkNativeChannel( const v8::FunctionCallbackInfo& args); +#if NODE_HAVE_USDT + static void EmitPublishProbe(const v8::FunctionCallbackInfo& args); +#endif +#if NODE_DC_SEMAPHORE_PLACEHOLDER + static void ProbeEnabled(const v8::FunctionCallbackInfo& args); +#endif using ChannelStatusCallback = std::function; void SetChannelStatusCallback(uint32_t index, ChannelStatusCallback cb); @@ -70,6 +95,10 @@ class BindingData : public SnapshotableObject { static void RegisterExternalReferences(ExternalReferenceRegistry* registry); private: +#if NODE_HAVE_USDT + static void SetupProbeSemaphore(v8::Isolate* isolate, + v8::Local target); +#endif InternalFieldInfo* internal_field_info_ = nullptr; std::unordered_map channel_status_callbacks_; }; @@ -89,6 +118,12 @@ class Channel : public BaseObject { return binding_data_ != nullptr && binding_data_->subscribers_[index_] > 0; } + // Producer interest: a real JS subscriber or an attached tracer. Never + // use this to gate dispatch into JS, only production of the event. + inline bool HasInterest() const { + return HasSubscribers() || IsProbeEnabled(); + } + void Publish(Environment* env, v8::Local message); void Link(v8::Isolate* isolate, v8::Local js_channel); diff --git a/src/node_provider.d b/src/node_provider.d new file mode 100644 index 000000000000..a8ed20276196 --- /dev/null +++ b/src/node_provider.d @@ -0,0 +1,3 @@ +provider node { + probe dc__publish(const char *, const void *); +}; diff --git a/src/node_sqlite.cc b/src/node_sqlite.cc index 9ad04eae0878..f8b61d94e524 100644 --- a/src/node_sqlite.cc +++ b/src/node_sqlite.cc @@ -1171,10 +1171,7 @@ bool DatabaseSync::Open() { } trace_channel_ = diagnostics_channel::Channel::Get(env(), "sqlite.db.query"); - if (trace_channel_ && trace_channel_->HasSubscribers()) { - sqlite3_trace_v2( - connection_.get(), SQLITE_TRACE_PROFILE, TraceCallback, this); - } + RefreshTracing(); opened = true; return true; @@ -1188,11 +1185,36 @@ void DatabaseSync::EnableTracing() { } sqlite3_trace_v2( connection_.get(), SQLITE_TRACE_PROFILE, TraceCallback, this); + tracing_installed_ = true; } void DatabaseSync::DisableTracing() { if (!IsOpen()) return; sqlite3_trace_v2(connection_.get(), 0, nullptr, nullptr); + tracing_installed_ = false; +} + +void DatabaseSync::RefreshTracing() { + // Tracer-driven refresh can wait for the next outer execution entry, so + // it does not churn the hook while a statement is stepping or SQLite has + // re-entered JavaScript through a callback. Real-subscriber notifications + // stay immediate through EnableTracing()/DisableTracing(). TraceCallback + // re-checks interest on every event, so a hook that outlives its interest + // publishes nothing. + if (!IsOpen() || IsInCallback() || !stepping_statements_.empty()) { + return; + } + if (!trace_channel_) { + trace_channel_ = + diagnostics_channel::Channel::Get(env(), "sqlite.db.query"); + } + const bool wanted = trace_channel_ && trace_channel_->HasInterest(); + if (wanted == tracing_installed_) return; + if (wanted) { + EnableTracing(); + } else { + DisableTracing(); + } } void DatabaseSync::FinalizeBackups() { @@ -1627,6 +1649,9 @@ void DatabaseSync::Close(const FunctionCallbackInfo& args) { int r = sqlite3_close_v2(db->connection_.get()); CHECK_ERROR_OR_THROW(env->isolate(), db, r, SQLITE_OK, void()); db->connection_.release(); + // The hook died with the connection. A reopen must be able to install it + // again on the fresh connection. + db->tracing_installed_ = false; // Backups can defer SQLite destruction until after the connection is closed. db->user_defined_functions_.clear(); } @@ -1760,6 +1785,10 @@ void DatabaseSync::Prepare(const FunctionCallbackInfo& args) { // getter, which may have closed the database since it was checked. THROW_AND_RETURN_ON_BAD_STATE(env, !db->IsOpen(), "database is not open"); + // Preparing can itself run SQL, for example when a virtual table module + // reads its configuration, so the hook is reconciled first. + db->RefreshTracing(); + Utf8Value sql(env->isolate(), args[0].As()); sqlite3_stmt* s = nullptr; @@ -1825,6 +1854,10 @@ void DatabaseSync::Exec(const FunctionCallbackInfo& args) { // SQLite callback is still executing, causing a use-after-free. BaseObjectPtr guard(db); + // sqlite3_exec() steps statements internally, outside any + // SteppingStatementGuard, so the hook is reconciled first. + db->RefreshTracing(); + Utf8Value sql(env->isolate(), args[0].As()); int r = sqlite3_exec(db->connection_.get(), *sql, nullptr, nullptr, nullptr); CHECK_ERROR_OR_THROW(env->isolate(), db, r, SQLITE_OK, void()); @@ -2034,6 +2067,9 @@ void DatabaseSync::Serialize(const FunctionCallbackInfo& args) { db_name = Utf8Value(env->isolate(), args[0].As()).ToString(); } + // Serializing runs a PRAGMA for the page count. + db->RefreshTracing(); + sqlite3_int64 size = 0; unsigned char* data = sqlite3_serialize(db->connection_.get(), db_name.c_str(), &size, 0); @@ -2119,6 +2155,9 @@ void DatabaseSync::Deserialize(const FunctionCallbackInfo& args) { // getter, which may have closed the database since it was checked. THROW_AND_RETURN_ON_BAD_STATE(env, !db->IsOpen(), "database is not open"); + // Deserializing runs SQL, such as the ATTACH it uses to adopt the buffer. + db->RefreshTracing(); + // sqlite3_malloc64 is required because SQLITE_DESERIALIZE_FREEONCLOSE // transfers ownership to SQLite, which calls sqlite3_free() on close. // See: https://www.sqlite.org/c3ref/deserialize.html @@ -2679,6 +2718,10 @@ void DatabaseSync::ApplyChangeset(const FunctionCallbackInfo& args) { // which could otherwise let it be garbage-collected mid-callback. BaseObjectPtr guard(db); + // Applying a changeset runs SQL on this connection, including through the + // conflict and filter callbacks below. + db->RefreshTracing(); + ArrayBufferViewContents buf(args[0]); if (buf.length() > std::numeric_limits::max()) { THROW_ERR_OUT_OF_RANGE(env, "The changeset is too large."); @@ -2808,6 +2851,8 @@ void DatabaseSync::LoadExtension(const FunctionCallbackInfo& args) { ToNamespacedPath(env, &path); THROW_IF_INSUFFICIENT_PERMISSIONS( env, permission::PermissionScope::kFileSystemRead, path.ToStringView()); + // Loading an extension runs its initialization SQL. + db->RefreshTracing(); char* errmsg = nullptr; const int r = sqlite3_load_extension( db->connection_.get(), *path, *entryPoint, &errmsg); @@ -2938,8 +2983,10 @@ int DatabaseSync::TraceCallback(unsigned int type, Environment* env = db->env(); diagnostics_channel::Channel* ch = db->trace_channel_.get(); - if (ch == nullptr || !ch->HasSubscribers() || - db->AreTraceEventsSuppressed()) { + // Producer interest: a real subscriber or an attached tracer. Either one + // can receive the event. Publish dispatches into JavaScript only when a + // real subscriber exists. + if (ch == nullptr || !ch->HasInterest() || db->AreTraceEventsSuppressed()) { return 0; } @@ -3510,6 +3557,9 @@ void StatementSync::All(const FunctionCallbackInfo& args) { env, stmt->IsFinalized(), "statement has been finalized"); THROW_AND_RETURN_IF_IN_AUTHORIZER(env, stmt->db_.get()); THROW_AND_RETURN_IF_STEPPING(env, stmt); + // The statement below may have finished in a previous run, so its reset + // and step can fire a trace event. Reconcile the hook before the guard. + stmt->db_->RefreshTracing(); Isolate* isolate = env->isolate(); SteppingStatementGuard stepping(stmt->db_.get(), stmt->statement_.get()); int r = stmt->ResetStatement(); @@ -3539,6 +3589,7 @@ void StatementSync::Iterate(const FunctionCallbackInfo& args) { env, stmt->IsFinalized(), "statement has been finalized"); THROW_AND_RETURN_IF_IN_AUTHORIZER(env, stmt->db_.get()); THROW_AND_RETURN_IF_STEPPING(env, stmt); + stmt->db_->RefreshTracing(); SteppingStatementGuard stepping(stmt->db_.get(), stmt->statement_.get()); int r = stmt->ResetStatement(); CHECK_ERROR_OR_THROW(env->isolate(), stmt->db_.get(), r, SQLITE_OK, void()); @@ -3565,6 +3616,7 @@ void StatementSync::Get(const FunctionCallbackInfo& args) { env, stmt->IsFinalized(), "statement has been finalized"); THROW_AND_RETURN_IF_IN_AUTHORIZER(env, stmt->db_.get()); THROW_AND_RETURN_IF_STEPPING(env, stmt); + stmt->db_->RefreshTracing(); SteppingStatementGuard stepping(stmt->db_.get(), stmt->statement_.get()); int r = stmt->ResetStatement(); CHECK_ERROR_OR_THROW(env->isolate(), stmt->db_.get(), r, SQLITE_OK, void()); @@ -3587,6 +3639,7 @@ void StatementSync::Run(const FunctionCallbackInfo& args) { env, stmt->IsFinalized(), "statement has been finalized"); THROW_AND_RETURN_IF_IN_AUTHORIZER(env, stmt->db_.get()); THROW_AND_RETURN_IF_STEPPING(env, stmt); + stmt->db_->RefreshTracing(); SteppingStatementGuard stepping(stmt->db_.get(), stmt->statement_.get()); int r = stmt->ResetStatement(); CHECK_ERROR_OR_THROW(env->isolate(), stmt->db_.get(), r, SQLITE_OK, void()); @@ -3924,6 +3977,9 @@ void SQLTagStore::Run(const FunctionCallbackInfo& args) { env, !session->database_->IsOpen(), "database is not open"); THROW_AND_RETURN_IF_IN_AUTHORIZER(env, session->database_.get()); + // Preparing the tagged SQL can itself run SQL, so reconcile before it. + session->database_->RefreshTracing(); + BaseObjectPtr stmt = PrepareStatement(args); if (!stmt) { @@ -3951,6 +4007,8 @@ void SQLTagStore::Iterate(const FunctionCallbackInfo& args) { env, !session->database_->IsOpen(), "database is not open"); THROW_AND_RETURN_IF_IN_AUTHORIZER(env, session->database_.get()); + session->database_->RefreshTracing(); + BaseObjectPtr stmt = PrepareStatement(args); if (!stmt) { @@ -3982,6 +4040,8 @@ void SQLTagStore::Get(const FunctionCallbackInfo& args) { env, !session->database_->IsOpen(), "database is not open"); THROW_AND_RETURN_IF_IN_AUTHORIZER(env, session->database_.get()); + session->database_->RefreshTracing(); + BaseObjectPtr stmt = PrepareStatement(args); if (!stmt) { @@ -4009,6 +4069,8 @@ void SQLTagStore::All(const FunctionCallbackInfo& args) { env, !session->database_->IsOpen(), "database is not open"); THROW_AND_RETURN_IF_IN_AUTHORIZER(env, session->database_.get()); + session->database_->RefreshTracing(); + BaseObjectPtr stmt = PrepareStatement(args); if (!stmt) { @@ -4277,6 +4339,8 @@ void StatementSyncIterator::Next(const FunctionCallbackInfo& args) { iter->statement_reset_generation_ != iter->stmt_->reset_generation_, "iterator was invalidated"); + iter->stmt_->db_->RefreshTracing(); + // sqlite3_reset() can run JavaScript through an aggregate's xFinal, so it // stays inside the guard. SteppingStatementGuard stepping(iter->stmt_->db_.get(), @@ -4429,6 +4493,10 @@ void Session::Changeset(const FunctionCallbackInfo& args) { THROW_AND_RETURN_ON_BAD_STATE(env, !session->session_, "session is not open"); THROW_AND_RETURN_IF_IN_AUTHORIZER(env, session->database_.get()); + // Generating a changeset runs SQL: SAVEPOINT, table metadata PRAGMAs and + // the row query itself. + session->database_->RefreshTracing(); + session->is_generating_changeset_ = true; auto changeset_guard = OnScopeLeave([&] { session->is_generating_changeset_ = false; }); @@ -4562,14 +4630,20 @@ static void Initialize(Local target, if (diag_binding != nullptr && sqlite_bd != nullptr) { uint32_t idx = diag_binding->GetOrCreateChannelIndex("sqlite.db.query"); BaseObjectPtr bd_ptr(sqlite_bd); + // Real-subscriber notifications stay immediate: the notification bool + // is authoritative, since it can arrive before the subscriber count is + // updated. A subscriber or an attached tracer means tracing stays on, + // and only neither of them turns it off. diag_binding->SetChannelStatusCallback(idx, [bd_ptr](bool is_active) { BindingData* bd = bd_ptr.get(); if (bd == nullptr) return; + const bool wanted = is_active || diagnostics_channel::IsProbeEnabled(); for (DatabaseSync* db : bd->open_databases) { - if (is_active) + if (wanted) { db->EnableTracing(); - else + } else { db->DisableTracing(); + } } }); } diff --git a/src/node_sqlite.h b/src/node_sqlite.h index 5b91e27d5736..4c3926ddb40f 100644 --- a/src/node_sqlite.h +++ b/src/node_sqlite.h @@ -286,8 +286,16 @@ class DatabaseSync : public BaseObject { // enable that use case. void SetIgnoreNextSQLiteError(bool ignore); bool ShouldIgnoreSQLiteError(); + // Immediate hook management for real-subscriber notifications. Installs + // or removes the SQLITE_TRACE_PROFILE hook and records its presence. void EnableTracing(); void DisableTracing(); + // Lazily reconciles the SQLITE_TRACE_PROFILE hook with tracer interest + // in the sqlite.db.query channel + // (diagnostics_channel::Channel::HasInterest()). Deferred while a statement + // is stepping on this connection or SQLite has re-entered JavaScript + // through a callback. The next outer execution entry reconciles then. + void RefreshTracing(); void IncrementCallbackDepth() { ++callback_depth_; } void DecrementCallbackDepth() { --callback_depth_; } @@ -351,6 +359,10 @@ class DatabaseSync : public BaseObject { std::unordered_set sessions_; std::unordered_set statements_; BaseObjectPtr trace_channel_; + // Mirrors whether the SQLITE_TRACE_PROFILE hook is currently installed on + // the open connection, so refreshes only touch sqlite3_trace_v2() when the + // wanted state differs. Reset whenever the connection is closed. + bool tracing_installed_ = false; friend class UserDefinedFunction; friend class CustomAggregate; diff --git a/src/node_usdt.h b/src/node_usdt.h new file mode 100644 index 000000000000..c31bb783fded --- /dev/null +++ b/src/node_usdt.h @@ -0,0 +1,70 @@ +#ifndef SRC_NODE_USDT_H_ +#define SRC_NODE_USDT_H_ + +#if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS + +#if defined(NODE_NO_USDT) +// All USDT support explicitly disabled via --without-dtrace. +#define NODE_HAVE_USDT 0 +#define NODE_DC_PUBLISH_ENABLED() (0) +#define NODE_DC_PUBLISH_PROBE(name, msg) do {} while (0) + +#elif defined(NODE_HAVE_DTRACE) +// Tier 1: dtrace -h generated header. On Linux (SystemTap wrapper) the +// header defines STAP_HAS_SEMAPHORES and a semaphore variable that starts +// at 0 and is incremented by an attached tracer — zero overhead without a +// tracer. On macOS/FreeBSD/illumos (native DTrace) the header provides an +// is-enabled probe via NODE_DC_PUBLISH_ENABLED() — the kernel patches the +// probe site to a no-op when no tracer is attached. +#include "node_provider.h" + +#define NODE_HAVE_USDT 1 +// NODE_DC_PUBLISH_ENABLED() and NODE_DC_PUBLISH() come from node_provider.h. +// Alias NODE_DC_PUBLISH to NODE_DC_PUBLISH_PROBE for consistency with +// the _ENABLED/_PROBE naming convention used in call sites. +#define NODE_DC_PUBLISH_PROBE(name, msg) NODE_DC_PUBLISH((name), (msg)) + +#if defined(STAP_HAS_SEMAPHORES) +// Linux/SystemTap: real semaphore — JS can check without crossing into C++. +inline unsigned short* NodeDCPublishSemaphore() { + return &node_dc__publish_semaphore; +} +#else +// macOS/FreeBSD/illumos: no semaphore variable — always report as enabled +// so that JS calls emitPublishProbe, which checks NODE_DC_PUBLISH_ENABLED() +// (the kernel is-enabled probe) and returns early if no tracer is attached. +inline unsigned short* NodeDCPublishSemaphore() { + static unsigned short always_enabled = 1; + return &always_enabled; +} +#endif + +#elif defined(__has_include) && __has_include() +// Tier 2: is available but dtrace -h was not used. The +// semaphore is always 1 so the probe macro is always invoked; on DTrace +// platforms the probe site itself is a no-op until a tracer attaches, but +// the JS-to-C++ call overhead for emitPublishProbe is still incurred. +#include + +#define NODE_HAVE_USDT 1 + +inline unsigned short* NodeDCPublishSemaphore() { + static unsigned short always_enabled = 1; + return &always_enabled; +} +#define NODE_DC_PUBLISH_ENABLED() (1) + +#define NODE_DC_PUBLISH_PROBE(name, msg) \ + DTRACE_PROBE2(node, dc__publish, (name), (msg)) + +#else // Tier 3: no dtrace, no — probes compile to no-ops + +#define NODE_HAVE_USDT 0 +#define NODE_DC_PUBLISH_ENABLED() (0) +#define NODE_DC_PUBLISH_PROBE(name, msg) do {} while (0) + +#endif + +#endif // defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS + +#endif // SRC_NODE_USDT_H_ diff --git a/src/permission/permission.cc b/src/permission/permission.cc index da86d6bbb923..bddb1f2ee360 100644 --- a/src/permission/permission.cc +++ b/src/permission/permission.cc @@ -324,7 +324,7 @@ bool Permission::is_scope_granted(Environment* env, if (!result && !publishing_) { auto ch = GetOrCreateChannel(env, permission); - if (ch && ch->HasSubscribers()) { + if (ch && ch->HasInterest()) { publishing_ = true; v8::Isolate* isolate = env->isolate(); v8::HandleScope handle_scope(isolate); @@ -382,7 +382,7 @@ void Permission::Drop(Environment* env, // Publish to diagnostics channel so observers can track drops if (!publishing_) { auto ch = GetOrCreateChannel(env, scope); - if (ch && ch->HasSubscribers()) { + if (ch && ch->HasInterest()) { publishing_ = true; v8::Isolate* isolate = env->isolate(); v8::HandleScope handle_scope(isolate); diff --git a/test/common/usdt.js b/test/common/usdt.js new file mode 100644 index 000000000000..c62b19475c9c --- /dev/null +++ b/test/common/usdt.js @@ -0,0 +1,18 @@ +'use strict'; + +const assert = require('assert'); +const { internalBinding } = require('internal/test/binding'); + +const usdtEnabled = + internalBinding('diagnostics_channel').probeSemaphore !== undefined; + +// CI declares the expected support. Linux builds without sys/sdt.h are valid. +const expected = process.env.NODE_TEST_EXPECT_USDT; +if (expected !== undefined) { + assert.ok(expected === '0' || expected === '1', + 'NODE_TEST_EXPECT_USDT must be 0 or 1'); + assert.strictEqual(usdtEnabled, expected === '1', + `Expected USDT support: ${expected}`); +} + +module.exports = { usdtEnabled }; diff --git a/test/fixtures/diagnostics-channel-usdt-publish.js b/test/fixtures/diagnostics-channel-usdt-publish.js new file mode 100644 index 000000000000..3ae0b3b65bba --- /dev/null +++ b/test/fixtures/diagnostics-channel-usdt-publish.js @@ -0,0 +1,15 @@ +'use strict'; + +// Fixture used by test-diagnostics-channel-usdt-bpftrace.js. +// Publishes messages to a diagnostics channel so the bpftrace probe can +// observe them. + +const dc = require('diagnostics_channel'); + +const ch = dc.channel('test:usdt:bpftrace'); +ch.subscribe(() => {}); + +// Publish several messages so the probe has time to fire. +for (let i = 0; i < 10; i++) { + ch.publish({ seq: i }); +} diff --git a/test/parallel/test-diagnostics-channel-crypto-fips-indicator.js b/test/parallel/test-diagnostics-channel-crypto-fips-indicator.js index 0d7366dd3438..9c7c17ed4d35 100644 --- a/test/parallel/test-diagnostics-channel-crypto-fips-indicator.js +++ b/test/parallel/test-diagnostics-channel-crypto-fips-indicator.js @@ -1,5 +1,7 @@ 'use strict'; +// Flags: --expose-internals + const common = require('../common'); if (!common.hasCrypto) { @@ -30,12 +32,18 @@ if (!hasOpenSSL(3, 4)) { } else if (!process.execArgv.includes('--enable-fips-indicator-events')) { spawnSyncAndExitWithoutError( process.execPath, - ['--enable-fips-indicator-events', __filename], + ['--enable-fips-indicator-events', '--expose-internals', __filename], ); } else { run().then(common.mustCall()); } +async function drainTicks() { + for (let i = 0; i < 3; i++) { + await new Promise((resolve) => setImmediate(resolve)); + } +} + function nextIndicator() { const keepAlive = setInterval(common.mustNotCall(), 10_000); const { promise, resolve } = Promise.withResolvers(); @@ -181,4 +189,50 @@ async function run() { }); const [exitCode] = await exitPromise; assert.strictEqual(exitCode, 0); + + // A simulated attached tracer (forced probe semaphore) must not replay + // queued indicator events into a subscriber that arrived later, and + // pending events must survive removing the last subscriber while the + // tracer stays attached. + { + const { internalBinding } = require('internal/test/binding'); + const { probeSemaphore } = internalBinding('diagnostics_channel'); + if (probeSemaphore !== undefined) { + const initial = probeSemaphore[0]; + probeSemaphore[0] = 1; + try { + // Tracer-only event: queued while no subscriber exists. + createHmac('sha256', key).digest(); + const lateSubscriber = common.mustNotCall(); + diagnosticsChannel.subscribe(channelName, lateSubscriber); + await drainTicks(); + assert.strictEqual( + diagnosticsChannel.unsubscribe(channelName, lateSubscriber), true); + + // Queued for a subscriber, then the last subscriber is removed + // while the tracer stays attached. + const seen = []; + const first = (event) => seen.push(`first:${event.count}`); + diagnosticsChannel.subscribe(channelName, first); + createHmac('sha256', key).digest(); + assert.strictEqual( + diagnosticsChannel.unsubscribe(channelName, first), true); + + const second = (event) => seen.push(`second:${event.count}`); + diagnosticsChannel.subscribe(channelName, second); + await drainTicks(); + assert.deepStrictEqual(seen, []); + + // Only a new event reaches the new subscriber, not coalesced with + // the retained pre-unsubscribe event. + createHmac('sha256', key).digest(); + await drainTicks(); + assert.deepStrictEqual(seen, ['second:1']); + assert.strictEqual( + diagnosticsChannel.unsubscribe(channelName, second), true); + } finally { + probeSemaphore[0] = initial; + } + } + } } diff --git a/test/parallel/test-diagnostics-channel-usdt-bpftrace.js b/test/parallel/test-diagnostics-channel-usdt-bpftrace.js new file mode 100644 index 000000000000..9ec289b07abc --- /dev/null +++ b/test/parallel/test-diagnostics-channel-usdt-bpftrace.js @@ -0,0 +1,65 @@ +// Flags: --expose-internals +'use strict'; + +// Verify that the USDT dc__publish probe fires and provides the correct +// channel name by tracing a child Node.js process with bpftrace. + +const common = require('../common'); + +if (!common.isLinux) + common.skip('bpftrace tests are Linux-only'); + +const { internalBinding } = require('internal/test/binding'); +const { probeSemaphore } = internalBinding('diagnostics_channel'); +if (probeSemaphore === undefined) + common.skip('Node.js built without USDT support'); + +const assert = require('assert'); +const { spawnSync } = require('child_process'); +const fixtures = require('../common/fixtures'); + +// bpftrace requires root. +if (process.getuid() !== 0) + common.skip('bpftrace requires root privileges'); + +const bpftrace = spawnSync('bpftrace', ['--version']); +if (bpftrace.error) + common.skip('bpftrace not found'); + +const fixtureScript = fixtures.path('diagnostics-channel-usdt-publish.js'); + +// bpftrace program: attach to the dc__publish probe, print the channel name, +// then exit after the traced process finishes. +const bpfProgram = ` +usdt:${process.execPath}:node:dc__publish { + printf("PROBE_FIRED channel=%s\\n", str(arg0)); +} +`; + +const result = spawnSync('bpftrace', [ + '-e', bpfProgram, + '-c', `${process.execPath} ${fixtureScript}`, +], { + timeout: 30_000, + encoding: 'utf-8', +}); + +if (result.error) + throw result.error; + +if (result.status !== 0) { + const stderr = result.stderr || ''; + // If bpftrace specifically cannot find our probe, that is a real failure + // in the USDT implementation, not an environmental issue. + if (stderr.includes('No probes found') || + stderr.includes('ERROR: usdt probe')) { + assert.fail(`USDT probe broken - bpftrace could not attach: ${stderr}`); + } + // Otherwise bpftrace may fail for kernel/permission reasons unrelated + // to our code. + common.skip(`bpftrace exited with status ${result.status}: ${stderr}`); +} + +const output = result.stdout; +assert.match(output, /PROBE_FIRED channel=test:usdt:bpftrace/, + `Expected probe to fire with channel name. stdout: ${output}`); diff --git a/test/parallel/test-diagnostics-channel-usdt.js b/test/parallel/test-diagnostics-channel-usdt.js new file mode 100644 index 000000000000..e1506a8ef4b4 --- /dev/null +++ b/test/parallel/test-diagnostics-channel-usdt.js @@ -0,0 +1,535 @@ +// Flags: --expose-internals +'use strict'; + +// Verify that diagnostics channel publish works correctly with USDT probe +// code in the publish path, and that the probe semaphore and emitPublishProbe +// binding are wired up correctly. + +const common = require('../common'); +const { usdtEnabled } = require('../common/usdt'); +const dc = require('diagnostics_channel'); +const assert = require('assert'); +const { AsyncLocalStorage } = require('node:async_hooks'); +const { internalBinding } = require('internal/test/binding'); + +const binding = internalBinding('diagnostics_channel'); + +// --- Semaphore and binding shape --- + +// probeSemaphore must be a Uint16Array (USDT compiled in) or undefined (not). +{ + const { probeSemaphore } = binding; + assert.ok( + probeSemaphore === undefined || probeSemaphore instanceof Uint16Array, + `Expected probeSemaphore to be Uint16Array or undefined, got ${typeof probeSemaphore}`, + ); + + if (probeSemaphore !== undefined) { + // Without a tracer attached the semaphore must be 0 (Linux, committed + // SystemTap-generated header) or 1 (macOS --with-dtrace path, which + // has no native semaphore). + assert.ok( + probeSemaphore[0] === 0 || probeSemaphore[0] === 1, + `Expected semaphore to be 0 or 1, got ${probeSemaphore[0]}`, + ); + + // emitPublishProbe must exist when USDT is compiled in. + assert.strictEqual(typeof binding.emitPublishProbe, 'function'); + } else { + // emitPublishProbe must not exist when USDT is absent. + assert.strictEqual(binding.emitPublishProbe, undefined); + } +} + +// --- JS probe guard: verify emitPublishProbe is called/skipped --- + +// When the semaphore is > 0 (macOS --with-dtrace, which has no native +// semaphore), emitPublishProbe must be called for string-named channels and +// must NOT be called for symbol-named channels. When the semaphore is 0 +// (Linux Tier 1, no tracer) or USDT is absent, emitPublishProbe must never +// be called. +{ + const { probeSemaphore, emitPublishProbe } = binding; + const semaphoreEnabled = probeSemaphore !== undefined && + probeSemaphore[0] > 0; + + let probeCallCount = 0; + const origProbe = emitPublishProbe; + if (origProbe !== undefined) { + binding.emitPublishProbe = (...args) => { + probeCallCount++; + return origProbe(...args); + }; + } + + // String-named channel with subscriber — probe fires only if semaphore > 0. + const ch = dc.channel('test:usdt:probe-guard'); + const subscriber = common.mustCall(); + ch.subscribe(subscriber); + ch.publish({ probeGuard: true }); + ch.unsubscribe(subscriber); + + if (semaphoreEnabled) { + assert.strictEqual(probeCallCount, 1, + `emitPublishProbe should be called once for ` + + `string-named channel, got ${probeCallCount}`); + } else { + assert.strictEqual(probeCallCount, 0, + `emitPublishProbe should not be called when the ` + + `semaphore is 0, got ${probeCallCount}`); + } + + // Symbol-named channel — probe must never fire regardless of semaphore. + probeCallCount = 0; + const sym = Symbol('test:usdt:symbol-probe-guard'); + const symCh = dc.channel(sym); + const symSub = common.mustCall(); + symCh.subscribe(symSub); + symCh.publish({ symbolGuard: true }); + symCh.unsubscribe(symSub); + + assert.strictEqual(probeCallCount, 0, + `emitPublishProbe must not be called for symbol-named ` + + `channels, got ${probeCallCount}`); + + // Restore original. + if (origProbe !== undefined) { + binding.emitPublishProbe = origProbe; + } +} + +// --- JS probe guard: positive path through the public publish() API --- + +// Force the semaphore to look "attached" and verify that the hot path in +// lib/diagnostics_channel actually calls emitPublishProbe. This exercises +// the semaphore view as seen by the hot path, which reads it through the +// binding on every publish so that a capture during startup snapshot +// building cannot leave a stale view behind. +if (usdtEnabled) { + const { probeSemaphore } = binding; + const origSemaphore = probeSemaphore[0]; + let probeCalls = []; + const origProbe = binding.emitPublishProbe; + binding.emitPublishProbe = (name) => probeCalls.push(name); + + try { + probeSemaphore[0] = 1; + const ch = dc.channel('test:usdt:probe-positive'); + const subscriber = common.mustCall(); + ch.subscribe(subscriber); + probeCalls = []; + ch.publish({ probePositive: true }); + ch.unsubscribe(subscriber); + + assert.deepStrictEqual(probeCalls, ['test:usdt:probe-positive'], + `publish() must call emitPublishProbe for string-named channels ` + + `when the semaphore is > 0, got ${JSON.stringify(probeCalls)}`); + + // Symbol-named channels must never emit the probe. + probeCalls = []; + const sym = Symbol('test:usdt:symbol-probe-positive'); + const symCh = dc.channel(sym); + const symSub = common.mustCall(); + symCh.subscribe(symSub); + symCh.publish({ symbolPositive: true }); + symCh.unsubscribe(symSub); + + assert.deepStrictEqual(probeCalls, [], + `publish() must not call emitPublishProbe for symbol-named ` + + `channels, got ${JSON.stringify(probeCalls)}`); + + // With the semaphore back at 0, the probe must not be emitted. + probeSemaphore[0] = 0; + probeCalls = []; + const ch0 = dc.channel('test:usdt:probe-negative'); + const sub0 = common.mustCall(); + ch0.subscribe(sub0); + ch0.publish({ probeNegative: true }); + ch0.unsubscribe(sub0); + + assert.deepStrictEqual(probeCalls, [], + `publish() must not call emitPublishProbe when the semaphore is 0, ` + + `got ${JSON.stringify(probeCalls)}`); + } finally { + probeSemaphore[0] = origSemaphore; + binding.emitPublishProbe = origProbe; + } +} + +// --- Publish with and without subscribers --- + +// String-named channel with subscribers. +{ + const ch = dc.channel('test:usdt:string'); + const input = { foo: 'bar' }; + + const subscriber = common.mustCall((message, name) => { + assert.strictEqual(name, 'test:usdt:string'); + assert.deepStrictEqual(message, input); + }); + + ch.subscribe(subscriber); + assert.ok(ch.hasSubscribers); + ch.publish(input); + ch.unsubscribe(subscriber); +} + +// String-named channel without subscribers (exercises the C++ +// Channel::Publish early-return / fire_usdt_probe path). +{ + const ch = dc.channel('test:usdt:no-sub'); + assert.ok(!ch.hasSubscribers); + ch.publish({ data: 1 }); +} + +// Symbol-named channel with subscribers. +{ + const sym = Symbol('test:usdt:symbol'); + const ch = dc.channel(sym); + const input = { baz: 'qux' }; + + const subscriber = common.mustCall((message, name) => { + assert.strictEqual(name, sym); + assert.deepStrictEqual(message, input); + }); + + ch.subscribe(subscriber); + assert.ok(ch.hasSubscribers); + ch.publish(input); + ch.unsubscribe(subscriber); +} + +// Symbol-named channel without subscribers. +{ + const sym = Symbol('test:usdt:symbol-nosub'); + const ch = dc.channel(sym); + assert.ok(!ch.hasSubscribers); + ch.publish({ data: 2 }); +} + +// --- Non-object messages (nullptr branch in EmitPublishProbe) --- + +{ + const ch = dc.channel('test:usdt:primitive'); + const received = []; + const subscriber = common.mustCall((message) => { + received.push(message); + }, 4); + + ch.subscribe(subscriber); + ch.publish('hello'); + ch.publish(42); + ch.publish(null); + ch.publish(undefined); + ch.unsubscribe(subscriber); + + assert.deepStrictEqual(received, ['hello', 42, null, undefined]); +} + +// --- Active-to-inactive lifecycle --- + +// Publish after unsubscribe: channel reverts to inactive, publish must still +// work (hits the no-subscriber early-return with fire_usdt_probe in C++). +{ + const ch = dc.channel('test:usdt:lifecycle'); + const subscriber = common.mustCall((message) => { + assert.deepStrictEqual(message, { step: 1 }); + }); + + ch.subscribe(subscriber); + ch.publish({ step: 1 }); + ch.unsubscribe(subscriber); + assert.ok(!ch.hasSubscribers); + ch.publish({ step: 2 }); +} + +// Re-subscribe after unsubscribe: verifies the channel transitions back to +// active correctly and the probe path still works. +{ + const ch = dc.channel('test:usdt:resubscribe'); + const first = common.mustCall(); + ch.subscribe(first); + ch.publish({ phase: 'first' }); + ch.unsubscribe(first); + + assert.ok(!ch.hasSubscribers); + ch.publish({ phase: 'inactive' }); + + const second = common.mustCall((message) => { + assert.deepStrictEqual(message, { phase: 'second' }); + }); + ch.subscribe(second); + assert.ok(ch.hasSubscribers); + ch.publish({ phase: 'second' }); + ch.unsubscribe(second); +} + +// --- Direct emitPublishProbe call (when available) --- + +// Call emitPublishProbe directly to exercise the C++ function with various +// argument types. This path is normally guarded by the semaphore in JS, +// so it may not be reached in normal testing. +// NOTE: On Tier 1 (dtrace -h) builds without a tracer attached, +// NODE_DC_PUBLISH_ENABLED() returns false and the probe body is skipped. +// Full probe exercising requires the bpftrace integration test. +{ + const { probeSemaphore, emitPublishProbe } = binding; + if (probeSemaphore !== undefined && emitPublishProbe !== undefined) { + // Object message. + emitPublishProbe('test:usdt:direct', { x: 1 }); + // Non-object message (nullptr branch). + emitPublishProbe('test:usdt:direct', 'string'); + emitPublishProbe('test:usdt:direct', null); + emitPublishProbe('test:usdt:direct', 42); + emitPublishProbe('test:usdt:direct', undefined); + } +} + +// --- Tracer-driven activation without JavaScript subscribers --- + +// On real semaphore tiers (ordinary Linux) the JS view is the live kernel +// value, so writing it simulates attach and detach. On placeholder tiers +// (native DTrace, sandboxed builds) the view is constant one and the native +// probeEnabled query alone decides. That stays false here without a real +// tracer. Tier 3 builds have no view at all. +const canSimulate = usdtEnabled && binding.probeEnabled === undefined; + +// Attach and detach flip hasSubscribers for string names, including a +// name that has no channel object yet. Symbol names never carry the probe. +if (canSimulate) { + const { probeSemaphore } = binding; + const origSemaphore = probeSemaphore[0]; + + try { + const ch = dc.channel('test:usdt:tracer-only'); + + probeSemaphore[0] = 0; + assert.ok(!ch.hasSubscribers); + assert.ok(!dc.hasSubscribers('test:usdt:tracer-only')); + assert.ok(!dc.hasSubscribers('test:usdt:never-created')); + + probeSemaphore[0] = 1; + assert.ok(ch.hasSubscribers); + assert.ok(dc.hasSubscribers('test:usdt:tracer-only')); + assert.ok(dc.hasSubscribers('test:usdt:never-created')); + + const sym = Symbol('test:usdt:tracer-only-symbol'); + const symCh = dc.channel(sym); + assert.ok(!symCh.hasSubscribers); + assert.ok(!dc.hasSubscribers(sym)); + + probeSemaphore[0] = 0; + assert.ok(!ch.hasSubscribers); + assert.ok(!dc.hasSubscribers('test:usdt:never-created')); + } finally { + probeSemaphore[0] = origSemaphore; + } +} + +// Placeholder tiers must not read the constant-one view as tracer +// interest. Without a real tracer the native query is the only truth. +if (binding.probeEnabled !== undefined) { + assert.strictEqual(binding.probeSemaphore[0], 1); + assert.strictEqual(binding.probeEnabled(), false); + const ch = dc.channel('test:usdt:placeholder-truth'); + assert.ok(!ch.hasSubscribers); + assert.ok(!dc.hasSubscribers('test:usdt:placeholder-truth')); +} + +// Inactive publish, runStores, and withStoreScope reach the probe when a +// tracer is attached and stay no-ops otherwise. +if (canSimulate) { + const { probeSemaphore } = binding; + const origSemaphore = probeSemaphore[0]; + const origProbe = binding.emitPublishProbe; + const probeNames = []; + binding.emitPublishProbe = (name) => probeNames.push(name); + + try { + const name = 'test:usdt:inactive-emit'; + const ch = dc.channel(name); + assert.ok(!ch.hasSubscribers); + + // Detached: no emission and unchanged passthrough behavior. + probeSemaphore[0] = 0; + probeNames.length = 0; + ch.publish({ via: 'publish' }); + assert.deepStrictEqual(probeNames, []); + assert.strictEqual(ch.runStores({ via: 'runStores' }, () => 'ret'), + 'ret'); + ch.withStoreScope({ via: 'withStoreScope' }); + assert.deepStrictEqual(probeNames, []); + + // Attached: one probe per path, exactly once each. + probeSemaphore[0] = 1; + probeNames.length = 0; + ch.publish({ via: 'publish' }); + assert.strictEqual(ch.runStores({ via: 'runStores' }, () => 'ret'), + 'ret'); + ch.withStoreScope({ via: 'withStoreScope' }); + assert.deepStrictEqual(probeNames, [name, name, name], + `expected one probe per inactive path, got ${JSON.stringify(probeNames)}`); + } finally { + probeSemaphore[0] = origSemaphore; + binding.emitPublishProbe = origProbe; + } +} + +// A bound store and tracer interest coexist: one probe per publish, store +// scoping unchanged, and probe-only production survives unsubscribe. +if (canSimulate) { + const { probeSemaphore } = binding; + const origSemaphore = probeSemaphore[0]; + const origProbe = binding.emitPublishProbe; + let probeCount = 0; + binding.emitPublishProbe = () => { probeCount++; }; + + try { + probeSemaphore[0] = 1; + + const store = new AsyncLocalStorage(); + const ch = dc.channel('test:usdt:store-and-tracer'); + ch.bindStore(store); + const data = { k: 1 }; + ch.runStores(data, common.mustCall(() => { + assert.strictEqual(store.getStore(), data); + })); + assert.strictEqual(store.getStore(), undefined); + assert.strictEqual(probeCount, 1, + `expected exactly one probe for runStores, got ${probeCount}`); + + const ch2 = dc.channel('test:usdt:sub-and-tracer'); + const sub = common.mustCall((message) => { + assert.strictEqual(message.v, 1); + }); + ch2.subscribe(sub); + probeCount = 0; + ch2.publish({ v: 1 }); + assert.strictEqual(probeCount, 1); + + // Removing the last JS listener must not stop probe production while + // the tracer stays attached. + ch2.unsubscribe(sub); + probeCount = 0; + ch2.publish({ v: 2 }); + assert.strictEqual(probeCount, 1); + } finally { + probeSemaphore[0] = origSemaphore; + binding.emitPublishProbe = origProbe; + } +} + +// The interest checks read the semaphore view through the binding every +// time, so a view replaced the way deserialization replaces it cannot +// leave stale interest behind. +if (canSimulate) { + const origView = binding.probeSemaphore; + const origValue = origView[0]; + + try { + origView[0] = 0; + const ch = dc.channel('test:usdt:fresh-view'); + assert.ok(!ch.hasSubscribers); + + binding.probeSemaphore = new Uint16Array([1]); + assert.ok(ch.hasSubscribers); + + binding.probeSemaphore = new Uint16Array([0]); + assert.ok(!ch.hasSubscribers); + } finally { + binding.probeSemaphore = origView; + origView[0] = origValue; + } +} + +// Unsupported builds keep the inactive fast paths: no view, no interest. +if (!usdtEnabled) { + assert.strictEqual(binding.probeSemaphore, undefined); + assert.strictEqual(binding.emitPublishProbe, undefined); + assert.strictEqual(binding.probeEnabled, undefined); + + const ch = dc.channel('test:usdt:no-usdt'); + assert.ok(!ch.hasSubscribers); + assert.ok(!dc.hasSubscribers('test:usdt:no-usdt')); + assert.ok(!dc.hasSubscribers(Symbol('test:usdt:no-usdt-sym'))); + ch.publish({}); + assert.strictEqual(ch.runStores({}, () => 'ret'), 'ret'); + ch.withStoreScope({}); +} + +// Tracing helpers reach the publish path with tracer interest only. Store +// scoping does not apply because no store is bound. +if (canSimulate) { + const { probeSemaphore } = binding; + const origSemaphore = probeSemaphore[0]; + const origProbe = binding.emitPublishProbe; + const probeNames = []; + // The internal-test-binding warning is emitted asynchronously and + // reaches the console channels during the await windows below, so only + // this tracing channel's probes are recorded. + binding.emitPublishProbe = (name) => { + if (name.startsWith('tracing:test:usdt:trace-helper')) { + probeNames.push(name); + } + }; + + (async () => { + try { + probeSemaphore[0] = 1; + const tc = dc.tracingChannel('test:usdt:trace-helper'); + + probeNames.length = 0; + assert.strictEqual(tc.traceSync(() => 42), 42); + assert.deepStrictEqual(probeNames, [ + 'tracing:test:usdt:trace-helper:start', + 'tracing:test:usdt:trace-helper:end', + ]); + + probeNames.length = 0; + assert.throws(() => tc.traceSync(() => { + throw new Error('boom'); + }), /boom/); + assert.deepStrictEqual(probeNames, [ + 'tracing:test:usdt:trace-helper:start', + 'tracing:test:usdt:trace-helper:error', + 'tracing:test:usdt:trace-helper:end', + ]); + + probeNames.length = 0; + let cbResult; + tc.traceCallback((cb) => { cbResult = cb(null, 'v'); }, -1, {}, undefined, + common.mustCall((err, res) => { + assert.strictEqual(err, null); + assert.strictEqual(res, 'v'); + return 'ret'; + })); + assert.strictEqual(cbResult, 'ret'); + assert.deepStrictEqual(probeNames, [ + 'tracing:test:usdt:trace-helper:start', + 'tracing:test:usdt:trace-helper:asyncStart', + 'tracing:test:usdt:trace-helper:asyncEnd', + 'tracing:test:usdt:trace-helper:end', + ]); + + probeNames.length = 0; + const result = tc.tracePromise(() => Promise.resolve(7)); + // The call window publishes start and end synchronously. The + // continuation window publishes asyncStart and asyncEnd when the + // promise resolves. + assert.deepStrictEqual(probeNames, [ + 'tracing:test:usdt:trace-helper:start', + 'tracing:test:usdt:trace-helper:end', + ]); + assert.strictEqual(await result, 7); + assert.deepStrictEqual(probeNames, [ + 'tracing:test:usdt:trace-helper:start', + 'tracing:test:usdt:trace-helper:end', + 'tracing:test:usdt:trace-helper:asyncStart', + 'tracing:test:usdt:trace-helper:asyncEnd', + ]); + } finally { + probeSemaphore[0] = origSemaphore; + binding.emitPublishProbe = origProbe; + } + })().then(common.mustCall()); +} diff --git a/test/parallel/test-sqlite-diagnostic-channel.js b/test/parallel/test-sqlite-diagnostic-channel.js index 8b0776c969d7..7b896fa4fc32 100644 --- a/test/parallel/test-sqlite-diagnostic-channel.js +++ b/test/parallel/test-sqlite-diagnostic-channel.js @@ -1,4 +1,4 @@ -// Flags: --expose-gc +// Flags: --expose-gc --expose-internals 'use strict'; const { mustCall, skipIfSQLiteMissing } = require('../common'); @@ -9,6 +9,17 @@ const dc = require('node:diagnostics_channel'); const { DatabaseSync } = require('node:sqlite'); const { suite, it } = require('node:test'); const { gcUntil } = require('../common/gc'); +const { internalBinding } = require('internal/test/binding'); + +// Tracer-driven activation is simulated by writing the USDT probe +// semaphore view, which is only truthful on tiers where it is the live +// kernel value (ordinary Linux). On placeholder tiers (native DTrace, +// sandboxed builds) the view is constant one and the native probeEnabled() +// query alone decides, and on tiers without USDT there is no view at all. +// Coverage with a real tracer runs in the bpftrace harness. +const dcBinding = internalBinding('diagnostics_channel'); +const canSimulateTracer = dcBinding.probeSemaphore !== undefined && + dcBinding.probeEnabled === undefined; suite('sqlite.db.query diagnostics channel', () => { it('subscriber receives SQL string for exec() statements', (t) => { @@ -234,4 +245,199 @@ suite('sqlite.db.query diagnostics channel', () => { { __proto__: null, x: 1 }, ]); }); + + // A subscription from inside a SQLite callback must take effect for the + // nested query it runs, matching the behavior of a build without USDT + // probes. Real-subscriber notifications stay immediate. + it('enables tracing from inside a callback for the nested query', (t) => { + const events = []; + const onQuery = (msg) => events.push(msg.sql); + + using db = new DatabaseSync(':memory:'); + db.function('start_listening', () => { + dc.subscribe('sqlite.db.query', onQuery); + db.prepare('SELECT 42 AS nested').get(); + return 1; + }); + db.prepare('SELECT start_listening()').get(); + dc.unsubscribe('sqlite.db.query', onQuery); + + assert.ok(events.includes('SELECT 42 AS nested')); + }); + + // The new blocks below simulate a tracer by writing the USDT probe + // semaphore view, which is only possible on real-semaphore tiers, so they + // are skipped (not silently passed) everywhere else. + const skipNoTracerSimulation = + !canSimulateTracer && + 'simulating a tracer requires the live USDT probe semaphore view'; + + it('executes all SQL paths under tracer interest without JavaScript dispatch', + { skip: skipNoTracerSimulation }, + (t) => { + + const origSemaphore = dcBinding.probeSemaphore[0]; + try { + // The tracer attaches before the database opens and no JavaScript + // subscriber exists during the sweep. + dcBinding.probeSemaphore[0] = 1; + + using db = new DatabaseSync(':memory:'); + db.exec('CREATE TABLE t(id INTEGER PRIMARY KEY, value TEXT)'); + const session = db.createSession({ table: 't' }); + using insert = db.prepare('INSERT INTO t VALUES (?, ?)'); + using select = db.prepare('SELECT id, value FROM t WHERE id = ?'); + assert.strictEqual(insert.run(1, 'a').changes, 1); + assert.deepStrictEqual(select.get(1), + { __proto__: null, id: 1, value: 'a' }); + assert.deepStrictEqual(select.all(1), + [{ __proto__: null, id: 1, value: 'a' }]); + for (const row of select.iterate(1)) { + assert.strictEqual(row.id, 1); + } + assert.ok(session.changeset().byteLength > 0); + assert.ok(session.patchset().byteLength > 0); + const data = db.serialize(); + assert.ok(data.byteLength > 0); + const target = new DatabaseSync(':memory:'); + target.exec('CREATE TABLE t(id INTEGER PRIMARY KEY, value TEXT)'); + assert.strictEqual(target.applyChangeset(session.changeset()), true); + target.deserialize(data); + const store = db.createTagStore(); + assert.strictEqual(store.run`INSERT INTO t VALUES (${2}, ${'b'})`.changes, + 1); + assert.deepStrictEqual(db.prepare('SELECT id FROM t ORDER BY id').all(), + [{ __proto__: null, id: 1 }, + { __proto__: null, id: 2 }]); + session.close(); + target.close(); + + // Tracer interest alone never dispatches into JavaScript: a + // subscriber that arrives after the sweep sees only its own events. + const calls = []; + const handler = (msg) => calls.push(msg); + dc.subscribe('sqlite.db.query', handler); + db.exec("INSERT INTO t VALUES (3, 'c')"); + assert.strictEqual(calls.length, 1); + assert.strictEqual(calls[0].sql, "INSERT INTO t VALUES (3, 'c')"); + dc.unsubscribe('sqlite.db.query', handler); + } finally { + dcBinding.probeSemaphore[0] = origSemaphore; + } + }); + + it('keeps tracing installed for an attached tracer when the last subscriber leaves', + { skip: skipNoTracerSimulation }, + (t) => { + const calls = []; + const handler = (msg) => calls.push(msg); + + const origSemaphore = dcBinding.probeSemaphore[0]; + try { + // The tracer attaches before any subscriber and before the database + // opens. + dcBinding.probeSemaphore[0] = 1; + using db = new DatabaseSync(':memory:'); + db.exec('CREATE TABLE t (x INTEGER)'); + assert.strictEqual(calls.length, 0); + + dc.subscribe('sqlite.db.query', handler); + db.exec('INSERT INTO t VALUES (1)'); + assert.strictEqual(calls.length, 1); + + // The last JavaScript subscriber leaves while the tracer remains + // attached. Tracing must stay installed for the tracer without + // dispatching into JavaScript. + dc.unsubscribe('sqlite.db.query', handler); + db.exec('INSERT INTO t VALUES (2)'); + assert.strictEqual(calls.length, 1); + + // A subscriber that comes back still receives events. + dc.subscribe('sqlite.db.query', handler); + db.exec('INSERT INTO t VALUES (3)'); + assert.strictEqual(calls.length, 2); + } finally { + dc.unsubscribe('sqlite.db.query', handler); + dcBinding.probeSemaphore[0] = origSemaphore; + } + }); + + it('tracing follows tracer attach, detach, and database reopen', + { skip: skipNoTracerSimulation }, + (t) => { + const calls = []; + const handler = (msg) => calls.push(msg); + dc.subscribe('sqlite.db.query', handler); + t.after(() => dc.unsubscribe('sqlite.db.query', handler)); + + const origSemaphore = dcBinding.probeSemaphore[0]; + try { + using db = new DatabaseSync(':memory:'); + dcBinding.probeSemaphore[0] = 0; + db.exec('CREATE TABLE t (x INTEGER)'); + assert.strictEqual(calls.length, 1); + + // Attaching takes effect at the next execution entry. + dcBinding.probeSemaphore[0] = 1; + db.exec('INSERT INTO t VALUES (1)'); + assert.strictEqual(calls.length, 2); + + // Detaching stops future tracer-only production but never removes + // the real subscriber. + dcBinding.probeSemaphore[0] = 0; + db.exec('INSERT INTO t VALUES (2)'); + assert.strictEqual(calls.length, 3); + + // Closing destroys the hook with the connection. Reopening under an + // attached tracer installs it again. A :memory: database starts empty + // after reopening, so the table is recreated. + db.close(); + db.open(); + db.exec('CREATE TABLE t (x INTEGER)'); + dcBinding.probeSemaphore[0] = 1; + db.exec('INSERT INTO t VALUES (3)'); + assert.strictEqual(calls.length, 5); + } finally { + dcBinding.probeSemaphore[0] = origSemaphore; + } + }); + + it('iterator stepping tolerates tracer attach and detach between steps', + { skip: skipNoTracerSimulation }, + (t) => { + const calls = []; + const handler = (msg) => calls.push(msg); + dc.subscribe('sqlite.db.query', handler); + t.after(() => dc.unsubscribe('sqlite.db.query', handler)); + + const origSemaphore = dcBinding.probeSemaphore[0]; + try { + using db = new DatabaseSync(':memory:'); + db.exec('CREATE TABLE t (x INTEGER)'); + db.exec('INSERT INTO t VALUES (1), (2), (3)'); + calls.length = 0; + + using stmt = db.prepare('SELECT x FROM t'); + const iterator = stmt.iterate(); + assert.deepStrictEqual(iterator.next().value, { __proto__: null, x: 1 }); + assert.strictEqual(calls.length, 0); + + // Attaching between steps takes effect on the next step without + // disturbing the iteration. + dcBinding.probeSemaphore[0] = 1; + assert.deepStrictEqual(iterator.next().value, { __proto__: null, x: 2 }); + assert.strictEqual(calls.length, 0); + + dcBinding.probeSemaphore[0] = 0; + assert.deepStrictEqual(iterator.next().value, { __proto__: null, x: 3 }); + assert.strictEqual(calls.length, 0); + + // The statement finishes on the next step, which fires the profile + // event for the real subscriber. + assert.strictEqual(iterator.next().done, true); + assert.strictEqual(calls.length, 1); + } finally { + dcBinding.probeSemaphore[0] = origSemaphore; + } + }); }); From 91aec81d3cf801bd508e49d28b7871da79953276 Mon Sep 17 00:00:00 2001 From: Bryan English Date: Tue, 8 Sep 2026 20:59:49 -0400 Subject: [PATCH 2/2] build: pre-generate USDT probe header and add bpftrace CI Commit the generated SystemTap provider header so Linux builds need only , not a build-time dtrace tool. Keep macOS opt-in through --with-dtrace and allow --without-dtrace to disable probes entirely. Add Linux CI coverage for enabled and disabled builds, with explicit capability assertions and a generated-header check. Exercise real probes with bpftrace, including snapshot restoration and tracer-only publishing from JavaScript and SQLite. Signed-off-by: Bryan English Assisted-by: Pi using GLM-5.3 --- .github/workflows/test-linux.yml | 84 ++++++++ Makefile | 3 + configure.py | 45 +++- doc/api/diagnostics_channel.md | 48 +++-- node.gyp | 61 +++--- src/node_diagnostics_channel.cc | 21 +- src/node_provider_linux.h | 32 +++ src/node_usdt.h | 106 ++++++---- .../test-diagnostics-channel-usdt-bpftrace.js | 195 +++++++++++++++--- tools/usdt/README.md | 55 +++++ tools/usdt/generate_headers.py | 97 +++++++++ 11 files changed, 606 insertions(+), 141 deletions(-) create mode 100644 src/node_provider_linux.h create mode 100644 tools/usdt/README.md create mode 100755 tools/usdt/generate_headers.py diff --git a/.github/workflows/test-linux.yml b/.github/workflows/test-linux.yml index 408371f3313c..561e8b971488 100644 --- a/.github/workflows/test-linux.yml +++ b/.github/workflows/test-linux.yml @@ -98,3 +98,87 @@ jobs: ./tools/test.py --flaky-tests keep_retrying -p actions -j 4 env: DIR: dir%20with $unusual"chars?'åß∂ƒ©∆¬…` + + # End-to-end coverage for the diagnostics_channel USDT probes: + # a real bpftrace attach against a default (USDT-enabled) build, plus + # a --without-dtrace build that pins the no-op tier. Only runs when + # USDT-related paths change, so ordinary PRs do not pay for it. + test-usdt: + name: USDT probes (${{ matrix.cfg }}) + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + cfg: [default, without-dtrace] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + fetch-depth: 2 + path: node + - name: Detect USDT-related changes + id: changes + run: | + cd node + if [ "${{ github.event_name }}" != "pull_request" ]; then + echo "usdt=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + # For pull_request events, HEAD is GitHub's merge ref; HEAD^1 is the + # tip of the base branch, so no additional fetch is needed. + FILES=$(git diff --name-only HEAD^1 HEAD) + echo "$FILES" + if echo "$FILES" | grep -qE '^(src/node_(usdt|provider|diagnostics_channel)\.(h|cc|d)|src/node_provider_linux\.h|lib/diagnostics_channel\.js|tools/usdt/|test/common/usdt\.js|test/parallel/test-diagnostics-channel-usdt.*\.js|\.github/workflows/test-linux\.yml|configure\.py|node\.gyp|doc/api/diagnostics_channel\.md)'; then + echo "usdt=true" >> "$GITHUB_OUTPUT" + else + echo "usdt=false" >> "$GITHUB_OUTPUT" + fi + - name: Install Clang ${{ env.CLANG_VERSION }} + if: steps.changes.outputs.usdt == 'true' + uses: $/.github/actions/install-clang + with: + clang-version: ${{ env.CLANG_VERSION }} + - name: Install Rust ${{ env.RUSTC_VERSION }} + if: steps.changes.outputs.usdt == 'true' + run: | + rustup override set "$RUSTC_VERSION" + rustup --version + - name: Set up sccache + if: steps.changes.outputs.usdt == 'true' && (github.base_ref == 'main' || github.ref_name == 'main') + uses: Mozilla-Actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 + with: + version: v0.17.0 + - name: Install bpftrace and systemtap-sdt-dev + if: steps.changes.outputs.usdt == 'true' + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends bpftrace systemtap-sdt-dev + - name: Check committed probe header is in sync + if: steps.changes.outputs.usdt == 'true' + run: | + cd node + python3 tools/usdt/generate_headers.py --check + - name: Configure + if: steps.changes.outputs.usdt == 'true' + run: | + cd node + ./configure ${{ matrix.cfg == 'without-dtrace' && '--without-dtrace' || '' }} + - name: Build + if: steps.changes.outputs.usdt == 'true' + run: make -C node -j4 + - name: USDT binding tests + if: steps.changes.outputs.usdt == 'true' + run: | + cd node + python3 tools/test.py test/parallel/test-diagnostics-channel-usdt.js + env: + NODE_TEST_EXPECT_USDT: ${{ matrix.cfg == 'default' && '1' || '0' }} + - name: bpftrace end-to-end test + if: steps.changes.outputs.usdt == 'true' && matrix.cfg == 'default' + # Run as root: the test skips itself when not root, and bpftrace + # needs root to attach (BTF is available on GH-hosted images). + run: | + cd node + sudo -E python3 tools/test.py test/parallel/test-diagnostics-channel-usdt-bpftrace.js + env: + NODE_TEST_EXPECT_USDT: '1' diff --git a/Makefile b/Makefile index 8dec67750f0c..e0da5f193086 100644 --- a/Makefile +++ b/Makefile @@ -1581,6 +1581,9 @@ LINT_CPP_ADDON_DOC_FILES_GLOB = test/addons/??_*/*.cc test/addons/??_*/*.h LINT_CPP_ADDON_DOC_FILES = $(wildcard $(LINT_CPP_ADDON_DOC_FILES_GLOB)) LINT_CPP_EXCLUDE ?= LINT_CPP_EXCLUDE += src/node_root_certs.h +# Generated output of the SystemTap dtrace wrapper, committed verbatim +# (regenerate with tools/usdt/generate_headers.py). +LINT_CPP_EXCLUDE += src/node_provider_linux.h LINT_CPP_EXCLUDE += $(LINT_CPP_ADDON_DOC_FILES) # These files were copied more or less verbatim from V8. LINT_CPP_EXCLUDE += src/tracing/trace_event_legacy.h src/tracing/trace_event_legacy_inl.h diff --git a/configure.py b/configure.py index c7413be0c8e9..cdafaf2dfc6a 100755 --- a/configure.py +++ b/configure.py @@ -1066,6 +1066,13 @@ default=None, help='do not install the bundled Amaro (TypeScript utils)') +parser.add_argument('--with-dtrace', + action='store_true', + dest='with_dtrace', + default=None, + help='build with native DTrace/USDT probe support ' + '(opt-in on macOS; Linux probes need no dtrace tool)') + parser.add_argument('--without-dtrace', action='store_true', dest='without_dtrace', @@ -1369,9 +1376,10 @@ def to_utf8(s): def has_working_dtrace_h(): """Check whether a dtrace tool that supports -h is available. - Supported on Linux (SystemTap dtrace wrapper), macOS, FreeBSD, and - illumos/SmartOS (native DTrace). Non-Linux platforms require -xnolibs - to avoid loading standard D libraries during header generation.""" + Supported on macOS (native DTrace). Non-Linux platforms require + -xnolibs to avoid loading standard D libraries during header generation. + Linux never needs this check: the probe header is pre-generated and + committed (see tools/usdt/generate_headers.py).""" dtrace = shutil.which('dtrace') if dtrace is None: return False @@ -2212,14 +2220,39 @@ def configure_node(o): o['variables']['node_builtin_modules_path'] = options.node_builtin_modules_path o['variables']['node_no_usdt'] = b(options.without_dtrace) - use_dtrace = not options.without_dtrace and has_working_dtrace_h() + # USDT probe support for diagnostics_channel: + # + # * Linux: on by default whenever is available. The probe + # header is pre-generated and committed (src/node_provider_linux.h), + # so no dtrace tool is needed at build time. + # * macOS: opt-in via --with-dtrace; needs a working `dtrace -h` at + # build time (always present with Xcode/CLT). + # * FreeBSD/illumos: not supported yet; native DTrace there requires + # a `dtrace -G` link step that is not implemented. + if options.without_dtrace: + use_dtrace = False + elif options.with_dtrace: + if flavor == 'mac': + if not has_working_dtrace_h(): + raise Exception('dtrace -h is not working; cannot use --with-dtrace') + use_dtrace = True + else: + use_dtrace = False + warn('--with-dtrace is only supported on macOS. On Linux, USDT ' + 'probes are enabled automatically whenever is ' + 'available.') + else: + use_dtrace = False o['variables']['node_use_dtrace'] = b(use_dtrace) if options.without_dtrace: print('USDT probes: disabled (--without-dtrace)') + elif flavor == 'linux': + print('USDT probes: enabled when is available ' + '(systemtap-sdt-dev on Debian/Ubuntu)') elif use_dtrace: - print('USDT probes: enabled (dtrace -h, semaphore support)') + print('USDT probes: enabled (--with-dtrace, dtrace -h)') else: - print('USDT probes: fallback (sys/sdt.h) or disabled') + print('USDT probes: disabled (enable with --with-dtrace)') def configure_napi(output): version = getnapibuildversion.get_napi_version() diff --git a/doc/api/diagnostics_channel.md b/doc/api/diagnostics_channel.md index 7f7ad345553c..d0f889e5ee72 100644 --- a/doc/api/diagnostics_channel.md +++ b/doc/api/diagnostics_channel.md @@ -1578,26 +1578,32 @@ probe fires if the channel has active subscribers or a tracer is attached. #### Platform support -At `./configure` time, Node.js checks for a working `dtrace` tool and -uses `dtrace -h` to generate a probe header. Pass `--without-dtrace` to -`./configure` to disable probe support entirely. - -* **Linux**: Install the `systemtap-sdt-dev` package (Debian/Ubuntu) or - `systemtap-sdt-devel` (Fedora/RHEL) before building Node.js. The - SystemTap `dtrace` wrapper generates a header with semaphore support, - giving the probe zero overhead when no tracer is attached. -* **macOS**: Supported natively via DTrace. The probe instruction is - patched to a no-op by the kernel when no tracer is attached, but the - JS-to-C++ call for `emitPublishProbe` is still incurred on every - publish to a string-named channel with subscribers. -* **FreeBSD**: Supported natively via DTrace, with the same - characteristics as macOS. -* **illumos/SmartOS**: Supported natively via DTrace, with the same - characteristics as macOS. - -If `dtrace` is not found but `` is available, the probe falls -back to always-enabled mode. On platforms where neither is available, -the probe compiles to a no-op with zero runtime overhead. +USDT support is platform-gated and, on Linux, does not require a +`dtrace` tool at build time. Pass `--without-dtrace` to `./configure` +to disable probe support entirely. + +* **Linux** (on by default): the probe header is pre-generated and + committed (`src/node_provider_linux.h`, regenerated with + `tools/usdt/generate_headers.py`), so only `` is required + at build time — install the `systemtap-sdt-dev` package + (Debian/Ubuntu) or `systemtap-sdt-devel` (Fedora/RHEL). The SystemTap + semaphore gives the probe effectively zero overhead when no tracer is + attached. When `` is absent, the probe silently compiles + to a no-op. A dedicated CI job runs an end-to-end bpftrace test on + Linux and verifies the committed header is in sync with + `src/node_provider.d`. +* **macOS** (opt-in): pass `--with-dtrace` to `./configure` to enable. + Requires a working `dtrace -h` at build time (always present with + Xcode/CLT). The probe instruction is patched to a no-op by the + kernel when no tracer is attached, but the JS-to-C++ call for + `emitPublishProbe` is still incurred on every publish to a + string-named channel with subscribers, which is why this tier is + opt-in. +* **FreeBSD/illumos**: not supported yet. Native DTrace there requires + a `dtrace -G` link step that is not implemented. + +On platforms where probes are not available, they compile to no-ops +with zero runtime overhead. #### Example: bpftrace (Linux) @@ -1609,7 +1615,7 @@ sudo bpftrace -e ' ' -c './out/Release/node app.js' ``` -#### Example: DTrace (macOS/FreeBSD) +#### Example: DTrace (macOS) ```bash sudo dtrace -n ' diff --git a/node.gyp b/node.gyp index e4ea17b362a4..cfe23dfc255b 100644 --- a/node.gyp +++ b/node.gyp @@ -280,6 +280,7 @@ 'src/node_diagnostics_channel.h', 'src/node_usdt.h', 'src/node_provider.d', + 'src/node_provider_linux.h', 'src/node_modules.h', 'src/node_object_wrap.h', 'src/node_options.h', @@ -910,37 +911,8 @@ }], [ 'node_use_dtrace=="true"', { 'defines': [ 'NODE_HAVE_DTRACE=1' ], - 'conditions': [ - [ 'OS=="linux"', { - 'actions': [ - { - 'action_name': 'node_dtrace_header', - 'inputs': [ 'src/node_provider.d' ], - 'outputs': [ '<(SHARED_INTERMEDIATE_DIR)/node_provider.h' ], - 'action': [ - 'dtrace', '-h', - '-s', 'src/node_provider.d', - '-o', '<(SHARED_INTERMEDIATE_DIR)/node_provider.h', - ], - }, - ], - }, { - # macOS, FreeBSD, illumos: native DTrace requires -xnolibs - # to avoid loading kernel D libraries during header generation. - 'actions': [ - { - 'action_name': 'node_dtrace_header', - 'inputs': [ 'src/node_provider.d' ], - 'outputs': [ '<(SHARED_INTERMEDIATE_DIR)/node_provider.h' ], - 'action': [ - 'dtrace', '-h', '-xnolibs', - '-s', 'src/node_provider.d', - '-o', '<(SHARED_INTERMEDIATE_DIR)/node_provider.h', - ], - }, - ], - }], - ], + 'dependencies': [ 'node_dtrace_header' ], + 'include_dirs': [ '<(SHARED_INTERMEDIATE_DIR)' ], }], [ 'node_builtin_modules_path!=""', { 'defines': [ 'NODE_BUILTIN_MODULES_PATH="<(node_builtin_modules_path)"' ], @@ -1665,6 +1637,33 @@ }], ] }, # overlapped-checker + { + 'target_name': 'node_dtrace_header', + 'type': 'none', + 'conditions': [ + [ 'node_use_dtrace=="true"', { + 'actions': [ + { + # Native DTrace (macOS, opt-in via ./configure + # --with-dtrace): generate the probe header at build time. + # On Linux the probe header is pre-generated and committed + # at src/node_provider_linux.h, so no dtrace tool is + # needed there (see tools/usdt/generate_headers.py). + # -xnolibs avoids loading standard D libraries during + # header generation. + 'action_name': 'node_dtrace_header', + 'inputs': [ 'src/node_provider.d' ], + 'outputs': [ '<(SHARED_INTERMEDIATE_DIR)/node_provider.h' ], + 'action': [ + 'dtrace', '-h', '-xnolibs', + '-s', '<@(_inputs)', + '-o', '<@(_outputs)', + ], + }, + ], + } ], + ], + }, # node_dtrace_header { 'target_name': 'nop', 'type': 'executable', diff --git a/src/node_diagnostics_channel.cc b/src/node_diagnostics_channel.cc index ad6f60c2ba59..eca663c5bf8a 100644 --- a/src/node_diagnostics_channel.cc +++ b/src/node_diagnostics_channel.cc @@ -9,16 +9,15 @@ #include -#if defined(NODE_HAVE_DTRACE) && defined(STAP_HAS_SEMAPHORES) -// Definition of the USDT probe semaphore declared in the dtrace-generated -// node_provider.h. STAP_HAS_SEMAPHORES is only defined by the SystemTap -// dtrace wrapper (Linux), where the .probes ELF section attribute is valid. -// On macOS/FreeBSD/illumos (native DTrace) there is no semaphore variable; -// the kernel handles probe enabling directly. -// The generated header declares this symbol with C++ linkage (no extern "C" -// wrapper), so this definition must also use C++ linkage to ensure the -// linker resolves the same mangled symbol. -unsigned short node_dc__publish_semaphore +#if NODE_HAVE_USDT && defined(NODE_USDT_HAVE_SEMAPHORE) +// Definition of the USDT probe semaphore declared in the committed, +// SystemTap-generated src/node_provider_linux.h (Linux Tier 1). The +// .probes ELF section attribute is only valid there. On native DTrace +// platforms there is no semaphore variable; the kernel handles probe +// enabling directly. The generated header declares this symbol with +// C++ linkage (no extern "C" wrapper), so this definition must also use +// C++ linkage to ensure the linker resolves the same mangled symbol. +unsigned short node_dc__publish_semaphore // NOLINT(runtime/int) __attribute__((section(".probes"))); #endif @@ -187,7 +186,7 @@ void BindingData::SetupProbeSemaphore(Isolate* isolate, Local target) { #else auto backing = ArrayBuffer::NewBackingStore( NodeDCPublishSemaphore(), - sizeof(unsigned short), + sizeof(unsigned short), // NOLINT(runtime/int) [](void*, size_t, void*) {}, // no-op deleter — memory is static nullptr); #endif diff --git a/src/node_provider_linux.h b/src/node_provider_linux.h new file mode 100644 index 000000000000..878f2647bb3d --- /dev/null +++ b/src/node_provider_linux.h @@ -0,0 +1,32 @@ +/* + * This file is generated by tools/usdt/generate_headers.py from + * src/node_provider.d using the SystemTap `dtrace` wrapper. Do not + * edit it by hand. + * + * Regenerate with: python3 tools/usdt/generate_headers.py + * The test-usdt CI job verifies that this file is in sync with + * src/node_provider.d. + */ + +/* Generated by the Systemtap dtrace wrapper */ + + +#define _SDT_HAS_SEMAPHORES 1 + + +#define STAP_HAS_SEMAPHORES 1 /* deprecated */ + + +#include + +/* NODE_DC_PUBLISH ( const char *, const void * ) */ +#if defined STAP_SDT_V1 +#define NODE_DC_PUBLISH_ENABLED() __builtin_expect (dc__publish_semaphore, 0) +#define node_dc__publish_semaphore dc__publish_semaphore +#else +#define NODE_DC_PUBLISH_ENABLED() __builtin_expect (node_dc__publish_semaphore, 0) +#endif +__extension__ extern unsigned short node_dc__publish_semaphore __attribute__ ((unused)) __attribute__ ((section (".probes"))); +#define NODE_DC_PUBLISH(arg1, arg2) \ +DTRACE_PROBE2 (node, dc__publish, arg1, arg2) + diff --git a/src/node_usdt.h b/src/node_usdt.h index c31bb783fded..339842589f61 100644 --- a/src/node_usdt.h +++ b/src/node_usdt.h @@ -3,65 +3,93 @@ #if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS +// USDT probe support for diagnostics_channel. +// +// Tier 1, Linux (on by default): the probe header is pre-generated with +// the SystemTap `dtrace` wrapper and committed at +// src/node_provider_linux.h (regenerate it with +// tools/usdt/generate_headers.py), so Linux builds never need a `dtrace` +// tool at build time. This tier is used automatically whenever +// is available (systemtap-sdt-dev on Debian/Ubuntu, +// systemtap-sdt-devel on Fedora/RHEL). It has effectively zero overhead +// when no tracer is attached: the semaphore check is a single memory +// load on the JS side and the probe site is a no-op until a tracer +// patches it. +// +// Tier 1, macOS (opt-in via ./configure --with-dtrace): the header is +// generated at build time with `dtrace -h` (always present with +// Xcode/CLT). The kernel patches the probe sites to no-ops when no +// tracer is attached, but the JS-to-C++ call for emitPublishProbe() is +// still incurred on every publish, so this tier is opt-in. +// +// Tier 3 (everything else, or --without-dtrace): probes compile to +// no-ops with zero runtime overhead. +// +// FreeBSD/illumos are not supported yet: native DTrace there requires a +// `dtrace -G` link step that is not implemented. + +// Everything in this header is declared at global scope intentionally: +// it shims the dtrace-generated probe headers, which declare their +// symbols at global scope, and its main API is macros, which +// namespaces do not affect. NodeDCPublishSemaphore() stays global for +// the same reason: it hands out the address of one of those symbols +// (or an always-enabled stand-in on macOS), so it lives beside what +// it points at. + #if defined(NODE_NO_USDT) -// All USDT support explicitly disabled via --without-dtrace. + +// Tier 3: explicitly disabled via ./configure --without-dtrace. #define NODE_HAVE_USDT 0 #define NODE_DC_PUBLISH_ENABLED() (0) -#define NODE_DC_PUBLISH_PROBE(name, msg) do {} while (0) +#define NODE_DC_PUBLISH_PROBE(name, msg) \ + do { \ + } while (0) -#elif defined(NODE_HAVE_DTRACE) -// Tier 1: dtrace -h generated header. On Linux (SystemTap wrapper) the -// header defines STAP_HAS_SEMAPHORES and a semaphore variable that starts -// at 0 and is incremented by an attached tracer — zero overhead without a -// tracer. On macOS/FreeBSD/illumos (native DTrace) the header provides an -// is-enabled probe via NODE_DC_PUBLISH_ENABLED() — the kernel patches the -// probe site to a no-op when no tracer is attached. -#include "node_provider.h" +#elif defined(__linux__) && defined(__has_include) && __has_include() +// Tier 1, Linux: committed SystemTap-generated header with semaphore +// support. NODE_DC_PUBLISH_ENABLED() and NODE_DC_PUBLISH() come from +// node_provider_linux.h. NODE_DC_PUBLISH is aliased to +// NODE_DC_PUBLISH_PROBE for consistency with the naming convention used +// in call sites. #define NODE_HAVE_USDT 1 -// NODE_DC_PUBLISH_ENABLED() and NODE_DC_PUBLISH() come from node_provider.h. -// Alias NODE_DC_PUBLISH to NODE_DC_PUBLISH_PROBE for consistency with -// the _ENABLED/_PROBE naming convention used in call sites. +#define NODE_USDT_HAVE_SEMAPHORE 1 + +#include "node_provider_linux.h" + #define NODE_DC_PUBLISH_PROBE(name, msg) NODE_DC_PUBLISH((name), (msg)) -#if defined(STAP_HAS_SEMAPHORES) -// Linux/SystemTap: real semaphore — JS can check without crossing into C++. -inline unsigned short* NodeDCPublishSemaphore() { +// Real semaphore — JS can check it without crossing into C++. +inline unsigned short* NodeDCPublishSemaphore() { // NOLINT(runtime/int) return &node_dc__publish_semaphore; } -#else -// macOS/FreeBSD/illumos: no semaphore variable — always report as enabled -// so that JS calls emitPublishProbe, which checks NODE_DC_PUBLISH_ENABLED() -// (the kernel is-enabled probe) and returns early if no tracer is attached. -inline unsigned short* NodeDCPublishSemaphore() { - static unsigned short always_enabled = 1; - return &always_enabled; -} -#endif -#elif defined(__has_include) && __has_include() -// Tier 2: is available but dtrace -h was not used. The -// semaphore is always 1 so the probe macro is always invoked; on DTrace -// platforms the probe site itself is a no-op until a tracer attaches, but -// the JS-to-C++ call overhead for emitPublishProbe is still incurred. -#include +#elif defined(NODE_HAVE_DTRACE) +// Tier 1, macOS (opt-in --with-dtrace): build-time `dtrace -h` generated +// header. NODE_DC_PUBLISH_ENABLED() and NODE_DC_PUBLISH() come from +// node_provider.h. #define NODE_HAVE_USDT 1 -inline unsigned short* NodeDCPublishSemaphore() { - static unsigned short always_enabled = 1; +#include "node_provider.h" + +#define NODE_DC_PUBLISH_PROBE(name, msg) NODE_DC_PUBLISH((name), (msg)) + +// No semaphore variable — always report as enabled so that JS calls +// emitPublishProbe(), which checks NODE_DC_PUBLISH_ENABLED() (the kernel +// is-enabled probe) and returns early if no tracer is attached. +inline unsigned short* NodeDCPublishSemaphore() { // NOLINT(runtime/int) + static unsigned short always_enabled = 1; // NOLINT(runtime/int) return &always_enabled; } -#define NODE_DC_PUBLISH_ENABLED() (1) - -#define NODE_DC_PUBLISH_PROBE(name, msg) \ - DTRACE_PROBE2(node, dc__publish, (name), (msg)) -#else // Tier 3: no dtrace, no — probes compile to no-ops +#else // Tier 3: no USDT support — probes compile to no-ops #define NODE_HAVE_USDT 0 #define NODE_DC_PUBLISH_ENABLED() (0) -#define NODE_DC_PUBLISH_PROBE(name, msg) do {} while (0) +#define NODE_DC_PUBLISH_PROBE(name, msg) \ + do { \ + } while (0) #endif diff --git a/test/parallel/test-diagnostics-channel-usdt-bpftrace.js b/test/parallel/test-diagnostics-channel-usdt-bpftrace.js index 9ec289b07abc..8bb18b42ec30 100644 --- a/test/parallel/test-diagnostics-channel-usdt-bpftrace.js +++ b/test/parallel/test-diagnostics-channel-usdt-bpftrace.js @@ -2,23 +2,26 @@ 'use strict'; // Verify that the USDT dc__publish probe fires and provides the correct -// channel name by tracing a child Node.js process with bpftrace. +// channel name by tracing child Node.js processes with bpftrace, including +// after a startup snapshot is built (a publish during --build-snapshot must +// not leave probe state that keeps probes disabled after restore). const common = require('../common'); +const { usdtEnabled } = require('../common/usdt'); if (!common.isLinux) common.skip('bpftrace tests are Linux-only'); -const { internalBinding } = require('internal/test/binding'); -const { probeSemaphore } = internalBinding('diagnostics_channel'); -if (probeSemaphore === undefined) +if (!usdtEnabled) common.skip('Node.js built without USDT support'); const assert = require('assert'); +const fs = require('fs'); const { spawnSync } = require('child_process'); const fixtures = require('../common/fixtures'); +const tmpdir = require('../common/tmpdir'); -// bpftrace requires root. +// The bpftrace tool requires root to attach uprobes. if (process.getuid() !== 0) common.skip('bpftrace requires root privileges'); @@ -26,40 +29,166 @@ const bpftrace = spawnSync('bpftrace', ['--version']); if (bpftrace.error) common.skip('bpftrace not found'); -const fixtureScript = fixtures.path('diagnostics-channel-usdt-publish.js'); - -// bpftrace program: attach to the dc__publish probe, print the channel name, -// then exit after the traced process finishes. +// The bpftrace program: attach to the dc__publish probe, print the channel +// name, then exit after the traced process finishes. const bpfProgram = ` usdt:${process.execPath}:node:dc__publish { printf("PROBE_FIRED channel=%s\\n", str(arg0)); } `; -const result = spawnSync('bpftrace', [ - '-e', bpfProgram, - '-c', `${process.execPath} ${fixtureScript}`, -], { - timeout: 30_000, - encoding: 'utf-8', -}); - -if (result.error) - throw result.error; - -if (result.status !== 0) { - const stderr = result.stderr || ''; - // If bpftrace specifically cannot find our probe, that is a real failure - // in the USDT implementation, not an environmental issue. - if (stderr.includes('No probes found') || - stderr.includes('ERROR: usdt probe')) { - assert.fail(`USDT probe broken - bpftrace could not attach: ${stderr}`); +// Run `command` under bpftrace and return the spawn result. If bpftrace +// cannot find the USDT probe that is a failure of the USDT implementation +// itself; other bpftrace failures are treated as environmental. +function runBpftrace(command) { + const result = spawnSync('bpftrace', ['-e', bpfProgram, '-c', command], { + timeout: 30_000, + encoding: 'utf-8', + }); + + assert.ifError(result.error); + + if (result.status !== 0) { + const stderr = result.stderr || ''; + // If bpftrace specifically cannot find our probe, that is a real + // failure in the USDT implementation, not an environmental issue. + if (stderr.includes('No probes found') || + stderr.includes('ERROR: usdt probe')) { + assert.fail(`USDT probe broken - bpftrace could not attach: ${stderr}`); + } + // Otherwise bpftrace may fail for kernel/permission reasons unrelated + // to our code. + common.skip(`bpftrace exited with status ${result.status}: ${stderr}`); } - // Otherwise bpftrace may fail for kernel/permission reasons unrelated - // to our code. - common.skip(`bpftrace exited with status ${result.status}: ${stderr}`); + + return result; +} + +// Scenario 1: a plain publish is observable by a tracer. +{ + const fixtureScript = fixtures.path('diagnostics-channel-usdt-publish.js'); + + const result = runBpftrace(`${process.execPath} ${fixtureScript}`); + assert.match(result.stdout, /PROBE_FIRED channel=test:usdt:bpftrace/, + `Expected probe to fire with channel name. stdout: ${result.stdout}`); } -const output = result.stdout; -assert.match(output, /PROBE_FIRED channel=test:usdt:bpftrace/, - `Expected probe to fire with channel name. stdout: ${output}`); +// Scenario 2: publish during --build-snapshot, then restore the snapshot +// and publish again under a tracer. This covers the case where probe state +// captured while building a startup snapshot goes stale on deserialization. +{ + tmpdir.refresh(); + const blobPath = tmpdir.resolve('usdt-snapshot.blob'); + const buildScript = tmpdir.resolve('usdt-snapshot-build.js'); + const runScript = tmpdir.resolve('usdt-snapshot-run.js'); + const childSource = ` +'use strict'; +const dc = require('node:diagnostics_channel'); +const ch = dc.channel('test:usdt:snapshot'); +ch.subscribe(() => {}); +for (let i = 0; i < 10; i++) { + ch.publish({ seq: i }); +} +`; + fs.writeFileSync(buildScript, childSource); + fs.writeFileSync(runScript, childSource); + + const build = spawnSync(process.execPath, [ + '--snapshot-blob', + blobPath, + '--build-snapshot', + buildScript, + ], { + timeout: 30_000, + encoding: 'utf-8', + }); + assert.ifError(build.error); + assert.strictEqual(build.status, 0, + `snapshot build failed: ${build.stderr}`); + + const result = runBpftrace( + `${process.execPath} --snapshot-blob=${blobPath} ${runScript}`); + assert.match(result.stdout, /PROBE_FIRED channel=test:usdt:snapshot/, + `Expected probe to fire after snapshot restore. stdout: ${result.stdout}`); +} + +// Scenario 3: a tracer alone is interest. A channel with no JavaScript +// subscriber reports hasSubscribers and produces probe events from publish, +// runStores, and tracing helpers. Symbols must not report interest. +{ + tmpdir.refresh(); + const script = tmpdir.resolve('usdt-tracer-only.js'); + fs.writeFileSync(script, ` +'use strict'; +const dc = require('node:diagnostics_channel'); +const ch = dc.channel('test:usdt:tracer-only'); +if (!ch.hasSubscribers) throw new Error('expected tracer interest'); +if (!dc.hasSubscribers('test:usdt:never-created')) { + throw new Error('expected module-level tracer interest'); +} +if (dc.channel(Symbol('sym')).hasSubscribers) { + throw new Error('symbols must not carry the probe'); +} +ch.publish({ via: 'publish' }); +ch.runStores({ via: 'runStores' }, () => {}); +dc.tracingChannel('test:usdt:tracer-only').traceSync(() => {}); +`); + + const result = runBpftrace(`${process.execPath} ${script}`); + assert.match(result.stdout, /PROBE_FIRED channel=test:usdt:tracer-only$/m, + `Expected probe to fire without JS subscribers. stdout: ${result.stdout}`); + assert.match(result.stdout, + /PROBE_FIRED channel=tracing:test:usdt:tracer-only:start/, + `Expected tracing start probe to fire. stdout: ${result.stdout}`); + assert.match(result.stdout, + /PROBE_FIRED channel=tracing:test:usdt:tracer-only:end/, + `Expected tracing end probe to fire. stdout: ${result.stdout}`); +} + +// Scenario 4: a tracer alone observes sqlite.db.query. SQLite installs its +// trace hook from producer interest, so SQL runs without any JavaScript +// subscriber still reach the probe. The traced process also opens a +// database that already exists on disk, covering hook reconciliation at +// open for an existing file. Skipped on --without-sqlite builds. The +// remaining scenarios do not depend on node:sqlite. +if (process.versions.sqlite === undefined) { + console.log('skipping sqlite scenario: node:sqlite not built in'); +} else { + tmpdir.refresh(); + const dbPath = tmpdir.resolve('usdt-sqlite-existing.db'); + const setup = tmpdir.resolve('usdt-sqlite-setup.js'); + fs.writeFileSync(setup, ` +'use strict'; +const { DatabaseSync } = require('node:sqlite'); +const db = new DatabaseSync(${JSON.stringify(dbPath)}); +db.exec('CREATE TABLE t (x INTEGER)'); +db.exec('INSERT INTO t VALUES (1)'); +db.close(); +`); + const setupResult = spawnSync(process.execPath, [setup]); + assert.ifError(setupResult.error); + assert.strictEqual(setupResult.status, 0, + `sqlite setup failed: ${setupResult.stderr}`); + + const script = tmpdir.resolve('usdt-sqlite-tracer-only.js'); + fs.writeFileSync(script, ` +'use strict'; +const { DatabaseSync } = require('node:sqlite'); +const db = new DatabaseSync(${JSON.stringify(dbPath)}); +db.prepare('SELECT x FROM t').get(); +db.exec('INSERT INTO t VALUES (2)'); +db.close(); +const mem = new DatabaseSync(':memory:'); +mem.exec('CREATE TABLE t (x INTEGER)'); +const stmt = mem.prepare('INSERT INTO t VALUES (?)'); +stmt.run(1); +const session = mem.createSession({ table: 't' }); +session.changeset(); +mem.serialize(); +mem.close(); +`); + + const result = runBpftrace(`${process.execPath} ${script}`); + assert.match(result.stdout, /PROBE_FIRED channel=sqlite\.db\.query/, + `Expected sqlite probe to fire without JS subscribers. stdout: ${result.stdout}`); +} diff --git a/tools/usdt/README.md b/tools/usdt/README.md new file mode 100644 index 000000000000..9f1336d8802a --- /dev/null +++ b/tools/usdt/README.md @@ -0,0 +1,55 @@ +# USDT probe headers + +This directory contains tooling for the `diagnostics_channel` USDT +(User-Level Statically Defined Tracing) probes. + +## Why the Linux probe header is committed + +The probe definitions live in [`src/node_provider.d`][d]. On Linux the +probe header is *not* generated at build time. It is generated with the +SystemTap `dtrace` wrapper and committed as +[`src/node_provider_linux.h`][h] instead, so that: + +* building Node.js with USDT support on Linux requires only + `` (the `systemtap-sdt-dev` package on Debian/Ubuntu, + `systemtap-sdt-devel` on Fedora/RHEL) — no `dtrace` tool, and +* the same probe header is used by every Linux build, so a missing or + misbehaving `dtrace` tool can never silently change the build. + +Linux is the only platform that works this way because the SystemTap +`dtrace -h` output is portable across kernels (it only depends on +``), while native DTrace implementations (macOS, FreeBSD, +illumos) produce platform-specific headers and, except on macOS, require +extra `dtrace -G` link-time processing that is not implemented. On macOS +the header is generated at build time when configuring with +`--with-dtrace`. + +## Regenerating + +After changing `src/node_provider.d`: + +```console +$ python3 tools/usdt/generate_headers.py +wrote /path/to/node/src/node_provider_linux.h +``` + +The SystemTap `dtrace` wrapper must be in `PATH` (it is installed with +`systemtap-sdt-dev`/`systemtap-sdt-devel`; override the binary with +`--dtrace` or the `DTRACE` environment variable). Native DTrace +implementations are rejected because their output is not the committed +format. + +Commit the result together with the `src/node_provider.d` change. + +## Drift check + +CI (`test-usdt` job in `.github/workflows/test-linux.yml`) verifies that +the committed header matches `src/node_provider.d`: + +```console +$ python3 tools/usdt/generate_headers.py --check +/path/to/node/src/node_provider_linux.h is up to date +``` + +[d]: ../../src/node_provider.d +[h]: ../../src/node_provider_linux.h diff --git a/tools/usdt/generate_headers.py b/tools/usdt/generate_headers.py new file mode 100755 index 000000000000..ac1a7f7194d8 --- /dev/null +++ b/tools/usdt/generate_headers.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +# Generate the committed USDT probe header (src/node_provider_linux.h) +# from src/node_provider.d using the SystemTap `dtrace` wrapper. +# +# The header is committed to the repository so that Node.js can be built +# with USDT probe support on Linux without a `dtrace` tool at build time; +# only is required (from the systemtap-sdt-dev package on +# Debian/Ubuntu, or systemtap-sdt-devel on Fedora/RHEL). +# +# Usage: +# python3 tools/usdt/generate_headers.py # regenerate +# python3 tools/usdt/generate_headers.py --check # verify (CI) +# +# The SystemTap `dtrace` wrapper must be in PATH. Override the binary +# with --dtrace or the DTRACE environment variable. The native DTrace +# implementations (macOS, FreeBSD, illumos) are deliberately rejected: +# they produce a different, platform-specific header and require extra +# link-time processing; only the SystemTap output is committed. + +import argparse +import os +import subprocess +import sys +import tempfile + +ROOT = os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +PROVIDER = os.path.join(ROOT, 'src', 'node_provider.d') +OUTPUT = os.path.join(ROOT, 'src', 'node_provider_linux.h') + +BANNER = """\ +/* + * This file is generated by tools/usdt/generate_headers.py from + * src/node_provider.d using the SystemTap `dtrace` wrapper. Do not + * edit it by hand. + * + * Regenerate with: python3 tools/usdt/generate_headers.py + * The test-usdt CI job verifies that this file is in sync with + * src/node_provider.d. + */ + +""" + + +def generate(dtrace_bin): + fd, tmp = tempfile.mkstemp(suffix='.h') + os.close(fd) + try: + subprocess.check_call( + [dtrace_bin, '-h', '-s', PROVIDER, '-o', tmp], cwd=ROOT) + with open(tmp) as f: + content = f.read() + finally: + os.unlink(tmp) + + if 'Systemtap dtrace wrapper' not in content: + sys.exit( + '%s did not produce SystemTap output. The header must be ' + 'generated with the SystemTap dtrace wrapper ' + '(systemtap-sdt-dev on Debian/Ubuntu, systemtap-sdt-devel on ' + 'Fedora/RHEL), not a native DTrace implementation.' % dtrace_bin) + + return BANNER + content + + +def main(): + parser = argparse.ArgumentParser( + description='Regenerate committed USDT probe headers.') + parser.add_argument( + '--check', action='store_true', + help='verify that the committed header is up to date; ' + 'exit with 1 on drift') + parser.add_argument( + '--dtrace', default=os.environ.get('DTRACE', 'dtrace'), + help='path to the SystemTap dtrace wrapper [default: %(default)s]') + args = parser.parse_args() + + content = generate(args.dtrace) + + if args.check: + with open(OUTPUT) as f: + committed = f.read() + if committed != content: + sys.exit( + '%s is out of date with src/node_provider.d. ' + 'Regenerate it with: python3 tools/usdt/generate_headers.py' + % OUTPUT) + print('%s is up to date' % OUTPUT) + return + + with open(OUTPUT, 'w') as f: + f.write(content) + print('wrote %s' % OUTPUT) + + +if __name__ == '__main__': + main()