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 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..d0f889e5ee72 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,94 @@ 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 + +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..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..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/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..eca663c5bf8a 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,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), // 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 +#if NODE_DC_SEMAPHORE_PLACEHOLDER + SetMethod(isolate, target, "probeEnabled", ProbeEnabled); +#endif } void BindingData::CreatePerContextProperties(Local target, @@ -168,6 +221,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 +231,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 +363,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 +401,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_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_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..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/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..8bb18b42ec30 --- /dev/null +++ b/test/parallel/test-diagnostics-channel-usdt-bpftrace.js @@ -0,0 +1,194 @@ +// Flags: --expose-internals +'use strict'; + +// Verify that the USDT dc__publish probe fires and provides the correct +// 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'); + +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'); + +// 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'); + +// 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)); +} +`; + +// 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}`); + } + + 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}`); +} + +// 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/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; + } + }); }); 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()