Skip to content

Ship libdd-shared-runtime-ffi as its own cdylib#52

Closed
lloeki wants to merge 1 commit into
mainfrom
lloeki/shared-runtime
Closed

Ship libdd-shared-runtime-ffi as its own cdylib#52
lloeki wants to merge 1 commit into
mainfrom
lloeki/shared-runtime

Conversation

@lloeki

@lloeki lloeki commented Jun 3, 2026

Copy link
Copy Markdown
Member

Why?

The Ruby trace exporter needs libdatadog's shared-runtime fork-lifecycle
functions (ddog_shared_runtime_new / _before_fork / _after_fork_parent /
_after_fork_child) so it can quiesce and restart the exporter's background
runtime across fork(). Those symbols live in the libdd-shared-runtime-ffi
crate, but they are not currently shipped: the aggregate
libdatadog_profiling.so is built solely from libdd-profiling-ffi, which does
not depend on libdd-shared-runtime-ffi. Only the opaque ddog_SharedRuntime
type and ddog_trace_exporter_config_set_shared_runtime leak in transitively.

What does this PR do?

Builds the existing libdd-shared-runtime-ffi crate as its own cdylib
(libdatadog_shared_runtime.so) and installs it, together with its
cbindgen-generated shared-runtime.h, into the vendored platform tree — without
any change to the libdatadog source. The gem packager already globs the vendored
tree, so the extra library and header are picked up automatically.

This keeps the change entirely within libdatadog-rb (the repo whose purpose is
custom packaging of libdatadog artifacts).

How to test the change?

Build from a local libdatadog checkout and confirm the artifacts:

LIBDATADOG_SOURCE=/path/to/libdatadog bundle exec rake libdatadog:build
nm -D vendor/libdatadog-<ver>/<platform>/lib/libdatadog_shared_runtime.so | grep ddog_shared_runtime
ls vendor/libdatadog-<ver>/<platform>/include/datadog/shared-runtime.h

A standalone C program that links -ldatadog_shared_runtime and runs
new -> before_fork -> after_fork_parent -> free exits cleanly.

Additional Notes:

Currently only source builds (LIBDATADOG_SOURCE) are handled; tag/release
builds skip the step and would need a checkout. See the commit message for the
known cross-library caveat (two statically linked copies of the shared-runtime
code).

Draft, kept for archival/decision-tracking purposes.

JIRA:

The trace exporter drives its fork-safety lifecycle through the generic
shared-runtime FFI (`ddog_shared_runtime_new`, `_before_fork`,
`_after_fork_parent`, `_after_fork_child`), but those symbols are not
shipped. The aggregate `libdatadog_profiling.so` is built solely from
`libdd-profiling-ffi`, which does not depend on `libdd-shared-runtime-ffi`,
so only the opaque `ddog_SharedRuntime` type and
`ddog_trace_exporter_config_set_shared_runtime` leak in transitively.

Build that crate as its own cdylib (it already declares a `cdylib` crate
type) and install it as `libdatadog_shared_runtime.so` alongside its
cbindgen-generated `shared-runtime.h`, so consumers can link the lifecycle
functions without changing the libdatadog source.

Caveat: this leaves a second, statically linked copy of the shared-runtime
and tokio code beside the copy already inside `libdatadog_profiling.so`. A
`SharedRuntime` handle is created in one library and driven from the other.
The two copies are ABI-compatible because they are built from the same
source revision and the handle crosses the boundary as an opaque pointer,
so this is sound at the link/ABI level. The behavior of a tokio runtime
created in one library and quiesced/restarted from another across `fork()`
is not yet verified; an integration test that forks with an exporter
attached must confirm it before this is relied upon in production.

Only source builds (via `LIBDATADOG_SOURCE`) are handled; tag/release
builds skip this step and would need a checkout. The cleaner long-term fix
is to have the aggregate library include the crate so a single library
exports everything.
@lloeki

lloeki commented Jun 3, 2026

Copy link
Copy Markdown
Member Author

Outcome: this approach does not work — recording why before abandoning it

I wired this branch into the dd-trace-rb native trace exporter to validate it end
to end: DataDog/dd-trace-rb#5835 (branch lloeki/native-trace-exporter-fork-safety),
local commits 835dcfc8f2 ("Link native extension against libdatadog_shared_runtime")
and 0f3bbf1e15 ("Rewire native trace exporter to redesigned fork-safety FFI"),
which create a SharedRuntime via ddog_shared_runtime_new, attach it with
ddog_trace_exporter_config_set_shared_runtime, and drive
ddog_shared_runtime_before_fork / _after_fork_parent / _after_fork_child.

It compiles and links cleanly, but ddog_trace_exporter_new segfaults at runtime.

Root cause

The exporter FFI (ddog_trace_exporter_*, cancel_token_*) lives in
libdatadog_profiling.so, while the lifecycle functions shipped by this PR live in
libdatadog_shared_runtime.so. Each .so statically embeds its own copy of the
libdd_shared_runtime crate
(88 internal libdd_shared_runtime::* symbols in
libdatadog_profiling.so, 41 in libdatadog_shared_runtime.so). So:

  • ddog_shared_runtime_new builds a SharedRuntime using this PR's copy of the crate;
  • ddog_trace_exporter_config_set_shared_runtime just clones the Arc (fine);
  • ddog_trace_exporter_new then spins up the exporter's workers using
    libdatadog_profiling.so's own, different copy of the crate/tokio against that
    SharedRuntime — dereferencing a vtable/function pointer from the wrong image → crash.

Control: skipping only the attach call lets ddog_trace_exporter_new succeed, which
confirms the dd-trace-rb rewiring is correct and the fault is purely the duplicated
crate copy across two libraries. It is not fixable from the consumer side (the internal
crate symbols are local / non-interposable).

Conclusion

The exporter FFI and the shared-runtime FFI must share a single copy of
libdd_shared_runtime at runtime. Shipping the shared-runtime FFI as a separate
library cannot satisfy that. The viable fix is to emit ddog_shared_runtime_* into the
single libdatadog_profiling.so (have libdd-profiling-ffi depend, feature-gated, on
libdd-shared-runtime-ffi), which is being pursued upstream in libdatadog instead.

Leaving this PR as a draft for the record; not merging.

lloeki added a commit to DataDog/libdatadog that referenced this pull request Jun 3, 2026
The trace-exporter redesign on this branch drives fork safety through the
generic shared-runtime FFI (ddog_shared_runtime_new / _free / _before_fork /
_after_fork_parent / _after_fork_child), but those symbols aren't shipped
today: the builder compiles one aggregate cdylib from libdd-profiling-ffi's
dependency graph, which doesn't include libdd-shared-runtime-ffi.

Shipping it as a SEPARATE library was tried and fails at runtime with a
SIGSEGV, because each library statically embeds its own copy of
libdd_shared_runtime and a SharedRuntime handle created in one library cannot
be driven from the other. Evidence and analysis:
DataDog/libdatadog-rb#52 (comment)

Co-locate the FFI in the single profiling library so there is exactly one copy
of the libdd_shared_runtime crate at runtime. cargo unifies it with the copy
already pulled in via data-pipeline. The change is additive and opt-in behind a
new `shared-runtime` feature, so other consumers are unaffected unless enabled.

- libdd-profiling-ffi: add optional libdd-shared-runtime-ffi dependency behind a
  `shared-runtime` feature, re-export its symbols, and propagate cbindgen.
- builder: add a `shared-runtime` feature (enabled by default), push the
  corresponding libdd-profiling-ffi feature in release.rs, and add
  shared-runtime.h to the header dedup/copy in profiling.rs.
@lloeki lloeki closed this Jun 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant