From 8b0fcb948e463bfe39999bcf743cd3896703faf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gustavo=20Andr=C3=A9=20dos=20Santos=20Lopes?= Date: Tue, 14 Jul 2026 14:54:49 +0100 Subject: [PATCH 1/4] feat(library-config): add macOS and Windows process context --- .github/workflows/test.yml | 8 + .../src/tracer_metadata.rs | 31 +- libdd-library-config/src/lib.rs | 5 +- libdd-library-config/src/otel_process_ctx.rs | 39 ++- .../src/otel_process_ctx/reader.rs | 16 +- .../otel_process_ctx/reader/copy_pipe_unix.rs | 58 ++++ .../reader/copy_pipe_windows.rs | 284 ++++++++++++++++++ .../src/otel_process_ctx/reader/macos.rs | 103 +++++++ .../src/otel_process_ctx/reader/windows.rs | 176 +++++++++++ .../src/otel_process_ctx/writer.rs | 14 +- .../src/otel_process_ctx/writer/macos.rs | 135 +++++++++ .../src/otel_process_ctx/writer/windows.rs | 109 +++++++ libdd-library-config/src/tracer_metadata.rs | 41 ++- 13 files changed, 999 insertions(+), 20 deletions(-) create mode 100644 libdd-library-config/src/otel_process_ctx/reader/copy_pipe_windows.rs create mode 100644 libdd-library-config/src/otel_process_ctx/reader/macos.rs create mode 100644 libdd-library-config/src/otel_process_ctx/reader/windows.rs create mode 100644 libdd-library-config/src/otel_process_ctx/writer/macos.rs create mode 100644 libdd-library-config/src/otel_process_ctx/writer/windows.rs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8673c56be6..14865956b5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -163,6 +163,14 @@ jobs: fi env: RUST_BACKTRACE: full + - name: "[${{ steps.rust-version.outputs.version}}] cargo nextest run -p libdd-library-config --no-default-features --features process-context-reader" + shell: bash + run: | + if [[ -z "$PACKAGES" ]] || echo "$PACKAGES" | grep -q "libdd-library-config"; then + cargo nextest run -p libdd-library-config --no-default-features --features process-context-reader --profile ci --verbose --no-tests=pass + fi + env: + RUST_BACKTRACE: full - name: "[${{ steps.rust-version.outputs.version}}] cargo nextest run --all-features" shell: bash run: | diff --git a/libdd-library-config-ffi/src/tracer_metadata.rs b/libdd-library-config-ffi/src/tracer_metadata.rs index 6fbb22ad75..1cf12043fa 100644 --- a/libdd-library-config-ffi/src/tracer_metadata.rs +++ b/libdd-library-config-ffi/src/tracer_metadata.rs @@ -11,7 +11,7 @@ use std::os::raw::{c_char, c_int}; /// C-compatible representation of an anonymous file handle #[repr(C)] pub struct TracerMemfdHandle { - /// File descriptor (relevant only on Linux) + /// File descriptor on Linux; `-1` on other platforms. pub fd: c_int, } @@ -102,21 +102,17 @@ pub unsafe extern "C" fn ddog_tracer_metadata_set( } } -/// Serializes the `TracerMetadata` into a platform-specific memory handle (e.g., memfd on Linux). -/// This function also attempts to publish the tracer metadata as an OTel process context -/// separately, but will ignore resulting errors. +/// Stores the `TracerMetadata` using the platform's supported mechanisms. Linux serializes the +/// metadata into a memfd and attempts to publish it as an OTel process context. Other platforms +/// publish only the OTel process context. /// /// # Safety /// - `ptr` must be a valid, non-null pointer to a `TracerMetadata`. /// /// # Returns /// - On Linux: a `TracerMemfdHandle` containing a raw file descriptor to a memory file. -/// - On unsupported platforms: an error. +/// - On other platforms: a `TracerMemfdHandle` with `fd` set to `-1`. /// - On failure: propagates any internal errors from the metadata storage process. -/// -/// # Platform Support -/// This function currently only supports Linux via `memfd`. On other platforms, -/// it will return an error. #[no_mangle] pub unsafe extern "C" fn ddog_tracer_metadata_store( ptr: *mut TracerMetadata, @@ -141,8 +137,23 @@ pub unsafe extern "C" fn ddog_tracer_metadata_store( }) } #[cfg(not(target_os = "linux"))] - Ok(_) => Err(anyhow::anyhow!("Unsupported platform")), + Ok(_) => Ok(TracerMemfdHandle { fd: -1 }), Err(err) => Err(err), }; result.into() } + +#[cfg(all(test, not(target_os = "linux")))] +mod tests { + use super::{ddog_tracer_metadata_store, TracerMetadata}; + + #[test] + fn store_returns_success_without_a_file_descriptor() { + let mut metadata = TracerMetadata::default(); + + let handle = unsafe { ddog_tracer_metadata_store(&mut metadata) }.unwrap(); + assert_eq!(handle.fd, -1); + + libdd_library_config::otel_process_ctx::unpublish().expect("unpublish should succeed"); + } +} diff --git a/libdd-library-config/src/lib.rs b/libdd-library-config/src/lib.rs index d801748d9f..01d1c5c971 100644 --- a/libdd-library-config/src/lib.rs +++ b/libdd-library-config/src/lib.rs @@ -2,10 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 extern crate alloc; -#[cfg(all( - target_os = "linux", - any(feature = "process-context-reader", feature = "process-context-writer") -))] +#[cfg(any(feature = "process-context-reader", feature = "process-context-writer"))] pub mod otel_process_ctx; pub mod tracer_metadata; diff --git a/libdd-library-config/src/otel_process_ctx.rs b/libdd-library-config/src/otel_process_ctx.rs index 8a34cb6801..fd04c2637f 100644 --- a/libdd-library-config/src/otel_process_ctx.rs +++ b/libdd-library-config/src/otel_process_ctx.rs @@ -1,9 +1,16 @@ // Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ // SPDX-License-Identifier: Apache-2.0 -//! Implementation of the Linux parts of the [OTEL process +//! Implementation of the [OTEL process //! context specification](https://github.com/open-telemetry/opentelemetry-specification/blob/main/oteps/profiles/4719-process-ctx.md). //! +//! Note: the Linux implementation follows the discovery method described in the OTEL process +//! specification linked above, that is, uses a memfd or a named mapping with the name OTEL_CTX. +//! This is a strategy only viable on Linux, since MacOS and Windows do not have those exact +//! features. Here, the MacOS and Windows implementations, on the other hand, use a global atomic +//! pointer to the mapping header that is published as a symbol named `otel_process_ctx_v2`. +//! SUCH MECHANISM IS NOT PART OF THE SPECIFICATION, which deals only with Linux. +//! //! The update/read protocol is seqlock-style: the publisher marks the mapping as unavailable, //! writes the payload metadata, publishes a non-zero version, and readers accept a copy only if //! the version they observed before copying still matches afterward. The general algorithm and @@ -85,8 +92,38 @@ mod tests { } } + #[cfg(any(target_os = "macos", target_os = "windows"))] + mod non_linux { + use super::super::MappingHeaderSnapshot; + use core::{ptr, sync::atomic::Ordering}; + use std::io; + + #[cfg(target_os = "macos")] + use super::super::writer::macos::otel_process_ctx_v2; + #[cfg(target_os = "windows")] + use super::super::writer::windows::otel_process_ctx_v2; + + pub(super) fn read_process_context() -> io::Result { + let header_ptr: *const MappingHeaderSnapshot = + otel_process_ctx_v2.load(Ordering::Acquire).cast(); + if header_ptr.is_null() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + "no process context is published", + )); + } + Ok(unsafe { ptr::read(header_ptr) }) + } + + pub(super) fn is_published() -> bool { + !otel_process_ctx_v2.load(Ordering::Acquire).is_null() + } + } + #[cfg(target_os = "linux")] use linux::{is_published, read_process_context}; + #[cfg(any(target_os = "macos", target_os = "windows"))] + use non_linux::{is_published, read_process_context}; #[test] #[cfg_attr(miri, ignore)] diff --git a/libdd-library-config/src/otel_process_ctx/reader.rs b/libdd-library-config/src/otel_process_ctx/reader.rs index da766907ca..d96eefe82e 100644 --- a/libdd-library-config/src/otel_process_ctx/reader.rs +++ b/libdd-library-config/src/otel_process_ctx/reader.rs @@ -16,16 +16,28 @@ use prost::Message; use super::{MappingHeaderSnapshot, PROCESS_CTX_VERSION, SIGNATURE, UNPUBLISHED_OR_UPDATING}; -#[cfg(target_os = "linux")] +#[cfg(unix)] mod copy_pipe_unix; +#[cfg(windows)] +mod copy_pipe_windows; #[cfg(target_os = "linux")] pub(super) mod linux; +#[cfg(target_os = "macos")] +pub(super) mod macos; +#[cfg(target_os = "windows")] +pub(super) mod windows; -#[cfg(target_os = "linux")] +#[cfg(unix)] use copy_pipe_unix::CopyPipe as PlatformCopyPipe; +#[cfg(windows)] +use copy_pipe_windows::CopyPipe as PlatformCopyPipe; #[cfg(target_os = "linux")] type PlatformHeaderDiscovery = linux::HeaderDiscovery; +#[cfg(target_os = "macos")] +type PlatformHeaderDiscovery = macos::HeaderDiscovery; +#[cfg(target_os = "windows")] +type PlatformHeaderDiscovery = windows::HeaderDiscovery; pub(super) trait ReaderPlatform { fn discover_header() -> io::Result>; diff --git a/libdd-library-config/src/otel_process_ctx/reader/copy_pipe_unix.rs b/libdd-library-config/src/otel_process_ctx/reader/copy_pipe_unix.rs index 4c75d29dfb..03c54076cf 100644 --- a/libdd-library-config/src/otel_process_ctx/reader/copy_pipe_unix.rs +++ b/libdd-library-config/src/otel_process_ctx/reader/copy_pipe_unix.rs @@ -174,6 +174,64 @@ fn create_pipe() -> io::Result<(OwnedFd, OwnedFd, usize)> { Ok((read_fd, write_fd, capacity as usize)) } +#[cfg(target_os = "macos")] +fn create_pipe() -> io::Result<(OwnedFd, OwnedFd, usize)> { + let mut fds = [0; 2]; + // SAFETY: fds points to space for the two descriptors returned by pipe. + if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 { + return Err(last_error("failed to create process context copy pipe")); + } + + // SAFETY: pipe initialized both descriptors and ownership is transferred exactly once. + let (read_fd, write_fd) = + unsafe { (OwnedFd::from_raw_fd(fds[0]), OwnedFd::from_raw_fd(fds[1])) }; + configure_fd(&read_fd)?; + configure_fd(&write_fd)?; + + // POSIX guarantees that an empty pipe accepts at least PIPE_BUF bytes without blocking. + // SAFETY: write_fd is a valid pipe descriptor. + let chunk_size = unsafe { libc::fpathconf(write_fd.as_raw_fd(), libc::_PC_PIPE_BUF) }; + if chunk_size <= 0 { + return Err(last_error( + "failed to query process context copy pipe capacity", + )); + } + + Ok((read_fd, write_fd, chunk_size as usize)) +} + +#[cfg(target_os = "macos")] +fn configure_fd(fd: &OwnedFd) -> io::Result<()> { + // SAFETY: fd is a valid descriptor. + let status = unsafe { libc::fcntl(fd.as_raw_fd(), libc::F_GETFL) }; + if status < 0 { + return Err(last_error( + "failed to query process context copy pipe status flags", + )); + } + // SAFETY: fd is valid and F_SETFL accepts the status flags returned above. + if unsafe { libc::fcntl(fd.as_raw_fd(), libc::F_SETFL, status | libc::O_NONBLOCK) } < 0 { + return Err(last_error( + "failed to make process context copy pipe non-blocking", + )); + } + + // SAFETY: fd is a valid descriptor. + let descriptor = unsafe { libc::fcntl(fd.as_raw_fd(), libc::F_GETFD) }; + if descriptor < 0 { + return Err(last_error( + "failed to query process context copy pipe descriptor flags", + )); + } + // SAFETY: fd is valid and F_SETFD accepts the descriptor flags returned above. + if unsafe { libc::fcntl(fd.as_raw_fd(), libc::F_SETFD, descriptor | libc::FD_CLOEXEC) } < 0 { + return Err(last_error( + "failed to mark process context copy pipe close-on-exec", + )); + } + Ok(()) +} + fn last_error(context: &'static str) -> io::Error { let err = io::Error::last_os_error(); io::Error::new(err.kind(), format!("{context}: {err}")) diff --git a/libdd-library-config/src/otel_process_ctx/reader/copy_pipe_windows.rs b/libdd-library-config/src/otel_process_ctx/reader/copy_pipe_windows.rs new file mode 100644 index 0000000000..d9a147640d --- /dev/null +++ b/libdd-library-config/src/otel_process_ctx/reader/copy_pipe_windows.rs @@ -0,0 +1,284 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +use core::{ffi::c_void, ptr}; +use std::io; + +use super::{PipeCopyError, ProcessMemoryCopy}; + +type Handle = *mut c_void; + +const REQUESTED_PIPE_BUFFER_SIZE: u32 = 4096; +const ERROR_NOACCESS: i32 = 998; +const ERROR_INVALID_USER_BUFFER: i32 = 1784; + +#[link(name = "kernel32")] +unsafe extern "system" { + fn CreatePipe( + read_pipe: *mut Handle, + write_pipe: *mut Handle, + pipe_attributes: *mut c_void, + size: u32, + ) -> i32; + fn ReadFile( + file: Handle, + buffer: *mut c_void, + bytes_to_read: u32, + bytes_read: *mut u32, + overlapped: *mut c_void, + ) -> i32; + fn GetNamedPipeInfo( + named_pipe: Handle, + flags: *mut u32, + out_buffer_size: *mut u32, + in_buffer_size: *mut u32, + max_instances: *mut u32, + ) -> i32; + fn WriteFile( + file: Handle, + buffer: *const c_void, + bytes_to_write: u32, + bytes_written: *mut u32, + overlapped: *mut c_void, + ) -> i32; + fn CloseHandle(object: Handle) -> i32; +} + +/// A cached anonymous pipe used to probe-copy process memory through the Windows kernel. +pub(super) struct CopyPipe { + read_handle: Handle, + write_handle: Handle, + chunk_size: u32, +} + +// SAFETY: the owned handles may move between threads. CopyPipe is not Sync because copy(&self) +// mutates the pipe state, so concurrent calls could interleave. +unsafe impl Send for CopyPipe {} + +impl ProcessMemoryCopy for CopyPipe { + fn new() -> io::Result { + let mut read_handle = ptr::null_mut(); + let mut write_handle = ptr::null_mut(); + // SAFETY: both handle pointers are valid out-parameters; security attributes are optional. + let result = unsafe { + CreatePipe( + &mut read_handle, + &mut write_handle, + ptr::null_mut(), + REQUESTED_PIPE_BUFFER_SIZE, + ) + }; + if result == 0 { + return Err(last_error("failed to create process context copy pipe")); + } + + let mut pipe = Self { + read_handle, + write_handle, + chunk_size: 0, + }; + // SAFETY: read_handle is the readable end returned by CreatePipe. Bytes written through + // write_handle are incoming data for this handle, so this reports the relevant capacity. + let result = unsafe { + GetNamedPipeInfo( + pipe.read_handle, + ptr::null_mut(), + ptr::null_mut(), + &mut pipe.chunk_size, + ptr::null_mut(), + ) + }; + if result == 0 { + return Err(last_error( + "failed to query process context copy pipe capacity", + )); + } + if pipe.chunk_size == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "process context copy pipe reported zero capacity", + )); + } + + Ok(pipe) + } + + fn copy(&self, addr: *const u8, len: usize) -> Result, PipeCopyError> { + let mut bytes: Vec = Vec::with_capacity(len); + let mut offset = 0; + + while offset < len { + let chunk_len = (len - offset).min(self.chunk_size as usize) as u32; + let chunk_addr = addr.wrapping_add(offset); + let mut written = 0; + + // SAFETY: WriteFile asks the kernel to copy from chunk_addr. An inaccessible source + // is reported as a Win32 invalid-buffer error rather than dereferenced by Rust. + let result = unsafe { + WriteFile( + self.write_handle, + chunk_addr.cast(), + chunk_len, + &mut written, + ptr::null_mut(), + ) + }; + if result == 0 { + let err = io::Error::last_os_error(); + let err = if matches!( + err.raw_os_error(), + Some(ERROR_NOACCESS) | Some(ERROR_INVALID_USER_BUFFER) + ) { + io::Error::new( + io::ErrorKind::WouldBlock, + "process context memory was unmapped during read", + ) + } else { + io::Error::new( + err.kind(), + format!("failed to copy process context memory: {err}"), + ) + }; + return Err(PipeCopyError { + err, + pipe_dirty: written != 0, + }); + } + if written == 0 { + return Err(PipeCopyError { + err: io::Error::new( + io::ErrorKind::WriteZero, + "zero-length write while copying process context memory", + ), + pipe_dirty: false, + }); + } + + let mut drained = 0; + while drained < written { + let mut read = 0; + // SAFETY: bytes owns len bytes of spare capacity and + // offset + written <= len, so the destination is writable. + let result = unsafe { + ReadFile( + self.read_handle, + bytes.as_mut_ptr().add(offset + drained as usize).cast(), + written - drained, + &mut read, + ptr::null_mut(), + ) + }; + if result == 0 { + return Err(PipeCopyError { + err: last_error("failed to drain process context copy pipe"), + pipe_dirty: true, + }); + } + if read == 0 { + return Err(PipeCopyError { + err: io::Error::new( + io::ErrorKind::UnexpectedEof, + "process context copy pipe reported EOF", + ), + pipe_dirty: true, + }); + } + drained += read; + } + + offset += written as usize; + } + + // SAFETY: every byte was initialized by ReadFile above. + unsafe { bytes.set_len(len) }; + Ok(bytes) + } +} + +impl Drop for CopyPipe { + fn drop(&mut self) { + // SAFETY: both handles are owned by self and closed exactly once during drop. + unsafe { + CloseHandle(self.read_handle); + CloseHandle(self.write_handle); + } + } +} + +fn last_error(context: &'static str) -> io::Error { + let err = io::Error::last_os_error(); + io::Error::new(err.kind(), format!("{context}: {err}")) +} + +#[cfg(test)] +mod tests { + use core::{ffi::c_void, ptr}; + + use super::{io, CopyPipe, ProcessMemoryCopy}; + + const MEM_COMMIT: u32 = 0x1000; + const MEM_RESERVE: u32 = 0x2000; + const MEM_RELEASE: u32 = 0x8000; + const PAGE_NOACCESS: u32 = 0x01; + const PAGE_READWRITE: u32 = 0x04; + + #[link(name = "kernel32")] + unsafe extern "system" { + fn VirtualAlloc( + address: *mut c_void, + size: usize, + allocation_type: u32, + protect: u32, + ) -> *mut c_void; + fn VirtualFree(address: *mut c_void, size: usize, free_type: u32) -> i32; + fn VirtualProtect( + address: *mut c_void, + size: usize, + new_protect: u32, + old_protect: *mut u32, + ) -> i32; + } + + #[test] + fn copies_valid_memory_across_multiple_chunks() { + let pipe = CopyPipe::new().expect("pipe creation should succeed"); + let len = pipe.chunk_size as usize + 1; + let source: Vec<_> = (0..len).map(|index| index as u8).collect(); + + let copied = pipe + .copy(source.as_ptr(), source.len()) + .expect("memory copy should succeed"); + + assert_eq!(copied, source); + } + + #[test] + fn rejects_inaccessible_memory() { + // SAFETY: the arguments reserve and commit one writable page. + let address = unsafe { + VirtualAlloc( + ptr::null_mut(), + 4096, + MEM_RESERVE | MEM_COMMIT, + PAGE_READWRITE, + ) + }; + assert!(!address.is_null()); + let mut old_protect = 0; + // SAFETY: address names the committed page allocated above and old_protect is writable. + assert_ne!( + unsafe { VirtualProtect(address, 4096, PAGE_NOACCESS, &mut old_protect) }, + 0 + ); + + let err = CopyPipe::new() + .expect("pipe creation should succeed") + .copy(address.cast(), 1) + .expect_err("inaccessible memory should fail"); + + assert_eq!(err.err.kind(), io::ErrorKind::WouldBlock); + assert!(!err.pipe_dirty); + // SAFETY: address was returned by VirtualAlloc and MEM_RELEASE requires size zero. + assert_ne!(unsafe { VirtualFree(address, 0, MEM_RELEASE) }, 0); + } +} diff --git a/libdd-library-config/src/otel_process_ctx/reader/macos.rs b/libdd-library-config/src/otel_process_ctx/reader/macos.rs new file mode 100644 index 0000000000..c51f11c590 --- /dev/null +++ b/libdd-library-config/src/otel_process_ctx/reader/macos.rs @@ -0,0 +1,103 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +use core::{ + ptr::NonNull, + sync::atomic::{AtomicPtr, Ordering}, +}; +use std::io; +#[cfg(not(feature = "process-context-writer"))] +use std::sync::OnceLock; + +use super::ReaderPlatform; + +pub(super) struct HeaderDiscovery; + +impl ReaderPlatform for HeaderDiscovery { + fn discover_header() -> io::Result> { + let header = process_context_global()?.load(Ordering::Acquire); + NonNull::new(header).ok_or_else(|| { + io::Error::new(io::ErrorKind::NotFound, "no process context is published") + }) + } +} + +#[cfg(feature = "process-context-writer")] +fn process_context_global() -> io::Result<&'static AtomicPtr> { + Ok(&crate::otel_process_ctx::writer::macos::otel_process_ctx_v2) +} + +#[cfg(not(feature = "process-context-writer"))] +fn process_context_global() -> io::Result<&'static AtomicPtr> { + static SYMBOL_ADDRESS: OnceLock = OnceLock::new(); + + let address = match SYMBOL_ADDRESS.get() { + Some(address) => *address, + None => { + // SAFETY: RTLD_DEFAULT searches globally visible symbols in the current process and + // the symbol name is NUL-terminated. + let symbol = + unsafe { libc::dlsym(libc::RTLD_DEFAULT, c"otel_process_ctx_v2".as_ptr().cast()) }; + let address = NonNull::new(symbol) + .map(|symbol| symbol.as_ptr() as usize) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + "couldn't resolve otel_process_ctx_v2 in the current process", + ) + })?; + + // may fail, another thread may have already set the address + let _ = SYMBOL_ADDRESS.set(address); + SYMBOL_ADDRESS.get().copied().unwrap_or(address) + } + }; + + // SAFETY: dlsym returned the address of the exported AtomicPtr. Successful addresses are + // cached only for the lifetime of the process. + Ok(unsafe { &*(address as *const AtomicPtr) }) +} + +#[cfg(all(test, not(feature = "process-context-writer")))] +mod tests { + use core::{ + ptr, + sync::atomic::{AtomicPtr, Ordering}, + }; + + use libdd_trace_protobuf::opentelemetry::proto::common::v1::{KeyValue, ProcessContext}; + + use crate::otel_process_ctx::{ + MappingHeaderSnapshot, ProcessContextSelfReader, PROCESS_CTX_VERSION, SIGNATURE, + }; + + #[no_mangle] + #[allow(non_upper_case_globals)] + pub static otel_process_ctx_v2: AtomicPtr = AtomicPtr::new(ptr::null_mut()); + + #[test] + fn reads_context_from_exported_global() { + let payload = b"\x12\x05\x0a\x03key"; + let expected = ProcessContext { + resource: None, + extra_attributes: vec![KeyValue { + key: "key".to_string(), + value: None, + key_ref: 0, + }], + }; + let header = MappingHeaderSnapshot { + signature: *SIGNATURE, + version: PROCESS_CTX_VERSION, + payload_size: payload.len() as u32, + monotonic_published_at_ns: 1, + payload_ptr: payload.as_ptr(), + }; + otel_process_ctx_v2.store(ptr::from_ref(&header).cast_mut().cast(), Ordering::Release); + + let reader = ProcessContextSelfReader::new().expect("reader creation should succeed"); + assert_eq!(reader.read().expect("read should succeed"), expected); + + otel_process_ctx_v2.store(ptr::null_mut(), Ordering::Relaxed); + } +} diff --git a/libdd-library-config/src/otel_process_ctx/reader/windows.rs b/libdd-library-config/src/otel_process_ctx/reader/windows.rs new file mode 100644 index 0000000000..d568717532 --- /dev/null +++ b/libdd-library-config/src/otel_process_ctx/reader/windows.rs @@ -0,0 +1,176 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +#[cfg(not(feature = "process-context-writer"))] +use core::{ffi::c_void, mem::size_of, ptr}; +use core::{ + ptr::NonNull, + sync::atomic::{AtomicPtr, Ordering}, +}; +use std::io; +#[cfg(not(feature = "process-context-writer"))] +use std::sync::OnceLock; + +use super::ReaderPlatform; + +#[cfg(not(feature = "process-context-writer"))] +type Handle = *mut c_void; + +#[cfg(not(feature = "process-context-writer"))] +#[link(name = "kernel32")] +unsafe extern "system" { + fn GetCurrentProcess() -> Handle; + fn GetProcAddress(module: Handle, proc_name: *const u8) -> *mut c_void; + fn K32EnumProcessModules( + process: Handle, + modules: *mut Handle, + size: u32, + needed: *mut u32, + ) -> i32; +} + +pub(super) struct HeaderDiscovery; + +impl ReaderPlatform for HeaderDiscovery { + fn discover_header() -> io::Result> { + let header = process_context_global()?.load(Ordering::Acquire); + NonNull::new(header).ok_or_else(|| { + io::Error::new(io::ErrorKind::NotFound, "no process context is published") + }) + } +} + +#[cfg(feature = "process-context-writer")] +fn process_context_global() -> io::Result<&'static AtomicPtr> { + Ok(&crate::otel_process_ctx::writer::windows::otel_process_ctx_v2) +} + +#[cfg(not(feature = "process-context-writer"))] +fn process_context_global() -> io::Result<&'static AtomicPtr> { + static SYMBOL_ADDRESS: OnceLock = OnceLock::new(); + + let address = match SYMBOL_ADDRESS.get() { + Some(address) => *address, + None => { + let address = find_process_context_global()?; + // may fail, another thread may have already set the address + let _ = SYMBOL_ADDRESS.set(address); + SYMBOL_ADDRESS.get().copied().unwrap_or(address) + } + }; + + // SAFETY: GetProcAddress returned the address of the exported AtomicPtr. Successful + // addresses are cached only for the lifetime of the process. + Ok(unsafe { &*(address as *const AtomicPtr) }) +} + +#[cfg(not(feature = "process-context-writer"))] +fn find_process_context_global() -> io::Result { + // SAFETY: GetCurrentProcess returns a pseudo-handle valid in the current process. + let process = unsafe { GetCurrentProcess() }; + let mut bytes_needed = 0; + // SAFETY: a zero-sized first call queries the required module-array size. + if unsafe { K32EnumProcessModules(process, ptr::null_mut(), 0, &mut bytes_needed) } == 0 { + return Err(last_error("failed to enumerate loaded process modules")); + } + + loop { + let module_count = bytes_needed as usize / size_of::(); + let mut modules = vec![ptr::null_mut(); module_count]; + let buffer_size = u32::try_from(modules.len() * size_of::()) + .map_err(|_| io::Error::other("loaded process module list was too large"))?; + let mut actual_bytes_needed = 0; + // SAFETY: modules provides buffer_size writable bytes and the out-parameter is valid. + if unsafe { + K32EnumProcessModules( + process, + modules.as_mut_ptr(), + buffer_size, + &mut actual_bytes_needed, + ) + } == 0 + { + return Err(last_error("failed to enumerate loaded process modules")); + } + + if actual_bytes_needed > buffer_size { + bytes_needed = actual_bytes_needed; + continue; + } + + let actual_module_count = actual_bytes_needed as usize / size_of::(); + for module in modules.into_iter().take(actual_module_count) { + // SAFETY: module was returned by K32EnumProcessModules and the symbol name is + // NUL-terminated. + let symbol = unsafe { GetProcAddress(module, c"otel_process_ctx_v2".as_ptr().cast()) }; + if let Some(symbol) = NonNull::new(symbol) { + return Ok(symbol.as_ptr() as usize); + } + } + + return Err(io::Error::new( + io::ErrorKind::NotFound, + "couldn't resolve otel_process_ctx_v2 in the current process", + )); + } +} + +#[cfg(not(feature = "process-context-writer"))] +fn last_error(context: &'static str) -> io::Error { + let error = io::Error::last_os_error(); + io::Error::new(error.kind(), format!("{context}: {error}")) +} + +#[cfg(all(test, not(feature = "process-context-writer")))] +mod tests { + use core::{ + ptr, + sync::atomic::{AtomicPtr, Ordering}, + }; + + use libdd_trace_protobuf::opentelemetry::proto::common::v1::{KeyValue, ProcessContext}; + + use crate::otel_process_ctx::{ + MappingHeaderSnapshot, ProcessContextSelfReader, PROCESS_CTX_VERSION, SIGNATURE, + }; + + #[cfg(target_env = "msvc")] + #[used] + #[link_section = ".drectve"] + static EXPORT_OTEL_PROCESS_CTX_V2: [u8; 28] = *b" /EXPORT:otel_process_ctx_v2"; + + #[cfg(target_env = "gnu")] + #[used] + #[link_section = ".drectve"] + static EXPORT_OTEL_PROCESS_CTX_V2: [u8; 28] = *b" -export:otel_process_ctx_v2"; + + #[no_mangle] + #[allow(non_upper_case_globals)] + pub static otel_process_ctx_v2: AtomicPtr = AtomicPtr::new(ptr::null_mut()); + + #[test] + fn reads_context_from_exported_global() { + let payload = b"\x12\x05\x0a\x03key"; + let expected = ProcessContext { + resource: None, + extra_attributes: vec![KeyValue { + key: "key".to_string(), + value: None, + key_ref: 0, + }], + }; + let header = MappingHeaderSnapshot { + signature: *SIGNATURE, + version: PROCESS_CTX_VERSION, + payload_size: payload.len() as u32, + monotonic_published_at_ns: 1, + payload_ptr: payload.as_ptr(), + }; + otel_process_ctx_v2.store(ptr::from_ref(&header).cast_mut().cast(), Ordering::Release); + + let reader = ProcessContextSelfReader::new().expect("reader creation should succeed"); + assert_eq!(reader.read().expect("read should succeed"), expected); + + otel_process_ctx_v2.store(ptr::null_mut(), Ordering::Relaxed); + } +} diff --git a/libdd-library-config/src/otel_process_ctx/writer.rs b/libdd-library-config/src/otel_process_ctx/writer.rs index 408a3e929a..f1bc0d0a0c 100644 --- a/libdd-library-config/src/otel_process_ctx/writer.rs +++ b/libdd-library-config/src/otel_process_ctx/writer.rs @@ -20,6 +20,10 @@ use super::{PROCESS_CTX_VERSION, SIGNATURE, UNPUBLISHED_OR_UPDATING}; #[cfg(target_os = "linux")] pub(super) mod linux; +#[cfg(target_os = "macos")] +pub(super) mod macos; +#[cfg(target_os = "windows")] +pub(super) mod windows; /// The header structure written at the start of the mapping. This must match the C /// layout of the specification. @@ -53,9 +57,17 @@ const _: () = { #[cfg(target_os = "linux")] type HeaderMemory = linux::MemMapping; +#[cfg(target_os = "macos")] +type HeaderMemory = macos::VmRegion; +#[cfg(target_os = "windows")] +type HeaderMemory = windows::HeapHeader; #[cfg(target_os = "linux")] type PlatformMonotonicClock = linux::MonotonicClock; +#[cfg(target_os = "macos")] +type PlatformMonotonicClock = macos::MonotonicClock; +#[cfg(target_os = "windows")] +type PlatformMonotonicClock = windows::MonotonicClock; type ProcessContextHandle = ProcessContextHandleGen; @@ -208,7 +220,7 @@ impl ProcessContextHandleGen { } // The returned size is guaranteed to be larger or equal to the size of `MappingHeader`. -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "macos"))] pub(super) const fn mapping_size() -> usize { core::mem::size_of::() } diff --git a/libdd-library-config/src/otel_process_ctx/writer/macos.rs b/libdd-library-config/src/otel_process_ctx/writer/macos.rs new file mode 100644 index 0000000000..2eba71b7fa --- /dev/null +++ b/libdd-library-config/src/otel_process_ctx/writer/macos.rs @@ -0,0 +1,135 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +//! Implements the publication strategy for MacOS. +//! This is not part of the OTEL process context specification, which deals only with Linux. + +use core::{ + ffi::c_void, + mem::forget, + ptr::{self, NonNull}, + sync::atomic::{fence, AtomicPtr, Ordering}, +}; +use std::io; + +use super::{HeaderMemoryHolder, MappingHeader, MonotonicTime}; + +#[no_mangle] +#[allow(non_upper_case_globals)] +pub static otel_process_ctx_v2: AtomicPtr = AtomicPtr::new(ptr::null_mut()); + +// From ; the libc crate does not expose this constant. +const VM_INHERIT_NONE: libc::c_int = 2; + +unsafe extern "C" { + fn minherit(address: *mut c_void, size: usize, inheritance: libc::c_int) -> libc::c_int; + fn clock_gettime_nsec_np(clock_id: libc::clockid_t) -> u64; +} + +pub(super) struct VmRegion { + start_addr: NonNull, + /// `Some(pid)` when `VM_INHERIT_NONE` succeeded, otherwise `None`. + only_for_pid: Option, +} + +pub(super) struct MonotonicClock; + +// SAFETY: VmRegion exclusively owns its mapping, which may be unmapped from any thread. +unsafe impl Send for VmRegion {} + +impl HeaderMemoryHolder for VmRegion { + fn new() -> io::Result { + let size = super::mapping_size(); + // SAFETY: a null address lets the kernel choose the address; the other arguments describe + // a private, anonymous, readable and writable mapping. + let address = unsafe { + libc::mmap( + ptr::null_mut(), + size, + libc::PROT_READ | libc::PROT_WRITE, + libc::MAP_PRIVATE | libc::MAP_ANON, + -1, + 0, + ) + }; + if address == libc::MAP_FAILED { + return Err(last_error("failed to allocate process context header")); + } + + // SAFETY: the region is a dedicated live mapping of size bytes. Failure is harmless; the + // mapping then follows the default inheritance behavior. + let only_for_pid = (unsafe { minherit(address, size, VM_INHERIT_NONE) } == 0) + .then_some(std::process::id()); + + // SAFETY: mmap returned a non-null address for a live mapping. + Ok(Self { + start_addr: unsafe { NonNull::new_unchecked(address) }, + only_for_pid, + }) + } + + fn as_ptr(&self) -> Option> { + if self + .only_for_pid + .is_some_and(|pid| pid != std::process::id()) + { + None + } else { + Some(self.start_addr.cast()) + } + } + + fn make_discoverable(&mut self) { + otel_process_ctx_v2.store(self.start_addr.as_ptr().cast(), Ordering::Release); + } + + fn unpublish_and_release(mut self) -> io::Result<()> { + otel_process_ctx_v2.store(ptr::null_mut(), Ordering::Relaxed); + // Make it slightly more likely that a reader will observe the unavailability. + fence(Ordering::SeqCst); + self.unmap()?; + forget(self); + Ok(()) + } + + fn after_fork(self) { + drop(self); + } +} + +impl MonotonicTime for MonotonicClock { + fn monotonic_time_ns() -> io::Result { + // SAFETY: CLOCK_MONOTONIC_RAW is a valid clock ID and this function has no pointer + // arguments. It returns continuous time directly in nanoseconds. + Ok(unsafe { clock_gettime_nsec_np(libc::CLOCK_MONOTONIC_RAW) }.max(1)) + } +} + +impl VmRegion { + fn unmap(&mut self) -> io::Result<()> { + if self + .only_for_pid + .is_some_and(|pid| pid != std::process::id()) + { + return Ok(()); + } + + // SAFETY: start_addr owns the live mapping and this method is called at most once unless + // munmap fails. + if unsafe { libc::munmap(self.start_addr.as_ptr(), super::mapping_size()) } != 0 { + return Err(last_error("failed to free process context header")); + } + Ok(()) + } +} + +impl Drop for VmRegion { + fn drop(&mut self) { + let _ = self.unmap(); + } +} + +fn last_error(context: &'static str) -> io::Error { + let error = io::Error::last_os_error(); + io::Error::new(error.kind(), format!("{context}: {error}")) +} diff --git a/libdd-library-config/src/otel_process_ctx/writer/windows.rs b/libdd-library-config/src/otel_process_ctx/writer/windows.rs new file mode 100644 index 0000000000..43ceca3a76 --- /dev/null +++ b/libdd-library-config/src/otel_process_ctx/writer/windows.rs @@ -0,0 +1,109 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +//! Implements the publication strategy for Windows. +//! This is not part of the OTEL process context specification, which deals only with Linux. + +use core::{ + ptr::{self, NonNull}, + sync::atomic::{fence, AtomicPtr, AtomicU32, AtomicU64, Ordering}, +}; +use std::{io, sync::OnceLock}; + +use super::super::UNPUBLISHED_OR_UPDATING; +use super::{HeaderMemoryHolder, MappingHeader, MonotonicTime}; + +#[no_mangle] +#[allow(non_upper_case_globals)] +pub static otel_process_ctx_v2: AtomicPtr = AtomicPtr::new(ptr::null_mut()); + +#[link(name = "kernel32")] +unsafe extern "system" { + fn QueryPerformanceCounter(performance_count: *mut i64) -> i32; + fn QueryPerformanceFrequency(frequency: *mut i64) -> i32; +} + +pub(super) struct HeapHeader { + header: Box, +} + +pub(super) struct MonotonicClock; + +impl HeaderMemoryHolder for HeapHeader { + fn new() -> io::Result { + Ok(Self { + header: Box::new(MappingHeader { + signature: [0; 8], + version: 0, + payload_size: AtomicU32::new(0), + monotonic_published_at_ns: AtomicU64::new(UNPUBLISHED_OR_UPDATING), + payload_ptr: AtomicPtr::new(ptr::null_mut()), + }), + }) + } + + fn as_ptr(&self) -> Option> { + Some(NonNull::from(self.header.as_ref())) + } + + fn make_discoverable(&mut self) { + otel_process_ctx_v2.store( + ptr::from_ref(self.header.as_ref()).cast_mut().cast(), + Ordering::Release, + ); + } + + fn unpublish_and_release(self) -> io::Result<()> { + otel_process_ctx_v2.store(ptr::null_mut(), Ordering::Relaxed); + fence(Ordering::SeqCst); + drop(self); + Ok(()) + } + + fn after_fork(self) {} +} + +impl MonotonicTime for MonotonicClock { + fn monotonic_time_ns() -> io::Result { + let frequency = performance_frequency()?; + let mut ticks = 0; + // SAFETY: ticks is a valid writable LARGE_INTEGER-compatible value. + if unsafe { QueryPerformanceCounter(&mut ticks) } == 0 { + return Err(last_error("failed to query monotonic process context time")); + } + let ticks = u64::try_from(ticks) + .map_err(|_| io::Error::other("monotonic process context time was negative"))?; + let nanos = u128::from(ticks) * 1_000_000_000 / u128::from(frequency); + u64::try_from(nanos) + .map(|nanos| nanos.max(1)) + .map_err(|_| io::Error::other("monotonic process context timestamp overflowed")) + } +} + +fn performance_frequency() -> io::Result { + static FREQUENCY: OnceLock = OnceLock::new(); + + if let Some(frequency) = FREQUENCY.get() { + return Ok(*frequency); + } + + let mut frequency = 0; + // SAFETY: frequency is a valid writable LARGE_INTEGER-compatible value. + if unsafe { QueryPerformanceFrequency(&mut frequency) } == 0 { + return Err(last_error( + "failed to query process context performance-counter frequency", + )); + } + let frequency = u64::try_from(frequency) + .ok() + .filter(|frequency| *frequency != 0) + .ok_or_else(|| io::Error::other("process context performance-counter frequency is zero"))?; + + let _ = FREQUENCY.set(frequency); + Ok(FREQUENCY.get().copied().unwrap_or(frequency)) +} + +fn last_error(context: &'static str) -> io::Error { + let error = io::Error::last_os_error(); + io::Error::new(error.kind(), format!("{context}: {error}")) +} diff --git a/libdd-library-config/src/tracer_metadata.rs b/libdd-library-config/src/tracer_metadata.rs index aa9c3b9146..95387e97ef 100644 --- a/libdd-library-config/src/tracer_metadata.rs +++ b/libdd-library-config/src/tracer_metadata.rs @@ -255,10 +255,23 @@ mod linux { #[cfg(not(target_os = "linux"))] mod other { + /// Publishes tracer metadata as an OTel process context. pub fn store_tracer_metadata( - _data: &super::TracerMetadata, + data: &super::TracerMetadata, ) -> anyhow::Result { - Ok(super::AnonymousFileHandle::Other(())) + #[cfg(feature = "process-context-writer")] + { + crate::otel_process_ctx::publish(&data.to_otel_process_ctx())?; + Ok(super::AnonymousFileHandle::Other(())) + } + + #[cfg(not(feature = "process-context-writer"))] + { + let _ = data; + Err(anyhow::anyhow!( + "storing tracer metadata requires the process-context-writer feature" + )) + } } } @@ -309,6 +322,30 @@ mod tests { assert!(find_extra_attr(&ctx, "threadlocal.attribute_key_map").is_none()); } + #[cfg(all( + not(target_os = "linux"), + feature = "process-context-reader", + feature = "process-context-writer" + ))] + #[test] + #[serial_test::serial] + fn store_tracer_metadata_publishes_otel_process_context() { + let metadata = TracerMetadata { + tracer_language: "python".to_owned(), + tracer_version: "1.2.3".to_owned(), + hostname: "test-host".to_owned(), + ..Default::default() + }; + let expected = metadata.to_otel_process_ctx(); + + store_tracer_metadata(&metadata).expect("metadata storage should succeed"); + let reader = crate::otel_process_ctx::ProcessContextSelfReader::new() + .expect("reader creation should succeed"); + assert_eq!(reader.read().expect("read should succeed"), expected); + + crate::otel_process_ctx::unpublish().expect("unpublish should succeed"); + } + #[cfg(feature = "otel-thread-ctx")] #[test] fn threadlocal_attrs_present_with_correct_values() { From 17e4297df89d0714a9fe903ea90afa5a24720a8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gustavo=20Andr=C3=A9=20dos=20Santos=20Lopes?= Date: Tue, 14 Jul 2026 19:44:25 +0100 Subject: [PATCH 2/4] Force otel_process_ctx_v2 exported on win via .drectve --- .../src/otel_process_ctx/writer/windows.rs | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/libdd-library-config/src/otel_process_ctx/writer/windows.rs b/libdd-library-config/src/otel_process_ctx/writer/windows.rs index 43ceca3a76..da57b15983 100644 --- a/libdd-library-config/src/otel_process_ctx/writer/windows.rs +++ b/libdd-library-config/src/otel_process_ctx/writer/windows.rs @@ -13,6 +13,16 @@ use std::{io, sync::OnceLock}; use super::super::UNPUBLISHED_OR_UPDATING; use super::{HeaderMemoryHolder, MappingHeader, MonotonicTime}; +#[cfg(target_env = "msvc")] +#[used] +#[link_section = ".drectve"] +static EXPORT_OTEL_PROCESS_CTX_V2: [u8; 28] = *b" /EXPORT:otel_process_ctx_v2"; + +#[cfg(target_env = "gnu")] +#[used] +#[link_section = ".drectve"] +static EXPORT_OTEL_PROCESS_CTX_V2: [u8; 28] = *b" -export:otel_process_ctx_v2"; + #[no_mangle] #[allow(non_upper_case_globals)] pub static otel_process_ctx_v2: AtomicPtr = AtomicPtr::new(ptr::null_mut()); @@ -107,3 +117,32 @@ fn last_error(context: &'static str) -> io::Error { let error = io::Error::last_os_error(); io::Error::new(error.kind(), format!("{context}: {error}")) } + +#[cfg(test)] +mod tests { + use core::{ffi::c_void, ptr}; + + use super::otel_process_ctx_v2; + + type Handle = *mut c_void; + + #[link(name = "kernel32")] + unsafe extern "system" { + fn GetModuleHandleW(module_name: *const u16) -> Handle; + fn GetProcAddress(module: Handle, proc_name: *const u8) -> *mut c_void; + } + + #[test] + fn exports_process_context_global() { + // SAFETY: a null module name requests the current executable's module handle. + let module = unsafe { GetModuleHandleW(ptr::null()) }; + assert!(!module.is_null()); + + // SAFETY: module is the current executable and the symbol name is NUL-terminated. + let symbol = unsafe { GetProcAddress(module, c"otel_process_ctx_v2".as_ptr().cast()) }; + assert_eq!( + symbol.cast_const(), + ptr::from_ref(&otel_process_ctx_v2).cast::() + ); + } +} From 954186bdc921a8ed0f9b160d83d965efbf36fde3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gustavo=20Andr=C3=A9=20dos=20Santos=20Lopes?= Date: Tue, 14 Jul 2026 23:05:26 +0100 Subject: [PATCH 3/4] Detect unpublished data on macos after fork --- Cargo.lock | 1 + libdd-library-config/Cargo.toml | 3 + libdd-library-config/src/lib.rs | 5 +- libdd-library-config/src/otel_process_ctx.rs | 56 +++++++++++++--- .../src/otel_process_ctx/macos.rs | 26 ++++++++ .../src/otel_process_ctx/reader/macos.rs | 64 +++++++++++++------ .../src/otel_process_ctx/writer/macos.rs | 16 +++-- 7 files changed, 139 insertions(+), 32 deletions(-) create mode 100644 libdd-library-config/src/otel_process_ctx/macos.rs diff --git a/Cargo.lock b/Cargo.lock index 8057cea510..26b6d421c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3124,6 +3124,7 @@ dependencies = [ "libc", "libdd-trace-protobuf", "memfd", + "portable-atomic", "prost", "rand 0.8.5", "rmp", diff --git a/libdd-library-config/Cargo.toml b/libdd-library-config/Cargo.toml index b46e7a7a3e..7ce34f150c 100644 --- a/libdd-library-config/Cargo.toml +++ b/libdd-library-config/Cargo.toml @@ -40,6 +40,9 @@ serial_test = "3.2" memfd = { version = "0.6" } libc = "0.2" +[target.'cfg(target_os = "macos")'.dependencies] +portable-atomic = { version = "1.9.0", default-features = false } + [lints.clippy] std_instead_of_alloc = "warn" std_instead_of_core = "warn" diff --git a/libdd-library-config/src/lib.rs b/libdd-library-config/src/lib.rs index 01d1c5c971..7ff3647163 100644 --- a/libdd-library-config/src/lib.rs +++ b/libdd-library-config/src/lib.rs @@ -2,7 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 extern crate alloc; -#[cfg(any(feature = "process-context-reader", feature = "process-context-writer"))] +#[cfg(all( + any(feature = "process-context-reader", feature = "process-context-writer"), + any(target_os = "linux", target_os = "macos", target_os = "windows") +))] pub mod otel_process_ctx; pub mod tracer_metadata; diff --git a/libdd-library-config/src/otel_process_ctx.rs b/libdd-library-config/src/otel_process_ctx.rs index fd04c2637f..a90475724c 100644 --- a/libdd-library-config/src/otel_process_ctx.rs +++ b/libdd-library-config/src/otel_process_ctx.rs @@ -33,6 +33,8 @@ mod writer; compile_error!("OTel process context requires 64-bit atomics on Linux"); #[cfg(target_os = "linux")] pub mod linux; +#[cfg(target_os = "macos")] +mod macos; #[cfg(feature = "process-context-reader")] pub use reader::ProcessContextSelfReader; @@ -92,16 +94,50 @@ mod tests { } } - #[cfg(any(target_os = "macos", target_os = "windows"))] - mod non_linux { - use super::super::MappingHeaderSnapshot; + #[cfg(target_os = "macos")] + mod macos { use core::{ptr, sync::atomic::Ordering}; use std::io; - #[cfg(target_os = "macos")] - use super::super::writer::macos::otel_process_ctx_v2; - #[cfg(target_os = "windows")] - use super::super::writer::windows::otel_process_ctx_v2; + use super::super::{ + macos::{HEADER_ADDRESS_MASK, PUBLISHER_PID_SHIFT}, + writer::macos::otel_process_ctx_v2, + MappingHeaderSnapshot, + }; + + fn published_header() -> *mut u8 { + let value = otel_process_ctx_v2.load(Ordering::Acquire); + let publisher_pid = (value >> PUBLISHER_PID_SHIFT) as u32; + if publisher_pid != std::process::id() { + return ptr::null_mut(); + } + + let header_address = (value & HEADER_ADDRESS_MASK) as usize; + ptr::with_exposed_provenance_mut(header_address) + } + + pub(super) fn read_process_context() -> io::Result { + let header_ptr: *const MappingHeaderSnapshot = published_header().cast(); + if header_ptr.is_null() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + "no process context is published", + )); + } + Ok(unsafe { ptr::read(header_ptr) }) + } + + pub(super) fn is_published() -> bool { + !published_header().is_null() + } + } + + #[cfg(target_os = "windows")] + mod windows { + use core::{ptr, sync::atomic::Ordering}; + use std::io; + + use super::super::{writer::windows::otel_process_ctx_v2, MappingHeaderSnapshot}; pub(super) fn read_process_context() -> io::Result { let header_ptr: *const MappingHeaderSnapshot = @@ -122,8 +158,10 @@ mod tests { #[cfg(target_os = "linux")] use linux::{is_published, read_process_context}; - #[cfg(any(target_os = "macos", target_os = "windows"))] - use non_linux::{is_published, read_process_context}; + #[cfg(target_os = "macos")] + use macos::{is_published, read_process_context}; + #[cfg(target_os = "windows")] + use windows::{is_published, read_process_context}; #[test] #[cfg_attr(miri, ignore)] diff --git a/libdd-library-config/src/otel_process_ctx/macos.rs b/libdd-library-config/src/otel_process_ctx/macos.rs new file mode 100644 index 0000000000..4a1ecc5c0f --- /dev/null +++ b/libdd-library-config/src/otel_process_ctx/macos.rs @@ -0,0 +1,26 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +use portable_atomic::AtomicU128; + +#[cfg(not(any(target_arch = "aarch64", target_arch = "x86_64")))] +compile_error!("OTel process context only supports aarch64 and x86_64 on macOS"); +#[cfg(not(target_endian = "little"))] +compile_error!("OTel process context requires a little-endian macOS target"); + +pub(super) type AtomicPublishedHeader = AtomicU128; + +const _: () = { + // Both supported macOS architectures have native 128-bit atomics. Keep this assertion so a + // target change cannot silently select portable-atomic's software-lock fallback. + assert!(AtomicPublishedHeader::is_always_lock_free()); + assert!(size_of::() == size_of::()); + assert!(align_of::() == 16); + assert!(size_of::() == size_of::()); +}; + +// The low 64 bits contain the header address and the next 32 bits contain the publisher PID. +// Keeping them in one value lets readers observe both through a single atomic load. +#[cfg(feature = "process-context-reader")] +pub(super) const HEADER_ADDRESS_MASK: u128 = u64::MAX as u128; +pub(super) const PUBLISHER_PID_SHIFT: u32 = u64::BITS; diff --git a/libdd-library-config/src/otel_process_ctx/reader/macos.rs b/libdd-library-config/src/otel_process_ctx/reader/macos.rs index c51f11c590..54061583a4 100644 --- a/libdd-library-config/src/otel_process_ctx/reader/macos.rs +++ b/libdd-library-config/src/otel_process_ctx/reader/macos.rs @@ -2,33 +2,52 @@ // SPDX-License-Identifier: Apache-2.0 use core::{ - ptr::NonNull, - sync::atomic::{AtomicPtr, Ordering}, + ptr::{self, NonNull}, + sync::atomic::Ordering, }; use std::io; #[cfg(not(feature = "process-context-writer"))] use std::sync::OnceLock; use super::ReaderPlatform; +use crate::otel_process_ctx::macos::{ + AtomicPublishedHeader, HEADER_ADDRESS_MASK, PUBLISHER_PID_SHIFT, +}; pub(super) struct HeaderDiscovery; impl ReaderPlatform for HeaderDiscovery { fn discover_header() -> io::Result> { - let header = process_context_global()?.load(Ordering::Acquire); - NonNull::new(header).ok_or_else(|| { - io::Error::new(io::ErrorKind::NotFound, "no process context is published") - }) + let global = process_context_global()?; + let current_pid = std::process::id(); + + let value = global.load(Ordering::Acquire); + let (publisher_pid, header) = unpack_published_header(value); + + // After fork, the global retains the parent's publication even if the mapping itself was + // excluded from inheritance. Treat that stale publication exactly like an unpublished one. + if publisher_pid != current_pid { + return Err(not_found()); + } + + NonNull::new(header).ok_or_else(not_found) } } +fn not_found() -> io::Error { + io::Error::new( + io::ErrorKind::NotFound, + "no process context is published by the current process", + ) +} + #[cfg(feature = "process-context-writer")] -fn process_context_global() -> io::Result<&'static AtomicPtr> { +fn process_context_global() -> io::Result<&'static AtomicPublishedHeader> { Ok(&crate::otel_process_ctx::writer::macos::otel_process_ctx_v2) } #[cfg(not(feature = "process-context-writer"))] -fn process_context_global() -> io::Result<&'static AtomicPtr> { +fn process_context_global() -> io::Result<&'static AtomicPublishedHeader> { static SYMBOL_ADDRESS: OnceLock = OnceLock::new(); let address = match SYMBOL_ADDRESS.get() { @@ -53,27 +72,34 @@ fn process_context_global() -> io::Result<&'static AtomicPtr> { } }; - // SAFETY: dlsym returned the address of the exported AtomicPtr. Successful addresses are - // cached only for the lifetime of the process. - Ok(unsafe { &*(address as *const AtomicPtr) }) + // SAFETY: dlsym returned the address of the exported AtomicPublishedHeader. Successful + // addresses are cached only for the lifetime of the process. + Ok(unsafe { &*(address as *const AtomicPublishedHeader) }) +} + +fn unpack_published_header(value: u128) -> (u32, *mut u8) { + let publisher_pid = (value >> PUBLISHER_PID_SHIFT) as u32; + let header_address = (value & HEADER_ADDRESS_MASK) as usize; + ( + publisher_pid, + ptr::with_exposed_provenance_mut(header_address), + ) } #[cfg(all(test, not(feature = "process-context-writer")))] mod tests { - use core::{ - ptr, - sync::atomic::{AtomicPtr, Ordering}, - }; + use core::{ptr, sync::atomic::Ordering}; use libdd_trace_protobuf::opentelemetry::proto::common::v1::{KeyValue, ProcessContext}; use crate::otel_process_ctx::{ + macos::{AtomicPublishedHeader, PUBLISHER_PID_SHIFT}, MappingHeaderSnapshot, ProcessContextSelfReader, PROCESS_CTX_VERSION, SIGNATURE, }; #[no_mangle] #[allow(non_upper_case_globals)] - pub static otel_process_ctx_v2: AtomicPtr = AtomicPtr::new(ptr::null_mut()); + pub static otel_process_ctx_v2: AtomicPublishedHeader = AtomicPublishedHeader::new(0); #[test] fn reads_context_from_exported_global() { @@ -93,11 +119,13 @@ mod tests { monotonic_published_at_ns: 1, payload_ptr: payload.as_ptr(), }; - otel_process_ctx_v2.store(ptr::from_ref(&header).cast_mut().cast(), Ordering::Release); + let published_header = (u128::from(std::process::id()) << PUBLISHER_PID_SHIFT) + | ptr::from_ref(&header).expose_provenance() as u128; + otel_process_ctx_v2.store(published_header, Ordering::Release); let reader = ProcessContextSelfReader::new().expect("reader creation should succeed"); assert_eq!(reader.read().expect("read should succeed"), expected); - otel_process_ctx_v2.store(ptr::null_mut(), Ordering::Relaxed); + otel_process_ctx_v2.store(0, Ordering::Relaxed); } } diff --git a/libdd-library-config/src/otel_process_ctx/writer/macos.rs b/libdd-library-config/src/otel_process_ctx/writer/macos.rs index 2eba71b7fa..00fef48f78 100644 --- a/libdd-library-config/src/otel_process_ctx/writer/macos.rs +++ b/libdd-library-config/src/otel_process_ctx/writer/macos.rs @@ -8,15 +8,18 @@ use core::{ ffi::c_void, mem::forget, ptr::{self, NonNull}, - sync::atomic::{fence, AtomicPtr, Ordering}, + sync::atomic::{fence, Ordering}, }; use std::io; use super::{HeaderMemoryHolder, MappingHeader, MonotonicTime}; +use crate::otel_process_ctx::macos::{AtomicPublishedHeader, PUBLISHER_PID_SHIFT}; +// A child inherits this global after fork even when minherit excludes the header mapping. Store +// the publisher PID with the pointer so the child cannot discover its parent's unmapped address. #[no_mangle] #[allow(non_upper_case_globals)] -pub static otel_process_ctx_v2: AtomicPtr = AtomicPtr::new(ptr::null_mut()); +pub static otel_process_ctx_v2: AtomicPublishedHeader = AtomicPublishedHeader::new(0); // From ; the libc crate does not expose this constant. const VM_INHERIT_NONE: libc::c_int = 2; @@ -80,11 +83,12 @@ impl HeaderMemoryHolder for VmRegion { } fn make_discoverable(&mut self) { - otel_process_ctx_v2.store(self.start_addr.as_ptr().cast(), Ordering::Release); + let value = pack_published_header(std::process::id(), self.start_addr.as_ptr().cast()); + otel_process_ctx_v2.store(value, Ordering::Release); } fn unpublish_and_release(mut self) -> io::Result<()> { - otel_process_ctx_v2.store(ptr::null_mut(), Ordering::Relaxed); + otel_process_ctx_v2.store(0, Ordering::Relaxed); // Make it slightly more likely that a reader will observe the unavailability. fence(Ordering::SeqCst); self.unmap()?; @@ -97,6 +101,10 @@ impl HeaderMemoryHolder for VmRegion { } } +fn pack_published_header(publisher_pid: u32, header: *mut u8) -> u128 { + (u128::from(publisher_pid) << PUBLISHER_PID_SHIFT) | header.expose_provenance() as u128 +} + impl MonotonicTime for MonotonicClock { fn monotonic_time_ns() -> io::Result { // SAFETY: CLOCK_MONOTONIC_RAW is a valid clock ID and this function has no pointer From 174e3b47d4488961f4527504b7b1455b233e762c Mon Sep 17 00:00:00 2001 From: Levi Morrison Date: Wed, 15 Jul 2026 20:00:18 -0600 Subject: [PATCH 4/4] refactor: adjust SAFETY comments and a bit of style --- .../src/otel_process_ctx/writer/macos.rs | 26 ++++++++----------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/libdd-library-config/src/otel_process_ctx/writer/macos.rs b/libdd-library-config/src/otel_process_ctx/writer/macos.rs index 00fef48f78..b9f8616bec 100644 --- a/libdd-library-config/src/otel_process_ctx/writer/macos.rs +++ b/libdd-library-config/src/otel_process_ctx/writer/macos.rs @@ -24,6 +24,7 @@ pub static otel_process_ctx_v2: AtomicPublishedHeader = AtomicPublishedHeader::n // From ; the libc crate does not expose this constant. const VM_INHERIT_NONE: libc::c_int = 2; +// SAFETY: these signatures match their documentation. unsafe extern "C" { fn minherit(address: *mut c_void, size: usize, inheritance: libc::c_int) -> libc::c_int; fn clock_gettime_nsec_np(clock_id: libc::clockid_t) -> u64; @@ -43,29 +44,24 @@ unsafe impl Send for VmRegion {} impl HeaderMemoryHolder for VmRegion { fn new() -> io::Result { let size = super::mapping_size(); - // SAFETY: a null address lets the kernel choose the address; the other arguments describe - // a private, anonymous, readable and writable mapping. - let address = unsafe { - libc::mmap( - ptr::null_mut(), - size, - libc::PROT_READ | libc::PROT_WRITE, - libc::MAP_PRIVATE | libc::MAP_ANON, - -1, - 0, - ) - }; + let prot = libc::PROT_READ | libc::PROT_WRITE; + let flags = libc::MAP_PRIVATE | libc::MAP_ANON; + // SAFETY: it's always safe to call mmap with null as it will not + // override any extant mappings. + let address = unsafe { libc::mmap(ptr::null_mut(), size, prot, flags, -1, 0) }; if address == libc::MAP_FAILED { return Err(last_error("failed to allocate process context header")); } - // SAFETY: the region is a dedicated live mapping of size bytes. Failure is harmless; the - // mapping then follows the default inheritance behavior. + // SAFETY: minherit is safe, and the parameters are valid anyhow. + // Failure is harmless; the mapping then follows the default + // inheritance behavior. let only_for_pid = (unsafe { minherit(address, size, VM_INHERIT_NONE) } == 0) .then_some(std::process::id()); - // SAFETY: mmap returned a non-null address for a live mapping. Ok(Self { + // SAFETY: POSIX guarantees that when not using MAP_FIXED that the + // returned address will not start at zero. start_addr: unsafe { NonNull::new_unchecked(address) }, only_for_pid, })