From ee8daa037a820df6cfe09818ee78c7b52daf9ad9 Mon Sep 17 00:00:00 2001 From: Ayan Khan Date: Tue, 24 Mar 2026 15:09:55 -0400 Subject: [PATCH 1/2] feat: add cross-platform session ID shared memory carrier Add two new crates for propagating stable session identifiers across process boundaries via named shared memory: - datadog-session-id: Rust library built on datadog-ipc's NamedShmHandle (shm_open/memfd on Unix, CreateFileMapping on Windows) - datadog-session-id-ffi: C FFI exposing ddog_session_create, ddog_session_read_parent, ddog_session_read_pid, and ddog_session_carrier_drop SDKs that link against libdatadog can use the FFI to create/read session carriers without platform-specific shared memory code. Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 21 +++ Cargo.toml | 2 + datadog-session-id-ffi/Cargo.toml | 25 +++ datadog-session-id-ffi/build.rs | 10 + datadog-session-id-ffi/src/lib.rs | 228 +++++++++++++++++++++++ datadog-session-id/Cargo.toml | 18 ++ datadog-session-id/src/lib.rs | 295 ++++++++++++++++++++++++++++++ 7 files changed, 599 insertions(+) create mode 100644 datadog-session-id-ffi/Cargo.toml create mode 100644 datadog-session-id-ffi/build.rs create mode 100644 datadog-session-id-ffi/src/lib.rs create mode 100644 datadog-session-id/Cargo.toml create mode 100644 datadog-session-id/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index d8071c9246..f710eccccf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1502,6 +1502,27 @@ dependencies = [ "uuid", ] +[[package]] +name = "datadog-session-id" +version = "0.1.0" +dependencies = [ + "anyhow", + "datadog-ipc", + "libc", + "serde", + "serde_json", +] + +[[package]] +name = "datadog-session-id-ffi" +version = "0.1.0" +dependencies = [ + "anyhow", + "build_common", + "datadog-session-id", + "libdd-common-ffi", +] + [[package]] name = "datadog-sidecar" version = "0.0.1" diff --git a/Cargo.toml b/Cargo.toml index 245187c1a4..9f97ea5297 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,8 @@ members = [ "libdd-dogstatsd-client", "libdd-log", "libdd-log-ffi", "libdd-libunwind-sys", + "datadog-session-id", + "datadog-session-id-ffi", ] # https://doc.rust-lang.org/cargo/reference/resolver.html diff --git a/datadog-session-id-ffi/Cargo.toml b/datadog-session-id-ffi/Cargo.toml new file mode 100644 index 0000000000..2054c8568d --- /dev/null +++ b/datadog-session-id-ffi/Cargo.toml @@ -0,0 +1,25 @@ +# Copyright 2021-Present Datadog, Inc. https://www.datadoghq.com/ +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "datadog-session-id-ffi" +version = "0.1.0" +edition.workspace = true +license.workspace = true +publish = false + +[lib] +crate-type = ["staticlib", "cdylib", "lib"] +bench = false + +[dependencies] +datadog-session-id = { path = "../datadog-session-id" } +libdd-common-ffi = { path = "../libdd-common-ffi", default-features = false } +anyhow = "1.0" + +[features] +default = ["cbindgen"] +cbindgen = ["build_common/cbindgen", "libdd-common-ffi/cbindgen"] + +[build-dependencies] +build_common = { path = "../build-common" } diff --git a/datadog-session-id-ffi/build.rs b/datadog-session-id-ffi/build.rs new file mode 100644 index 0000000000..c0ef0afaec --- /dev/null +++ b/datadog-session-id-ffi/build.rs @@ -0,0 +1,10 @@ +// Copyright 2021-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 +extern crate build_common; + +use build_common::generate_and_configure_header; + +fn main() { + let header_name = "session-id.h"; + generate_and_configure_header(header_name); +} diff --git a/datadog-session-id-ffi/src/lib.rs b/datadog-session-id-ffi/src/lib.rs new file mode 100644 index 0000000000..4b723b1b46 --- /dev/null +++ b/datadog-session-id-ffi/src/lib.rs @@ -0,0 +1,228 @@ +// Copyright 2021-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +//! C FFI for the cross-platform session ID shared memory carrier. +//! +//! SDKs that link against libdatadog can call these functions to create +//! and read session carriers without dealing with platform-specific +//! shared memory details. +//! +//! ## Typical usage from C +//! +//! ### Writer (parent process, before exec): +//! ```c +//! DdogSessionCarrier *carrier = NULL; +//! DdogMaybeError err = ddog_session_create( +//! "550e8400-e29b-41d4-a716-446655440000", +//! "660e8400-e29b-41d4-a716-446655440001", // or NULL +//! &carrier +//! ); +//! // ... keep `carrier` alive until children have read it ... +//! ddog_session_carrier_drop(carrier); +//! ``` +//! +//! ### Reader (child process, at init): +//! ```c +//! DdogSessionResult result; +//! DdogMaybeError err = ddog_session_read_parent(&result); +//! if (result.found) { +//! printf("session: %s\n", result.session_id); +//! if (result.parent_session_id[0] != '\0') { +//! printf("parent: %s\n", result.parent_session_id); +//! } +//! } +//! ``` + +#![cfg_attr(not(test), deny(clippy::panic))] +#![cfg_attr(not(test), deny(clippy::unwrap_used))] +#![cfg_attr(not(test), deny(clippy::expect_used))] +#![cfg_attr(not(test), deny(clippy::todo))] +#![cfg_attr(not(test), deny(clippy::unimplemented))] + +use libdd_common_ffi as ffi; +use std::ffi::CStr; +use std::os::raw::c_char; + +macro_rules! try_c { + ($failable:expr) => { + match $failable { + Ok(o) => o, + Err(e) => return ffi::MaybeError::Some(ffi::Error::from(format!("{e:?}"))), + } + }; +} + +/// Maximum length for session ID strings (including null terminator). +/// UUIDs are 36 chars; we allow some headroom. +const SESSION_ID_MAX_LEN: usize = 128; + +/// Opaque handle returned by [`ddog_session_create`]. Must be kept alive +/// (not dropped) for as long as child processes need to read the session. +pub struct DdogSessionCarrier { + _inner: datadog_session_id::SessionCarrier, +} + +/// Result struct returned by [`ddog_session_read_parent`] and +/// [`ddog_session_read_pid`]. +#[repr(C)] +pub struct DdogSessionResult { + /// `true` if a session segment was found and read successfully. + pub found: bool, + /// The session ID string (null-terminated). Empty if `found` is false. + pub session_id: [c_char; SESSION_ID_MAX_LEN], + /// The parent session ID string (null-terminated). Empty if not set + /// or if `found` is false. + pub parent_session_id: [c_char; SESSION_ID_MAX_LEN], +} + +impl Default for DdogSessionResult { + fn default() -> Self { + Self { + found: false, + session_id: [0; SESSION_ID_MAX_LEN], + parent_session_id: [0; SESSION_ID_MAX_LEN], + } + } +} + +fn copy_str_to_buf(src: &str, dst: &mut [c_char; SESSION_ID_MAX_LEN]) { + let bytes = src.as_bytes(); + let copy_len = bytes.len().min(SESSION_ID_MAX_LEN - 1); + for (i, &b) in bytes[..copy_len].iter().enumerate() { + dst[i] = b as c_char; + } + dst[copy_len] = 0; +} + +/// Create a session carrier for the current process. +/// +/// # Safety +/// - `session_id` must be a valid null-terminated C string. +/// - `parent_session_id` may be null (no parent). +/// - `out` must be a valid pointer to a `*mut DdogSessionCarrier`. +/// +/// The caller must eventually call [`ddog_session_carrier_drop`] on the +/// returned handle. +#[no_mangle] +pub unsafe extern "C" fn ddog_session_create( + session_id: *const c_char, + parent_session_id: *const c_char, + out: *mut *mut DdogSessionCarrier, +) -> ffi::MaybeError { + if session_id.is_null() || out.is_null() { + return ffi::MaybeError::Some(ffi::Error::from( + "ddog_session_create: null session_id or out pointer".to_string(), + )); + } + + let sid = unsafe { CStr::from_ptr(session_id) }; + let sid_str = try_c!(sid.to_str().map_err(|e| anyhow::anyhow!("{e}"))); + + let parent_str = if parent_session_id.is_null() { + None + } else { + let psid = unsafe { CStr::from_ptr(parent_session_id) }; + Some(try_c!(psid.to_str().map_err(|e| anyhow::anyhow!("{e}")))) + }; + + let carrier = try_c!(datadog_session_id::create_session_carrier( + sid_str, + parent_str, + )); + + unsafe { + *out = Box::into_raw(Box::new(DdogSessionCarrier { _inner: carrier })); + } + + ffi::MaybeError::None +} + +/// Drop (free) a session carrier previously created with +/// [`ddog_session_create`]. After this call the shared memory segment +/// is unmapped and children can no longer read it. +/// +/// # Safety +/// - `carrier` must have been returned by `ddog_session_create`, or be null. +#[no_mangle] +pub unsafe extern "C" fn ddog_session_carrier_drop(carrier: *mut DdogSessionCarrier) { + if !carrier.is_null() { + unsafe { + drop(Box::from_raw(carrier)); + } + } +} + +/// Read session data from the **parent process**. +/// +/// # Safety +/// - `out` must be a valid pointer to a `DdogSessionResult`. +#[no_mangle] +pub unsafe extern "C" fn ddog_session_read_parent( + out: *mut DdogSessionResult, +) -> ffi::MaybeError { + if out.is_null() { + return ffi::MaybeError::Some(ffi::Error::from( + "ddog_session_read_parent: null out pointer".to_string(), + )); + } + + let mut result = DdogSessionResult::default(); + + match datadog_session_id::read_parent_session() { + Ok(Some(payload)) => { + result.found = true; + copy_str_to_buf(&payload.session_id, &mut result.session_id); + if let Some(ref psid) = payload.parent_session_id { + copy_str_to_buf(psid, &mut result.parent_session_id); + } + } + Ok(None) => { + result.found = false; + } + Err(e) => { + unsafe { *out = result; } + return ffi::MaybeError::Some(ffi::Error::from(format!("{e:?}"))); + } + } + + unsafe { *out = result; } + ffi::MaybeError::None +} + +/// Read session data from a **specific process** by PID. +/// +/// # Safety +/// - `out` must be a valid pointer to a `DdogSessionResult`. +#[no_mangle] +pub unsafe extern "C" fn ddog_session_read_pid( + pid: u32, + out: *mut DdogSessionResult, +) -> ffi::MaybeError { + if out.is_null() { + return ffi::MaybeError::Some(ffi::Error::from( + "ddog_session_read_pid: null out pointer".to_string(), + )); + } + + let mut result = DdogSessionResult::default(); + + match datadog_session_id::read_session_for_pid(pid) { + Ok(Some(payload)) => { + result.found = true; + copy_str_to_buf(&payload.session_id, &mut result.session_id); + if let Some(ref psid) = payload.parent_session_id { + copy_str_to_buf(psid, &mut result.parent_session_id); + } + } + Ok(None) => { + result.found = false; + } + Err(e) => { + unsafe { *out = result; } + return ffi::MaybeError::Some(ffi::Error::from(format!("{e:?}"))); + } + } + + unsafe { *out = result; } + ffi::MaybeError::None +} diff --git a/datadog-session-id/Cargo.toml b/datadog-session-id/Cargo.toml new file mode 100644 index 0000000000..5c65545975 --- /dev/null +++ b/datadog-session-id/Cargo.toml @@ -0,0 +1,18 @@ +# Copyright 2021-Present Datadog, Inc. https://www.datadoghq.com/ +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "datadog-session-id" +version = "0.1.0" +edition.workspace = true +license.workspace = true +publish = false + +[dependencies] +datadog-ipc = { path = "../datadog-ipc" } +anyhow = "1.0" +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[target.'cfg(unix)'.dependencies] +libc = "0.2" diff --git a/datadog-session-id/src/lib.rs b/datadog-session-id/src/lib.rs new file mode 100644 index 0000000000..41bbca3206 --- /dev/null +++ b/datadog-session-id/src/lib.rs @@ -0,0 +1,295 @@ +// Copyright 2021-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +//! Cross-platform shared memory carrier for Datadog session IDs. +//! +//! This crate provides a thin abstraction over named shared memory +//! (POSIX `shm_open` on Unix, `CreateFileMapping` on Windows) to +//! propagate stable session identifiers from a parent process to its +//! children across `fork`/`exec` boundaries. +//! +//! ## Wire format +//! +//! The shared memory region contains a JSON payload: +//! +//! ```json +//! { +//! "version": 1, +//! "session_id": "", +//! "parent_session_id": "" +//! } +//! ``` +//! +//! ## Discovery +//! +//! The SHM segment is created under a well-known name derived from the +//! **creating process's PID**: +//! +//! - Unix: `/dd-session-` (via `shm_open` or `/tmp/libdatadog` fallback) +//! - Windows: `Local\dd-session-` (via `CreateFileMapping`) +//! +//! A child process discovers the parent's segment by opening +//! `/dd-session-`. + +#![cfg_attr(not(test), deny(clippy::panic))] +#![cfg_attr(not(test), deny(clippy::unwrap_used))] +#![cfg_attr(not(test), deny(clippy::expect_used))] +#![cfg_attr(not(test), deny(clippy::todo))] +#![cfg_attr(not(test), deny(clippy::unimplemented))] + +use datadog_ipc::platform::{FileBackedHandle, MappedMem, NamedShmHandle}; +use serde::{Deserialize, Serialize}; +use std::ffi::CString; + +const SHM_NAME_PREFIX: &str = "/dd-session-"; + +/// Current wire format version. +const WIRE_VERSION: u32 = 1; + +/// Maximum size for the session payload. 4 KiB is more than enough for two +/// UUIDs plus a version field serialized as JSON. +const MAX_PAYLOAD_SIZE: usize = 4096; + +/// The payload written to and read from shared memory. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SessionPayload { + pub version: u32, + pub session_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_session_id: Option, +} + +/// An opaque handle to the shared memory segment that holds session data. +/// The segment stays alive (and readable by children) for as long as this +/// handle is not dropped. +pub struct SessionCarrier { + _mapped: MappedMem, +} + +/// Create a new session carrier for the **current process**. +/// +/// Writes `session_id` (and optionally `parent_session_id`) into a named +/// shared memory segment discoverable by child processes via +/// [`read_parent_session`]. +/// +/// The caller **must** keep the returned [`SessionCarrier`] alive for as +/// long as child processes may need to read the session data. +pub fn create_session_carrier( + session_id: &str, + parent_session_id: Option<&str>, +) -> anyhow::Result { + create_session_carrier_for_pid(session_id, parent_session_id, current_pid()) +} + +/// Create a session carrier associated with a specific PID. +/// +/// This is the same as [`create_session_carrier`] but allows the caller +/// to control which PID the segment is keyed under. Useful for testing +/// or for advanced scenarios where the publishing PID differs from the +/// current process. +pub fn create_session_carrier_for_pid( + session_id: &str, + parent_session_id: Option<&str>, + pid: u32, +) -> anyhow::Result { + let payload = SessionPayload { + version: WIRE_VERSION, + session_id: session_id.to_owned(), + parent_session_id: parent_session_id.map(|s| s.to_owned()), + }; + + let data = serde_json::to_vec(&payload)?; + if data.len() > MAX_PAYLOAD_SIZE { + anyhow::bail!( + "Session payload too large: {} bytes (max {})", + data.len(), + MAX_PAYLOAD_SIZE + ); + } + + let shm_name = shm_name_for_pid(pid)?; + let handle = NamedShmHandle::create(shm_name, MAX_PAYLOAD_SIZE)?; + let mut mapped = handle.map()?; + + let buf = mapped.as_slice_mut(); + buf[..data.len()].copy_from_slice(&data); + // Zero the rest so readers can find the end of JSON + for byte in &mut buf[data.len()..] { + *byte = 0; + } + + Ok(SessionCarrier { _mapped: mapped }) +} + +/// Read session data published by the **parent process**. +/// +/// Opens the shared memory segment created by the parent (using the +/// parent's PID for discovery) and deserializes the session payload. +/// +/// Returns `Ok(None)` if the parent did not publish session data (segment +/// does not exist). +pub fn read_parent_session() -> anyhow::Result> { + let ppid = parent_pid(); + read_session_for_pid(ppid) +} + +/// Read session data published by a specific process. +pub fn read_session_for_pid(pid: u32) -> anyhow::Result> { + let shm_name = shm_name_for_pid(pid)?; + let handle = match NamedShmHandle::open(&shm_name) { + Ok(h) => h, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + // On some platforms, permission errors or ENOENT variants differ + Err(e) => { + // Treat "does not exist" style errors as None + let raw = e.raw_os_error().unwrap_or(0); + if is_not_found_error(raw) { + return Ok(None); + } + return Err(e.into()); + } + }; + + let mapped = handle.map()?; + let slice = mapped.as_slice(); + + // Find the end of the JSON data (first null byte or end of region) + let end = slice.iter().position(|&b| b == 0).unwrap_or(slice.len()); + if end == 0 { + return Ok(None); + } + + let payload: SessionPayload = serde_json::from_slice(&slice[..end])?; + Ok(Some(payload)) +} + +fn shm_name_for_pid(pid: u32) -> anyhow::Result { + let name = format!("{SHM_NAME_PREFIX}{pid}"); + Ok(CString::new(name)?) +} + +fn current_pid() -> u32 { + std::process::id() +} + +#[cfg(unix)] +fn parent_pid() -> u32 { + unsafe { libc::getppid() as u32 } +} + +#[cfg(windows)] +fn parent_pid() -> u32 { + // On Windows we need the Windows API to get the parent PID. + // Using the `winapi` crate already in the dependency tree via datadog-ipc. + use winapi::um::processthreadsapi::GetCurrentProcessId; + use winapi::um::tlhelp32::{ + CreateToolhelp32Snapshot, Process32First, Process32Next, PROCESSENTRY32, TH32CS_SNAPPROCESS, + }; + unsafe { + let our_pid = GetCurrentProcessId(); + let snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if snap == winapi::um::handleapi::INVALID_HANDLE_VALUE { + return 0; + } + let mut entry: PROCESSENTRY32 = std::mem::zeroed(); + entry.dwSize = std::mem::size_of::() as u32; + if Process32First(snap, &mut entry) != 0 { + loop { + if entry.th32ProcessID == our_pid { + winapi::um::handleapi::CloseHandle(snap); + return entry.th32ParentProcessID; + } + if Process32Next(snap, &mut entry) == 0 { + break; + } + } + } + winapi::um::handleapi::CloseHandle(snap); + 0 + } +} + +/// Platform-specific check for "not found" OS error codes. +#[cfg(unix)] +fn is_not_found_error(raw: i32) -> bool { + raw == libc::ENOENT || raw == libc::ENOSYS || raw == libc::ENOTSUP +} + +#[cfg(windows)] +fn is_not_found_error(raw: i32) -> bool { + // ERROR_FILE_NOT_FOUND = 2 + raw == 2 +} + +#[cfg(test)] +mod tests { + use super::*; + + // Use distinct fake PIDs so tests don't collide when run in parallel. + // These PIDs are high enough to be very unlikely to exist. + const TEST_PID_1: u32 = 9_900_001; + const TEST_PID_2: u32 = 9_900_002; + + #[test] + #[cfg_attr(miri, ignore)] + fn roundtrip_session_carrier() { + let session_id = "550e8400-e29b-41d4-a716-446655440000"; + let parent_id = "660e8400-e29b-41d4-a716-446655440001"; + + let _carrier = create_session_carrier_for_pid( + session_id, + Some(parent_id), + TEST_PID_1, + ) + .expect("create carrier"); + + let payload = read_session_for_pid(TEST_PID_1).expect("read session"); + + let payload = payload.expect("payload should exist"); + assert_eq!(payload.version, WIRE_VERSION); + assert_eq!(payload.session_id, session_id); + assert_eq!( + payload.parent_session_id.as_deref(), + Some(parent_id) + ); + } + + #[test] + #[cfg_attr(miri, ignore)] + fn roundtrip_no_parent() { + let session_id = "770e8400-e29b-41d4-a716-446655440002"; + + let _carrier = create_session_carrier_for_pid( + session_id, + None, + TEST_PID_2, + ) + .expect("create carrier"); + + let payload = read_session_for_pid(TEST_PID_2).expect("read session"); + + let payload = payload.expect("payload should exist"); + assert_eq!(payload.session_id, session_id); + assert!(payload.parent_session_id.is_none()); + } + + #[test] + #[cfg_attr(miri, ignore)] + fn read_nonexistent_pid_returns_none() { + // PID 1 is init/systemd — very unlikely to have our SHM segment + let result = read_session_for_pid(999_999_999).expect("should not error"); + assert!(result.is_none()); + } + + #[test] + fn payload_serialization() { + let payload = SessionPayload { + version: 1, + session_id: "abc-123".to_string(), + parent_session_id: Some("def-456".to_string()), + }; + let json = serde_json::to_string(&payload).expect("serialize"); + let back: SessionPayload = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(payload, back); + } +} From aea39d921a0c27181b2838a5cb5a5422b8bc7be5 Mon Sep 17 00:00:00 2001 From: Ayan Khan Date: Tue, 24 Mar 2026 15:15:28 -0400 Subject: [PATCH 2/2] refactor: generalize to cross-platform named SHM primitives Replace the session-ID-specific crates with generic shared memory building blocks: - datadog-shm: ShmWriter (create/update) and ShmReader (open/read) backed by datadog-ipc's NamedShmHandle (shm_open on Unix, CreateFileMapping on Windows). Also provides PID-keyed convenience helpers and parent_pid() cross-platform. - datadog-shm-ffi: C FFI surface (ddog_shm_create, ddog_shm_open, ddog_shm_read_data, ddog_shm_update, ddog_shm_create_pid_keyed, ddog_shm_open_pid_keyed, etc.) Session IDs, config snapshots, or any other small blob can now be shared across processes through a single, reusable API. Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 8 +- Cargo.toml | 4 +- datadog-session-id-ffi/src/lib.rs | 228 ----------- datadog-session-id/src/lib.rs | 295 -------------- .../Cargo.toml | 4 +- .../build.rs | 2 +- datadog-shm-ffi/src/lib.rs | 354 +++++++++++++++++ .../Cargo.toml | 4 +- datadog-shm/src/lib.rs | 369 ++++++++++++++++++ 9 files changed, 732 insertions(+), 536 deletions(-) delete mode 100644 datadog-session-id-ffi/src/lib.rs delete mode 100644 datadog-session-id/src/lib.rs rename {datadog-session-id-ffi => datadog-shm-ffi}/Cargo.toml (85%) rename {datadog-session-id-ffi => datadog-shm-ffi}/build.rs (86%) create mode 100644 datadog-shm-ffi/src/lib.rs rename {datadog-session-id => datadog-shm}/Cargo.toml (77%) create mode 100644 datadog-shm/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index f710eccccf..506ffac368 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1503,23 +1503,21 @@ dependencies = [ ] [[package]] -name = "datadog-session-id" +name = "datadog-shm" version = "0.1.0" dependencies = [ "anyhow", "datadog-ipc", "libc", - "serde", - "serde_json", ] [[package]] -name = "datadog-session-id-ffi" +name = "datadog-shm-ffi" version = "0.1.0" dependencies = [ "anyhow", "build_common", - "datadog-session-id", + "datadog-shm", "libdd-common-ffi", ] diff --git a/Cargo.toml b/Cargo.toml index 9f97ea5297..5f03127076 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,8 +47,8 @@ members = [ "libdd-dogstatsd-client", "libdd-log", "libdd-log-ffi", "libdd-libunwind-sys", - "datadog-session-id", - "datadog-session-id-ffi", + "datadog-shm", + "datadog-shm-ffi", ] # https://doc.rust-lang.org/cargo/reference/resolver.html diff --git a/datadog-session-id-ffi/src/lib.rs b/datadog-session-id-ffi/src/lib.rs deleted file mode 100644 index 4b723b1b46..0000000000 --- a/datadog-session-id-ffi/src/lib.rs +++ /dev/null @@ -1,228 +0,0 @@ -// Copyright 2021-Present Datadog, Inc. https://www.datadoghq.com/ -// SPDX-License-Identifier: Apache-2.0 - -//! C FFI for the cross-platform session ID shared memory carrier. -//! -//! SDKs that link against libdatadog can call these functions to create -//! and read session carriers without dealing with platform-specific -//! shared memory details. -//! -//! ## Typical usage from C -//! -//! ### Writer (parent process, before exec): -//! ```c -//! DdogSessionCarrier *carrier = NULL; -//! DdogMaybeError err = ddog_session_create( -//! "550e8400-e29b-41d4-a716-446655440000", -//! "660e8400-e29b-41d4-a716-446655440001", // or NULL -//! &carrier -//! ); -//! // ... keep `carrier` alive until children have read it ... -//! ddog_session_carrier_drop(carrier); -//! ``` -//! -//! ### Reader (child process, at init): -//! ```c -//! DdogSessionResult result; -//! DdogMaybeError err = ddog_session_read_parent(&result); -//! if (result.found) { -//! printf("session: %s\n", result.session_id); -//! if (result.parent_session_id[0] != '\0') { -//! printf("parent: %s\n", result.parent_session_id); -//! } -//! } -//! ``` - -#![cfg_attr(not(test), deny(clippy::panic))] -#![cfg_attr(not(test), deny(clippy::unwrap_used))] -#![cfg_attr(not(test), deny(clippy::expect_used))] -#![cfg_attr(not(test), deny(clippy::todo))] -#![cfg_attr(not(test), deny(clippy::unimplemented))] - -use libdd_common_ffi as ffi; -use std::ffi::CStr; -use std::os::raw::c_char; - -macro_rules! try_c { - ($failable:expr) => { - match $failable { - Ok(o) => o, - Err(e) => return ffi::MaybeError::Some(ffi::Error::from(format!("{e:?}"))), - } - }; -} - -/// Maximum length for session ID strings (including null terminator). -/// UUIDs are 36 chars; we allow some headroom. -const SESSION_ID_MAX_LEN: usize = 128; - -/// Opaque handle returned by [`ddog_session_create`]. Must be kept alive -/// (not dropped) for as long as child processes need to read the session. -pub struct DdogSessionCarrier { - _inner: datadog_session_id::SessionCarrier, -} - -/// Result struct returned by [`ddog_session_read_parent`] and -/// [`ddog_session_read_pid`]. -#[repr(C)] -pub struct DdogSessionResult { - /// `true` if a session segment was found and read successfully. - pub found: bool, - /// The session ID string (null-terminated). Empty if `found` is false. - pub session_id: [c_char; SESSION_ID_MAX_LEN], - /// The parent session ID string (null-terminated). Empty if not set - /// or if `found` is false. - pub parent_session_id: [c_char; SESSION_ID_MAX_LEN], -} - -impl Default for DdogSessionResult { - fn default() -> Self { - Self { - found: false, - session_id: [0; SESSION_ID_MAX_LEN], - parent_session_id: [0; SESSION_ID_MAX_LEN], - } - } -} - -fn copy_str_to_buf(src: &str, dst: &mut [c_char; SESSION_ID_MAX_LEN]) { - let bytes = src.as_bytes(); - let copy_len = bytes.len().min(SESSION_ID_MAX_LEN - 1); - for (i, &b) in bytes[..copy_len].iter().enumerate() { - dst[i] = b as c_char; - } - dst[copy_len] = 0; -} - -/// Create a session carrier for the current process. -/// -/// # Safety -/// - `session_id` must be a valid null-terminated C string. -/// - `parent_session_id` may be null (no parent). -/// - `out` must be a valid pointer to a `*mut DdogSessionCarrier`. -/// -/// The caller must eventually call [`ddog_session_carrier_drop`] on the -/// returned handle. -#[no_mangle] -pub unsafe extern "C" fn ddog_session_create( - session_id: *const c_char, - parent_session_id: *const c_char, - out: *mut *mut DdogSessionCarrier, -) -> ffi::MaybeError { - if session_id.is_null() || out.is_null() { - return ffi::MaybeError::Some(ffi::Error::from( - "ddog_session_create: null session_id or out pointer".to_string(), - )); - } - - let sid = unsafe { CStr::from_ptr(session_id) }; - let sid_str = try_c!(sid.to_str().map_err(|e| anyhow::anyhow!("{e}"))); - - let parent_str = if parent_session_id.is_null() { - None - } else { - let psid = unsafe { CStr::from_ptr(parent_session_id) }; - Some(try_c!(psid.to_str().map_err(|e| anyhow::anyhow!("{e}")))) - }; - - let carrier = try_c!(datadog_session_id::create_session_carrier( - sid_str, - parent_str, - )); - - unsafe { - *out = Box::into_raw(Box::new(DdogSessionCarrier { _inner: carrier })); - } - - ffi::MaybeError::None -} - -/// Drop (free) a session carrier previously created with -/// [`ddog_session_create`]. After this call the shared memory segment -/// is unmapped and children can no longer read it. -/// -/// # Safety -/// - `carrier` must have been returned by `ddog_session_create`, or be null. -#[no_mangle] -pub unsafe extern "C" fn ddog_session_carrier_drop(carrier: *mut DdogSessionCarrier) { - if !carrier.is_null() { - unsafe { - drop(Box::from_raw(carrier)); - } - } -} - -/// Read session data from the **parent process**. -/// -/// # Safety -/// - `out` must be a valid pointer to a `DdogSessionResult`. -#[no_mangle] -pub unsafe extern "C" fn ddog_session_read_parent( - out: *mut DdogSessionResult, -) -> ffi::MaybeError { - if out.is_null() { - return ffi::MaybeError::Some(ffi::Error::from( - "ddog_session_read_parent: null out pointer".to_string(), - )); - } - - let mut result = DdogSessionResult::default(); - - match datadog_session_id::read_parent_session() { - Ok(Some(payload)) => { - result.found = true; - copy_str_to_buf(&payload.session_id, &mut result.session_id); - if let Some(ref psid) = payload.parent_session_id { - copy_str_to_buf(psid, &mut result.parent_session_id); - } - } - Ok(None) => { - result.found = false; - } - Err(e) => { - unsafe { *out = result; } - return ffi::MaybeError::Some(ffi::Error::from(format!("{e:?}"))); - } - } - - unsafe { *out = result; } - ffi::MaybeError::None -} - -/// Read session data from a **specific process** by PID. -/// -/// # Safety -/// - `out` must be a valid pointer to a `DdogSessionResult`. -#[no_mangle] -pub unsafe extern "C" fn ddog_session_read_pid( - pid: u32, - out: *mut DdogSessionResult, -) -> ffi::MaybeError { - if out.is_null() { - return ffi::MaybeError::Some(ffi::Error::from( - "ddog_session_read_pid: null out pointer".to_string(), - )); - } - - let mut result = DdogSessionResult::default(); - - match datadog_session_id::read_session_for_pid(pid) { - Ok(Some(payload)) => { - result.found = true; - copy_str_to_buf(&payload.session_id, &mut result.session_id); - if let Some(ref psid) = payload.parent_session_id { - copy_str_to_buf(psid, &mut result.parent_session_id); - } - } - Ok(None) => { - result.found = false; - } - Err(e) => { - unsafe { *out = result; } - return ffi::MaybeError::Some(ffi::Error::from(format!("{e:?}"))); - } - } - - unsafe { *out = result; } - ffi::MaybeError::None -} diff --git a/datadog-session-id/src/lib.rs b/datadog-session-id/src/lib.rs deleted file mode 100644 index 41bbca3206..0000000000 --- a/datadog-session-id/src/lib.rs +++ /dev/null @@ -1,295 +0,0 @@ -// Copyright 2021-Present Datadog, Inc. https://www.datadoghq.com/ -// SPDX-License-Identifier: Apache-2.0 - -//! Cross-platform shared memory carrier for Datadog session IDs. -//! -//! This crate provides a thin abstraction over named shared memory -//! (POSIX `shm_open` on Unix, `CreateFileMapping` on Windows) to -//! propagate stable session identifiers from a parent process to its -//! children across `fork`/`exec` boundaries. -//! -//! ## Wire format -//! -//! The shared memory region contains a JSON payload: -//! -//! ```json -//! { -//! "version": 1, -//! "session_id": "", -//! "parent_session_id": "" -//! } -//! ``` -//! -//! ## Discovery -//! -//! The SHM segment is created under a well-known name derived from the -//! **creating process's PID**: -//! -//! - Unix: `/dd-session-` (via `shm_open` or `/tmp/libdatadog` fallback) -//! - Windows: `Local\dd-session-` (via `CreateFileMapping`) -//! -//! A child process discovers the parent's segment by opening -//! `/dd-session-`. - -#![cfg_attr(not(test), deny(clippy::panic))] -#![cfg_attr(not(test), deny(clippy::unwrap_used))] -#![cfg_attr(not(test), deny(clippy::expect_used))] -#![cfg_attr(not(test), deny(clippy::todo))] -#![cfg_attr(not(test), deny(clippy::unimplemented))] - -use datadog_ipc::platform::{FileBackedHandle, MappedMem, NamedShmHandle}; -use serde::{Deserialize, Serialize}; -use std::ffi::CString; - -const SHM_NAME_PREFIX: &str = "/dd-session-"; - -/// Current wire format version. -const WIRE_VERSION: u32 = 1; - -/// Maximum size for the session payload. 4 KiB is more than enough for two -/// UUIDs plus a version field serialized as JSON. -const MAX_PAYLOAD_SIZE: usize = 4096; - -/// The payload written to and read from shared memory. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct SessionPayload { - pub version: u32, - pub session_id: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub parent_session_id: Option, -} - -/// An opaque handle to the shared memory segment that holds session data. -/// The segment stays alive (and readable by children) for as long as this -/// handle is not dropped. -pub struct SessionCarrier { - _mapped: MappedMem, -} - -/// Create a new session carrier for the **current process**. -/// -/// Writes `session_id` (and optionally `parent_session_id`) into a named -/// shared memory segment discoverable by child processes via -/// [`read_parent_session`]. -/// -/// The caller **must** keep the returned [`SessionCarrier`] alive for as -/// long as child processes may need to read the session data. -pub fn create_session_carrier( - session_id: &str, - parent_session_id: Option<&str>, -) -> anyhow::Result { - create_session_carrier_for_pid(session_id, parent_session_id, current_pid()) -} - -/// Create a session carrier associated with a specific PID. -/// -/// This is the same as [`create_session_carrier`] but allows the caller -/// to control which PID the segment is keyed under. Useful for testing -/// or for advanced scenarios where the publishing PID differs from the -/// current process. -pub fn create_session_carrier_for_pid( - session_id: &str, - parent_session_id: Option<&str>, - pid: u32, -) -> anyhow::Result { - let payload = SessionPayload { - version: WIRE_VERSION, - session_id: session_id.to_owned(), - parent_session_id: parent_session_id.map(|s| s.to_owned()), - }; - - let data = serde_json::to_vec(&payload)?; - if data.len() > MAX_PAYLOAD_SIZE { - anyhow::bail!( - "Session payload too large: {} bytes (max {})", - data.len(), - MAX_PAYLOAD_SIZE - ); - } - - let shm_name = shm_name_for_pid(pid)?; - let handle = NamedShmHandle::create(shm_name, MAX_PAYLOAD_SIZE)?; - let mut mapped = handle.map()?; - - let buf = mapped.as_slice_mut(); - buf[..data.len()].copy_from_slice(&data); - // Zero the rest so readers can find the end of JSON - for byte in &mut buf[data.len()..] { - *byte = 0; - } - - Ok(SessionCarrier { _mapped: mapped }) -} - -/// Read session data published by the **parent process**. -/// -/// Opens the shared memory segment created by the parent (using the -/// parent's PID for discovery) and deserializes the session payload. -/// -/// Returns `Ok(None)` if the parent did not publish session data (segment -/// does not exist). -pub fn read_parent_session() -> anyhow::Result> { - let ppid = parent_pid(); - read_session_for_pid(ppid) -} - -/// Read session data published by a specific process. -pub fn read_session_for_pid(pid: u32) -> anyhow::Result> { - let shm_name = shm_name_for_pid(pid)?; - let handle = match NamedShmHandle::open(&shm_name) { - Ok(h) => h, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), - // On some platforms, permission errors or ENOENT variants differ - Err(e) => { - // Treat "does not exist" style errors as None - let raw = e.raw_os_error().unwrap_or(0); - if is_not_found_error(raw) { - return Ok(None); - } - return Err(e.into()); - } - }; - - let mapped = handle.map()?; - let slice = mapped.as_slice(); - - // Find the end of the JSON data (first null byte or end of region) - let end = slice.iter().position(|&b| b == 0).unwrap_or(slice.len()); - if end == 0 { - return Ok(None); - } - - let payload: SessionPayload = serde_json::from_slice(&slice[..end])?; - Ok(Some(payload)) -} - -fn shm_name_for_pid(pid: u32) -> anyhow::Result { - let name = format!("{SHM_NAME_PREFIX}{pid}"); - Ok(CString::new(name)?) -} - -fn current_pid() -> u32 { - std::process::id() -} - -#[cfg(unix)] -fn parent_pid() -> u32 { - unsafe { libc::getppid() as u32 } -} - -#[cfg(windows)] -fn parent_pid() -> u32 { - // On Windows we need the Windows API to get the parent PID. - // Using the `winapi` crate already in the dependency tree via datadog-ipc. - use winapi::um::processthreadsapi::GetCurrentProcessId; - use winapi::um::tlhelp32::{ - CreateToolhelp32Snapshot, Process32First, Process32Next, PROCESSENTRY32, TH32CS_SNAPPROCESS, - }; - unsafe { - let our_pid = GetCurrentProcessId(); - let snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); - if snap == winapi::um::handleapi::INVALID_HANDLE_VALUE { - return 0; - } - let mut entry: PROCESSENTRY32 = std::mem::zeroed(); - entry.dwSize = std::mem::size_of::() as u32; - if Process32First(snap, &mut entry) != 0 { - loop { - if entry.th32ProcessID == our_pid { - winapi::um::handleapi::CloseHandle(snap); - return entry.th32ParentProcessID; - } - if Process32Next(snap, &mut entry) == 0 { - break; - } - } - } - winapi::um::handleapi::CloseHandle(snap); - 0 - } -} - -/// Platform-specific check for "not found" OS error codes. -#[cfg(unix)] -fn is_not_found_error(raw: i32) -> bool { - raw == libc::ENOENT || raw == libc::ENOSYS || raw == libc::ENOTSUP -} - -#[cfg(windows)] -fn is_not_found_error(raw: i32) -> bool { - // ERROR_FILE_NOT_FOUND = 2 - raw == 2 -} - -#[cfg(test)] -mod tests { - use super::*; - - // Use distinct fake PIDs so tests don't collide when run in parallel. - // These PIDs are high enough to be very unlikely to exist. - const TEST_PID_1: u32 = 9_900_001; - const TEST_PID_2: u32 = 9_900_002; - - #[test] - #[cfg_attr(miri, ignore)] - fn roundtrip_session_carrier() { - let session_id = "550e8400-e29b-41d4-a716-446655440000"; - let parent_id = "660e8400-e29b-41d4-a716-446655440001"; - - let _carrier = create_session_carrier_for_pid( - session_id, - Some(parent_id), - TEST_PID_1, - ) - .expect("create carrier"); - - let payload = read_session_for_pid(TEST_PID_1).expect("read session"); - - let payload = payload.expect("payload should exist"); - assert_eq!(payload.version, WIRE_VERSION); - assert_eq!(payload.session_id, session_id); - assert_eq!( - payload.parent_session_id.as_deref(), - Some(parent_id) - ); - } - - #[test] - #[cfg_attr(miri, ignore)] - fn roundtrip_no_parent() { - let session_id = "770e8400-e29b-41d4-a716-446655440002"; - - let _carrier = create_session_carrier_for_pid( - session_id, - None, - TEST_PID_2, - ) - .expect("create carrier"); - - let payload = read_session_for_pid(TEST_PID_2).expect("read session"); - - let payload = payload.expect("payload should exist"); - assert_eq!(payload.session_id, session_id); - assert!(payload.parent_session_id.is_none()); - } - - #[test] - #[cfg_attr(miri, ignore)] - fn read_nonexistent_pid_returns_none() { - // PID 1 is init/systemd — very unlikely to have our SHM segment - let result = read_session_for_pid(999_999_999).expect("should not error"); - assert!(result.is_none()); - } - - #[test] - fn payload_serialization() { - let payload = SessionPayload { - version: 1, - session_id: "abc-123".to_string(), - parent_session_id: Some("def-456".to_string()), - }; - let json = serde_json::to_string(&payload).expect("serialize"); - let back: SessionPayload = serde_json::from_str(&json).expect("deserialize"); - assert_eq!(payload, back); - } -} diff --git a/datadog-session-id-ffi/Cargo.toml b/datadog-shm-ffi/Cargo.toml similarity index 85% rename from datadog-session-id-ffi/Cargo.toml rename to datadog-shm-ffi/Cargo.toml index 2054c8568d..811712b57d 100644 --- a/datadog-session-id-ffi/Cargo.toml +++ b/datadog-shm-ffi/Cargo.toml @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 [package] -name = "datadog-session-id-ffi" +name = "datadog-shm-ffi" version = "0.1.0" edition.workspace = true license.workspace = true @@ -13,7 +13,7 @@ crate-type = ["staticlib", "cdylib", "lib"] bench = false [dependencies] -datadog-session-id = { path = "../datadog-session-id" } +datadog-shm = { path = "../datadog-shm" } libdd-common-ffi = { path = "../libdd-common-ffi", default-features = false } anyhow = "1.0" diff --git a/datadog-session-id-ffi/build.rs b/datadog-shm-ffi/build.rs similarity index 86% rename from datadog-session-id-ffi/build.rs rename to datadog-shm-ffi/build.rs index c0ef0afaec..085920aa3b 100644 --- a/datadog-session-id-ffi/build.rs +++ b/datadog-shm-ffi/build.rs @@ -5,6 +5,6 @@ extern crate build_common; use build_common::generate_and_configure_header; fn main() { - let header_name = "session-id.h"; + let header_name = "datadog-shm.h"; generate_and_configure_header(header_name); } diff --git a/datadog-shm-ffi/src/lib.rs b/datadog-shm-ffi/src/lib.rs new file mode 100644 index 0000000000..e4716a74db --- /dev/null +++ b/datadog-shm-ffi/src/lib.rs @@ -0,0 +1,354 @@ +// Copyright 2021-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +//! C FFI for cross-platform named shared memory primitives. +//! +//! Provides simple create / open / read / update / drop operations on +//! named SHM segments. SDKs link against this instead of dealing with +//! platform-specific APIs directly. +//! +//! ## Typical usage from C +//! +//! ### Writer +//! ```c +//! DdogShmWriter *writer = NULL; +//! DdogMaybeError err = ddog_shm_create( +//! "dd-session-42", // name +//! data_ptr, data_len, // payload +//! 0, // capacity (0 = default 64 KiB) +//! &writer +//! ); +//! // … segment is readable by other processes … +//! ddog_shm_writer_drop(writer); +//! ``` +//! +//! ### Reader +//! ```c +//! DdogShmReader *reader = NULL; +//! DdogMaybeError err = ddog_shm_open("dd-session-42", &reader); +//! if (reader != NULL) { +//! const uint8_t *ptr = NULL; +//! size_t len = 0; +//! ddog_shm_read_data(reader, &ptr, &len); +//! // … use ptr[0..len] … +//! ddog_shm_reader_drop(reader); +//! } +//! ``` + +#![cfg_attr(not(test), deny(clippy::panic))] +#![cfg_attr(not(test), deny(clippy::unwrap_used))] +#![cfg_attr(not(test), deny(clippy::expect_used))] +#![cfg_attr(not(test), deny(clippy::todo))] +#![cfg_attr(not(test), deny(clippy::unimplemented))] + +use libdd_common_ffi as ffi; +use std::ffi::CStr; +use std::os::raw::c_char; + +macro_rules! try_c { + ($failable:expr) => { + match $failable { + Ok(o) => o, + Err(e) => return ffi::MaybeError::Some(ffi::Error::from(format!("{e:?}"))), + } + }; +} + +/// Opaque writer handle. Keeps the SHM segment alive until dropped. +pub struct DdogShmWriter { + inner: datadog_shm::ShmWriter, +} + +/// Opaque reader handle. +pub struct DdogShmReader { + inner: datadog_shm::ShmReader, +} + +// --------------------------------------------------------------------------- +// Writer +// --------------------------------------------------------------------------- + +/// Create a named SHM segment and write `data` into it. +/// +/// If `capacity` is 0 the default (64 KiB) is used. +/// +/// # Safety +/// - `name` must be a valid null-terminated C string. +/// - `data` must point to at least `data_len` readable bytes +/// (may be NULL if `data_len` is 0). +/// - `out` must be a valid pointer. +#[no_mangle] +pub unsafe extern "C" fn ddog_shm_create( + name: *const c_char, + data: *const u8, + data_len: usize, + capacity: usize, + out: *mut *mut DdogShmWriter, +) -> ffi::MaybeError { + if name.is_null() || out.is_null() { + return ffi::MaybeError::Some(ffi::Error::from( + "ddog_shm_create: null name or out pointer".to_string(), + )); + } + + let name_str = try_c!(unsafe { CStr::from_ptr(name) } + .to_str() + .map_err(|e| anyhow::anyhow!("{e}"))); + + let bytes: &[u8] = if data.is_null() || data_len == 0 { + &[] + } else { + unsafe { std::slice::from_raw_parts(data, data_len) } + }; + + let writer = if capacity == 0 { + try_c!(datadog_shm::ShmWriter::create(name_str, bytes)) + } else { + try_c!(datadog_shm::ShmWriter::create_with_capacity( + name_str, bytes, capacity, + )) + }; + + unsafe { + *out = Box::into_raw(Box::new(DdogShmWriter { inner: writer })); + } + ffi::MaybeError::None +} + +/// Overwrite the segment contents. +/// +/// # Safety +/// - `writer` must have been returned by `ddog_shm_create`. +/// - `data` / `data_len` must be valid. +#[no_mangle] +pub unsafe extern "C" fn ddog_shm_update( + writer: *mut DdogShmWriter, + data: *const u8, + data_len: usize, +) -> ffi::MaybeError { + if writer.is_null() { + return ffi::MaybeError::Some(ffi::Error::from( + "ddog_shm_update: null writer".to_string(), + )); + } + + let bytes: &[u8] = if data.is_null() || data_len == 0 { + &[] + } else { + unsafe { std::slice::from_raw_parts(data, data_len) } + }; + + let w = unsafe { &mut *writer }; + try_c!(w.inner.update(bytes)); + ffi::MaybeError::None +} + +/// Drop a writer, unmapping and unlinking the segment. +/// +/// # Safety +/// - `writer` must have been returned by `ddog_shm_create`, or be null. +#[no_mangle] +pub unsafe extern "C" fn ddog_shm_writer_drop(writer: *mut DdogShmWriter) { + if !writer.is_null() { + unsafe { drop(Box::from_raw(writer)) }; + } +} + +// --------------------------------------------------------------------------- +// Reader +// --------------------------------------------------------------------------- + +/// Open an existing named SHM segment for reading. +/// +/// Sets `*out` to a reader handle, or to NULL if the segment does not +/// exist (this is **not** an error — the returned `MaybeError` will be +/// `None`). +/// +/// # Safety +/// - `name` must be a valid null-terminated C string. +/// - `out` must be a valid pointer. +#[no_mangle] +pub unsafe extern "C" fn ddog_shm_open( + name: *const c_char, + out: *mut *mut DdogShmReader, +) -> ffi::MaybeError { + if name.is_null() || out.is_null() { + return ffi::MaybeError::Some(ffi::Error::from( + "ddog_shm_open: null name or out pointer".to_string(), + )); + } + + let name_str = try_c!(unsafe { CStr::from_ptr(name) } + .to_str() + .map_err(|e| anyhow::anyhow!("{e}"))); + + match datadog_shm::ShmReader::open(name_str) { + Ok(Some(reader)) => { + unsafe { *out = Box::into_raw(Box::new(DdogShmReader { inner: reader })) }; + } + Ok(None) => { + unsafe { *out = std::ptr::null_mut() }; + } + Err(e) => { + unsafe { *out = std::ptr::null_mut() }; + return ffi::MaybeError::Some(ffi::Error::from(format!("{e:?}"))); + } + } + + ffi::MaybeError::None +} + +/// Get a pointer to the non-zero data prefix in the segment. +/// +/// Sets `*out_ptr` and `*out_len`. The pointer is valid until the reader +/// is dropped. +/// +/// # Safety +/// - `reader`, `out_ptr`, `out_len` must all be valid. +#[no_mangle] +pub unsafe extern "C" fn ddog_shm_read_data( + reader: *const DdogShmReader, + out_ptr: *mut *const u8, + out_len: *mut usize, +) -> ffi::MaybeError { + if reader.is_null() || out_ptr.is_null() || out_len.is_null() { + return ffi::MaybeError::Some(ffi::Error::from( + "ddog_shm_read_data: null argument".to_string(), + )); + } + + let r = unsafe { &*reader }; + let data = r.inner.data_bytes(); + unsafe { + *out_ptr = data.as_ptr(); + *out_len = data.len(); + } + ffi::MaybeError::None +} + +/// Get a pointer to the entire mapped region (including trailing zeros). +/// +/// # Safety +/// - `reader`, `out_ptr`, `out_len` must all be valid. +#[no_mangle] +pub unsafe extern "C" fn ddog_shm_read_raw( + reader: *const DdogShmReader, + out_ptr: *mut *const u8, + out_len: *mut usize, +) -> ffi::MaybeError { + if reader.is_null() || out_ptr.is_null() || out_len.is_null() { + return ffi::MaybeError::Some(ffi::Error::from( + "ddog_shm_read_raw: null argument".to_string(), + )); + } + + let r = unsafe { &*reader }; + let bytes = r.inner.as_bytes(); + unsafe { + *out_ptr = bytes.as_ptr(); + *out_len = bytes.len(); + } + ffi::MaybeError::None +} + +/// Drop a reader. +/// +/// # Safety +/// - `reader` must have been returned by `ddog_shm_open`, or be null. +#[no_mangle] +pub unsafe extern "C" fn ddog_shm_reader_drop(reader: *mut DdogShmReader) { + if !reader.is_null() { + unsafe { drop(Box::from_raw(reader)) }; + } +} + +// --------------------------------------------------------------------------- +// PID-keyed convenience +// --------------------------------------------------------------------------- + +/// Create a PID-keyed segment (`/dd--`). +/// +/// # Safety +/// - `prefix` must be a valid null-terminated C string. +/// - `data` / `data_len` must be valid. +/// - `out` must be a valid pointer. +#[no_mangle] +pub unsafe extern "C" fn ddog_shm_create_pid_keyed( + prefix: *const c_char, + pid: u32, + data: *const u8, + data_len: usize, + out: *mut *mut DdogShmWriter, +) -> ffi::MaybeError { + if prefix.is_null() || out.is_null() { + return ffi::MaybeError::Some(ffi::Error::from( + "ddog_shm_create_pid_keyed: null argument".to_string(), + )); + } + + let prefix_str = try_c!(unsafe { CStr::from_ptr(prefix) } + .to_str() + .map_err(|e| anyhow::anyhow!("{e}"))); + + let bytes: &[u8] = if data.is_null() || data_len == 0 { + &[] + } else { + unsafe { std::slice::from_raw_parts(data, data_len) } + }; + + let writer = try_c!(datadog_shm::create_pid_keyed(prefix_str, pid, bytes)); + + unsafe { + *out = Box::into_raw(Box::new(DdogShmWriter { inner: writer })); + } + ffi::MaybeError::None +} + +/// Open a PID-keyed segment. Sets `*out` to NULL if it doesn't exist. +/// +/// # Safety +/// - `prefix` must be a valid null-terminated C string. +/// - `out` must be a valid pointer. +#[no_mangle] +pub unsafe extern "C" fn ddog_shm_open_pid_keyed( + prefix: *const c_char, + pid: u32, + out: *mut *mut DdogShmReader, +) -> ffi::MaybeError { + if prefix.is_null() || out.is_null() { + return ffi::MaybeError::Some(ffi::Error::from( + "ddog_shm_open_pid_keyed: null argument".to_string(), + )); + } + + let prefix_str = try_c!(unsafe { CStr::from_ptr(prefix) } + .to_str() + .map_err(|e| anyhow::anyhow!("{e}"))); + + match datadog_shm::open_pid_keyed(prefix_str, pid) { + Ok(Some(reader)) => { + unsafe { *out = Box::into_raw(Box::new(DdogShmReader { inner: reader })) }; + } + Ok(None) => { + unsafe { *out = std::ptr::null_mut() }; + } + Err(e) => { + unsafe { *out = std::ptr::null_mut() }; + return ffi::MaybeError::Some(ffi::Error::from(format!("{e:?}"))); + } + } + + ffi::MaybeError::None +} + +/// Return the current process ID. +#[no_mangle] +pub extern "C" fn ddog_shm_current_pid() -> u32 { + datadog_shm::current_pid() +} + +/// Return the parent process ID. +#[no_mangle] +pub extern "C" fn ddog_shm_parent_pid() -> u32 { + datadog_shm::parent_pid() +} diff --git a/datadog-session-id/Cargo.toml b/datadog-shm/Cargo.toml similarity index 77% rename from datadog-session-id/Cargo.toml rename to datadog-shm/Cargo.toml index 5c65545975..50b42b4ca3 100644 --- a/datadog-session-id/Cargo.toml +++ b/datadog-shm/Cargo.toml @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 [package] -name = "datadog-session-id" +name = "datadog-shm" version = "0.1.0" edition.workspace = true license.workspace = true @@ -11,8 +11,6 @@ publish = false [dependencies] datadog-ipc = { path = "../datadog-ipc" } anyhow = "1.0" -serde = { version = "1", features = ["derive"] } -serde_json = "1" [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/datadog-shm/src/lib.rs b/datadog-shm/src/lib.rs new file mode 100644 index 0000000000..12d6800bcf --- /dev/null +++ b/datadog-shm/src/lib.rs @@ -0,0 +1,369 @@ +// Copyright 2021-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +//! Cross-platform named shared memory primitives for Datadog SDKs. +//! +//! This crate provides a thin, ergonomic layer on top of +//! [`datadog_ipc::platform::NamedShmHandle`] for creating, writing, and +//! reading named shared memory segments. It hides platform-specific +//! details: +//! +//! - **Unix (Linux)**: `memfd_create` or `shm_open` with `/tmp/libdatadog` +//! fallback (e.g. AWS Lambda) +//! - **Unix (macOS)**: `shm_open` with reserve/commit pattern +//! - **Windows**: `CreateFileMapping` in the `Local\` namespace +//! +//! ## Intended use cases +//! +//! Any scenario where a Datadog SDK process needs to share a small blob +//! of data with related processes (children, sidecars, etc.) via a +//! well-known name: +//! +//! - Propagating session / instance identifiers across `fork`/`exec` +//! - Sharing configuration snapshots between tracer and sidecar +//! - Publishing lightweight status that other processes can poll +//! +//! ## Naming convention +//! +//! Callers supply a plain name (e.g. `"dd-session-12345"`). The name +//! **must** start with `/` (POSIX requirement); this crate will prepend +//! one if missing. On Windows the name is automatically translated to +//! `Local\…` by the underlying `NamedShmHandle`. +//! +//! ## Example +//! +//! ```rust,no_run +//! use datadog_shm::{ShmWriter, ShmReader}; +//! +//! // Writer — create a segment and keep it alive +//! let writer = ShmWriter::create("dd-session-42", b"hello world")?; +//! +//! // Reader — open and read (returns None if segment doesn't exist) +//! if let Some(reader) = ShmReader::open("dd-session-42")? { +//! assert_eq!(reader.as_bytes(), b"hello world"); +//! } +//! # Ok::<(), anyhow::Error>(()) +//! ``` + +#![cfg_attr(not(test), deny(clippy::panic))] +#![cfg_attr(not(test), deny(clippy::unwrap_used))] +#![cfg_attr(not(test), deny(clippy::expect_used))] +#![cfg_attr(not(test), deny(clippy::todo))] +#![cfg_attr(not(test), deny(clippy::unimplemented))] + +use datadog_ipc::platform::{FileBackedHandle, MappedMem, NamedShmHandle}; +use std::ffi::CString; + +/// Default maximum segment size (64 KiB). Callers can override via +/// [`ShmWriter::create_with_capacity`]. +pub const DEFAULT_MAX_SIZE: usize = 65_536; + +// -- helpers ---------------------------------------------------------------- + +fn normalize_name(name: &str) -> anyhow::Result { + let normalized = if name.starts_with('/') { + name.to_owned() + } else { + format!("/{name}") + }; + Ok(CString::new(normalized)?) +} + +/// Platform-specific check for "segment does not exist" OS errors. +#[cfg(unix)] +fn is_not_found_error(e: &std::io::Error) -> bool { + match e.raw_os_error() { + Some(raw) => raw == libc::ENOENT || raw == libc::ENOSYS || raw == libc::ENOTSUP, + None => e.kind() == std::io::ErrorKind::NotFound, + } +} + +#[cfg(windows)] +fn is_not_found_error(e: &std::io::Error) -> bool { + // ERROR_FILE_NOT_FOUND = 2 + e.raw_os_error() == Some(2) || e.kind() == std::io::ErrorKind::NotFound +} + +// -- writer ----------------------------------------------------------------- + +/// A named shared memory segment open for writing. +/// +/// The segment remains accessible to other processes for as long as the +/// `ShmWriter` is alive. Dropping it unmaps (and on POSIX unlinks) +/// the segment. +pub struct ShmWriter { + mapped: MappedMem, + len: usize, +} + +impl ShmWriter { + /// Create a new named SHM segment containing `data`. + /// + /// The segment is sized to [`DEFAULT_MAX_SIZE`] or `data.len()`, + /// whichever is larger. Use [`create_with_capacity`](Self::create_with_capacity) + /// for explicit control. + pub fn create(name: &str, data: &[u8]) -> anyhow::Result { + let cap = DEFAULT_MAX_SIZE.max(data.len()); + Self::create_with_capacity(name, data, cap) + } + + /// Create a new named SHM segment with an explicit capacity. + /// + /// `capacity` must be `>= data.len()`. + pub fn create_with_capacity( + name: &str, + data: &[u8], + capacity: usize, + ) -> anyhow::Result { + if data.len() > capacity { + anyhow::bail!( + "data length ({}) exceeds capacity ({})", + data.len(), + capacity + ); + } + + let cname = normalize_name(name)?; + let handle = NamedShmHandle::create(cname, capacity)?; + let mut mapped = handle.map()?; + + let buf = mapped.as_slice_mut(); + buf[..data.len()].copy_from_slice(data); + // Zero the remainder so readers can detect end-of-data + for byte in &mut buf[data.len()..] { + *byte = 0; + } + + Ok(Self { + mapped, + len: data.len(), + }) + } + + /// Overwrite the segment contents with new data. + /// + /// Fails if `data` is larger than the segment capacity. + pub fn update(&mut self, data: &[u8]) -> anyhow::Result<()> { + let cap = self.mapped.get_size(); + if data.len() > cap { + anyhow::bail!( + "data length ({}) exceeds segment capacity ({})", + data.len(), + cap + ); + } + + let buf = self.mapped.as_slice_mut(); + buf[..data.len()].copy_from_slice(data); + for byte in &mut buf[data.len()..] { + *byte = 0; + } + self.len = data.len(); + Ok(()) + } + + /// The number of live data bytes currently written. + pub fn len(&self) -> usize { + self.len + } + + /// Whether the segment is empty. + pub fn is_empty(&self) -> bool { + self.len == 0 + } + + /// The total capacity of the segment. + pub fn capacity(&self) -> usize { + self.mapped.get_size() + } +} + +// -- reader ----------------------------------------------------------------- + +/// A read-only view of a named shared memory segment. +/// +/// Dropping the reader unmaps the segment from this process, but the +/// segment itself remains alive as long as the writer holds it open. +pub struct ShmReader { + mapped: MappedMem, +} + +impl ShmReader { + /// Open an existing named SHM segment for reading. + /// + /// Returns `Ok(None)` if no segment with that name exists. + pub fn open(name: &str) -> anyhow::Result> { + let cname = normalize_name(name)?; + let handle = match NamedShmHandle::open(&cname) { + Ok(h) => h, + Err(e) if is_not_found_error(&e) => return Ok(None), + Err(e) => return Err(e.into()), + }; + let mapped = handle.map()?; + Ok(Some(Self { mapped })) + } + + /// The raw bytes of the entire mapped region. + /// + /// The region may contain trailing zero bytes beyond the actual + /// payload. Use [`data_bytes`](Self::data_bytes) if you want + /// only the non-zero prefix. + pub fn as_bytes(&self) -> &[u8] { + self.mapped.as_slice() + } + + /// The non-zero prefix of the mapped region. + /// + /// Assumes the writer zero-filled the remainder after the payload. + pub fn data_bytes(&self) -> &[u8] { + let slice = self.mapped.as_slice(); + let end = slice.iter().position(|&b| b == 0).unwrap_or(slice.len()); + &slice[..end] + } + + /// Total mapped size. + pub fn mapped_size(&self) -> usize { + self.mapped.get_size() + } +} + +// -- convenience: PID-keyed helpers ----------------------------------------- + +/// Create a SHM segment keyed by a PID with a well-known prefix. +/// +/// The segment name will be `/dd--`. +pub fn create_pid_keyed(prefix: &str, pid: u32, data: &[u8]) -> anyhow::Result { + let name = format!("/dd-{prefix}-{pid}"); + ShmWriter::create(&name, data) +} + +/// Open a SHM segment keyed by a PID. +pub fn open_pid_keyed(prefix: &str, pid: u32) -> anyhow::Result> { + let name = format!("/dd-{prefix}-{pid}"); + ShmReader::open(&name) +} + +/// Return the current process ID. +pub fn current_pid() -> u32 { + std::process::id() +} + +/// Return the parent process ID. +#[cfg(unix)] +pub fn parent_pid() -> u32 { + unsafe { libc::getppid() as u32 } +} + +/// Return the parent process ID. +#[cfg(windows)] +pub fn parent_pid() -> u32 { + use winapi::um::processthreadsapi::GetCurrentProcessId; + use winapi::um::tlhelp32::{ + CreateToolhelp32Snapshot, Process32First, Process32Next, PROCESSENTRY32, TH32CS_SNAPPROCESS, + }; + unsafe { + let our_pid = GetCurrentProcessId(); + let snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if snap == winapi::um::handleapi::INVALID_HANDLE_VALUE { + return 0; + } + let mut entry: PROCESSENTRY32 = std::mem::zeroed(); + entry.dwSize = std::mem::size_of::() as u32; + if Process32First(snap, &mut entry) != 0 { + loop { + if entry.th32ProcessID == our_pid { + winapi::um::handleapi::CloseHandle(snap); + return entry.th32ParentProcessID; + } + if Process32Next(snap, &mut entry) == 0 { + break; + } + } + } + winapi::um::handleapi::CloseHandle(snap); + 0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[cfg_attr(miri, ignore)] + fn writer_reader_roundtrip() { + let data = b"hello shared memory"; + let _writer = + ShmWriter::create("dd-test-roundtrip-1", data).expect("create"); + + let reader = + ShmReader::open("dd-test-roundtrip-1").expect("open").expect("should exist"); + assert_eq!(reader.data_bytes(), data); + } + + #[test] + #[cfg_attr(miri, ignore)] + fn writer_update() { + let _writer = ShmWriter::create("dd-test-update-1", b"first") + .expect("create"); + + // Re-bind as mutable + let mut writer = _writer; + writer.update(b"second-value").expect("update"); + + let reader = + ShmReader::open("dd-test-update-1").expect("open").expect("should exist"); + assert_eq!(reader.data_bytes(), b"second-value"); + } + + #[test] + #[cfg_attr(miri, ignore)] + fn open_nonexistent_returns_none() { + let result = ShmReader::open("dd-test-nonexistent-9999999") + .expect("should not error"); + assert!(result.is_none()); + } + + #[test] + #[cfg_attr(miri, ignore)] + fn pid_keyed_roundtrip() { + let fake_pid = 9_800_001u32; + let data = b"{\"session_id\":\"abc-123\"}"; + + let _writer = create_pid_keyed("session", fake_pid, data) + .expect("create pid-keyed"); + + let reader = open_pid_keyed("session", fake_pid) + .expect("open pid-keyed") + .expect("should exist"); + assert_eq!(reader.data_bytes(), data.as_slice()); + } + + #[test] + #[cfg_attr(miri, ignore)] + fn custom_capacity() { + let data = b"small"; + let writer = ShmWriter::create_with_capacity("dd-test-cap-1", data, 128) + .expect("create"); + assert!(writer.capacity() >= 128); + assert_eq!(writer.len(), 5); + } + + #[test] + fn data_exceeding_capacity_fails() { + let big = vec![0xFFu8; 200]; + let result = ShmWriter::create_with_capacity("dd-test-toobig", &big, 100); + assert!(result.is_err()); + } + + #[test] + #[cfg_attr(miri, ignore)] + fn empty_data_roundtrip() { + let _writer = + ShmWriter::create("dd-test-empty-1", b"").expect("create"); + + let reader = + ShmReader::open("dd-test-empty-1").expect("open").expect("should exist"); + assert!(reader.data_bytes().is_empty()); + } +}