diff --git a/.github/workflows/test-linux.yml b/.github/workflows/test-linux.yml index 408371f3313c..ebde11c317a5 100644 --- a/.github/workflows/test-linux.yml +++ b/.github/workflows/test-linux.yml @@ -98,3 +98,83 @@ 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + 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/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: ./node/.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 + - 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 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 8b3332a461f4..cdafaf2dfc6a 100755 --- a/configure.py +++ b/configure.py @@ -1066,6 +1066,19 @@ 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', + default=None, + help='build without DTrace/USDT probe support') + parser.add_argument('--without-lief', action='store_true', dest='without_lief', @@ -1360,6 +1373,32 @@ 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 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 + # -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 +2219,41 @@ 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) + # 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 (--with-dtrace, dtrace -h)') + else: + print('USDT probes: disabled (enable with --with-dtrace)') + 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..633667cc1cdc 100644 --- a/doc/api/diagnostics_channel.md +++ b/doc/api/diagnostics_channel.md @@ -1529,6 +1529,84 @@ 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 + +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) + +```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) + +```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..c0021e6d00fa 100644 --- a/lib/diagnostics_channel.js +++ b/lib/diagnostics_channel.js @@ -33,6 +33,14 @@ const { triggerUncaughtException } = internalBinding('errors'); // The subscriber buffer is replaced when native channel storage grows, so it // must always be accessed through the binding instead of cached. const dc_binding = internalBinding('diagnostics_channel'); +// The USDT probe semaphore is exposed by the binding as a Uint16Array +// view over static native memory. It must be resolved lazily rather than +// captured at module load: this module is included in the startup +// snapshot, and the view's native backing store cannot be serialized: +// a view captured while building the snapshot is detached when the +// snapshot is deserialized. `null` marks a USDT-less build after the +// first resolution so that the hot path stays branch-only in that case. +let probeSemaphore; const { WeakReference, kEmptyObject } = require('internal/util'); const { isPromise } = require('internal/util/types'); @@ -188,6 +196,14 @@ class ActiveChannel { } publish(data) { + if (probeSemaphore === undefined) { + probeSemaphore = dc_binding.probeSemaphore ?? null; + } + if (probeSemaphore !== null && + 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 52f421f3181c..cfe23dfc255b 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,9 @@ 'src/node_metadata.h', 'src/node_mutex.h', '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', @@ -901,6 +906,14 @@ 'WARNING_CFLAGS': [ '-Werror' ], }, }], + [ 'node_no_usdt=="true"', { + 'defines': [ 'NODE_NO_USDT=1' ], + }], + [ 'node_use_dtrace=="true"', { + 'defines': [ 'NODE_HAVE_DTRACE=1' ], + 'dependencies': [ 'node_dtrace_header' ], + 'include_dirs': [ '<(SHARED_INTERMEDIATE_DIR)' ], + }], [ 'node_builtin_modules_path!=""', { 'defines': [ 'NODE_BUILTIN_MODULES_PATH="<(node_builtin_modules_path)"' ], }], @@ -1624,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 2593f6eab90f..c2f8e38222f3 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,22 @@ #include +#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 + namespace node { namespace diagnostics_channel { +using v8::ArrayBuffer; using v8::Context; using v8::Function; using v8::FunctionCallbackInfo; @@ -23,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,6 +140,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 +171,44 @@ 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), // NOLINT(runtime/int) + [](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 } void BindingData::CreatePerContextProperties(Local target, @@ -168,6 +218,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,8 +228,25 @@ void BindingData::RegisterExternalReferences( registry->Register(LinkNativeChannel); registry->Register(NotifyChannelActive); registry->Register(NotifyChannelInactive); +#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, @@ -279,15 +349,39 @@ 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); @@ -298,12 +392,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..832dd5c26e78 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; @@ -52,6 +53,9 @@ class BindingData : public SnapshotableObject { static void LinkNativeChannel( const v8::FunctionCallbackInfo& args); +#if NODE_HAVE_USDT + static void EmitPublishProbe(const v8::FunctionCallbackInfo& args); +#endif using ChannelStatusCallback = std::function; void SetChannelStatusCallback(uint32_t index, ChannelStatusCallback cb); @@ -70,6 +74,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_; }; 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_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 new file mode 100644 index 000000000000..339842589f61 --- /dev/null +++ b/src/node_usdt.h @@ -0,0 +1,98 @@ +#ifndef SRC_NODE_USDT_H_ +#define SRC_NODE_USDT_H_ + +#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) + +// 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) + +#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 +#define NODE_USDT_HAVE_SEMAPHORE 1 + +#include "node_provider_linux.h" + +#define NODE_DC_PUBLISH_PROBE(name, msg) NODE_DC_PUBLISH((name), (msg)) + +// Real semaphore — JS can check it without crossing into C++. +inline unsigned short* NodeDCPublishSemaphore() { // NOLINT(runtime/int) + return &node_dc__publish_semaphore; +} + +#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 + +#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; +} + +#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) + +#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 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-usdt-bpftrace.js b/test/parallel/test-diagnostics-channel-usdt-bpftrace.js new file mode 100644 index 000000000000..6191be302126 --- /dev/null +++ b/test/parallel/test-diagnostics-channel-usdt-bpftrace.js @@ -0,0 +1,64 @@ +// 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'); + +// The bpftrace tool requires root to attach uprobes. +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'); + +// 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', +}); + +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}`); +} + +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..4faaf0ea5278 --- /dev/null +++ b/test/parallel/test-diagnostics-channel-usdt.js @@ -0,0 +1,283 @@ +// 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, 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/internal/diagnostics_channel actually calls emitPublishProbe. This +// exercises the module-level view of the semaphore (which must survive +// startup-snapshot deserialization) rather than the binding directly. +if (binding.probeSemaphore !== undefined) { + 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); + } +} 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()