diff --git a/configure.py b/configure.py index fa47e9c48547f2..ede323d60a36ca 100755 --- a/configure.py +++ b/configure.py @@ -948,6 +948,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', @@ -1240,6 +1246,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") @@ -1976,6 +2007,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 4587ee649b5173..6b516a0de4b7e0 100644 --- a/doc/api/diagnostics_channel.md +++ b/doc/api/diagnostics_channel.md @@ -1112,6 +1112,78 @@ 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 without +modifying application code or adding JavaScript 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 only if the channel has active subscribers. + +* `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 bd17965b131208..76a40c6a6fa6a7 100644 --- a/lib/diagnostics_channel.js +++ b/lib/diagnostics_channel.js @@ -32,7 +32,12 @@ const { const { triggerUncaughtException } = internalBinding('errors'); const dc_binding = internalBinding('diagnostics_channel'); -const { subscribers: subscriberCounts } = dc_binding; +const { subscribers: subscriberCounts, probeSemaphore } = dc_binding; +// When compiled without USDT support, probeSemaphore is undefined and +// emitPublishProbe does not exist. Capture this once at load time so that +// when USDT is absent, the hot path in publish() can skip all probe logic +// with a single boolean check. +const hasUSDT = probeSemaphore !== undefined; const { WeakReference } = require('internal/util'); @@ -158,6 +163,9 @@ class ActiveChannel { } publish(data) { + if (hasUSDT && 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 { diff --git a/node.gyp b/node.gyp index 2dd7eb1af5865a..1c3f55b31e14b2 100644 --- a/node.gyp +++ b/node.gyp @@ -37,6 +37,8 @@ 'node_use_openssl%': 'true', 'node_use_quic%': 'false', 'node_use_sqlite%': 'true', + 'node_use_dtrace%': 'false', + 'node_no_usdt%': 'false', 'node_use_v8_platform%': 'true', 'node_v8_options%': '', 'node_write_snapshot_as_string_literals': 'true', @@ -272,6 +274,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', @@ -947,6 +951,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/node_diagnostics_channel.cc b/src/node_diagnostics_channel.cc index 450a124c86959a..97884e31b5c052 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 +// 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; @@ -22,6 +37,7 @@ using v8::Object; using v8::ObjectTemplate; using v8::SnapshotCreator; using v8::String; +using v8::Uint16Array; using v8::Value; BindingData::BindingData(Realm* realm, @@ -125,7 +141,31 @@ 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 +} + +#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. + auto backing = ArrayBuffer::NewBackingStore( + NodeDCPublishSemaphore(), + sizeof(unsigned short), + [](void*, size_t, void*) {}, // no-op deleter — memory is static + nullptr); + 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) { @@ -133,6 +173,9 @@ void BindingData::CreatePerIsolateProperties(IsolateData* isolate_data, SetMethod( isolate, target, "getOrCreateChannelIndex", GetOrCreateChannelIndex); SetMethod(isolate, target, "linkNativeChannel", LinkNativeChannel); +#if NODE_HAVE_USDT + SetMethod(isolate, target, "emitPublishProbe", EmitPublishProbe); +#endif } void BindingData::CreatePerContextProperties(Local target, @@ -142,14 +185,34 @@ 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( ExternalReferenceRegistry* registry) { registry->Register(GetOrCreateChannelIndex); registry->Register(LinkNativeChannel); +#if NODE_HAVE_USDT + registry->Register(EmitPublishProbe); +#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 + Channel::Channel(Environment* env, Local wrap, BindingData* binding_data, @@ -250,15 +313,40 @@ 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. + // This ensures external tracers observe every native publish attempt. + 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 (binding_data_ == nullptr) { + 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); @@ -269,12 +357,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 1c1831a0f9e45f..2625dc2402c746 100644 --- a/src/node_diagnostics_channel.h +++ b/src/node_diagnostics_channel.h @@ -10,6 +10,7 @@ #include "aliased_buffer.h" #include "base_object.h" #include "node_snapshotable.h" +#include "node_usdt.h" namespace node { class ExternalReferenceRegistry; @@ -52,6 +53,10 @@ class BindingData : public SnapshotableObject { const v8::FunctionCallbackInfo& args); static void LinkNativeChannel( const v8::FunctionCallbackInfo& args); +#if NODE_HAVE_USDT + static void EmitPublishProbe( + const v8::FunctionCallbackInfo& args); +#endif static void CreatePerIsolateProperties(IsolateData* isolate_data, v8::Local target); @@ -62,6 +67,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; }; diff --git a/src/node_provider.d b/src/node_provider.d new file mode 100644 index 00000000000000..a8ed2027619626 --- /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_usdt.h b/src/node_usdt.h new file mode 100644 index 00000000000000..c31bb783fded4a --- /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/test/fixtures/diagnostics-channel-usdt-publish.js b/test/fixtures/diagnostics-channel-usdt-publish.js new file mode 100644 index 00000000000000..3ae0b3b65bbada --- /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-usdt-bpftrace.js b/test/parallel/test-diagnostics-channel-usdt-bpftrace.js new file mode 100644 index 00000000000000..9ec289b07abc0a --- /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 00000000000000..568e53844159a5 --- /dev/null +++ b/test/parallel/test-diagnostics-channel-usdt.js @@ -0,0 +1,222 @@ +// 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 dc = require('diagnostics_channel'); +const assert = require('assert'); +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 SystemTap + // dtrace -h path) or 1 (macOS/FreeBSD/illumos dtrace -h path, or + // fallback sys/sdt.h path where the probe always fires). + 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 (Tier 2 fallback), emitPublishProbe must be +// called for string-named channels and must NOT be called for symbol-named +// channels. When the semaphore is 0 (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'); + } else { + assert.strictEqual(probeCallCount, 0, + 'emitPublishProbe should not be called when semaphore is 0'); + } + + // 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'); + + // Restore original. + if (origProbe !== undefined) { + 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); + } +}