diff --git a/Cargo.lock b/Cargo.lock index ea20e4c7ce..5f5787fa0f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2878,6 +2878,7 @@ dependencies = [ name = "libdd-profiling-ffi" version = "1.0.0" dependencies = [ + "allocator-api2", "anyhow", "build_common", "datadog-ffe-ffi", diff --git a/libdd-profiling-ffi/Cargo.toml b/libdd-profiling-ffi/Cargo.toml index 41e01d1631..e63daeae4d 100644 --- a/libdd-profiling-ffi/Cargo.toml +++ b/libdd-profiling-ffi/Cargo.toml @@ -38,6 +38,7 @@ datadog-ffe-ffi = ["dep:datadog-ffe-ffi"] build_common = { path = "../build-common" } [dependencies] +allocator-api2 = { version = "0.2.21", default-features = false, features = ["alloc"] } anyhow = "1.0" libdd-data-pipeline-ffi = { path = "../libdd-data-pipeline-ffi", default-features = false, optional = true } libdd-crashtracker-ffi = { path = "../libdd-crashtracker-ffi", default-features = false, optional = true} diff --git a/libdd-profiling-ffi/src/arc_handle.rs b/libdd-profiling-ffi/src/arc_handle.rs new file mode 100644 index 0000000000..44bf718685 --- /dev/null +++ b/libdd-profiling-ffi/src/arc_handle.rs @@ -0,0 +1,78 @@ +// Copyright 2025-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +use crate::profile_error::ProfileError; +use crate::EmptyHandleError; +use libdd_profiling::profiles::collections::Arc; +use std::ptr::{null_mut, NonNull}; + +/// Opaque FFI handle to an `Arc`'s inner `T`. +/// +/// Safety rules for implementors/callers: +/// - Do not create multiple owning `Arc`s from the same raw pointer. +/// - Always restore the original `Arc` with `into_raw` after any `from_raw`. +/// - Use `as_inner()` to validate non-null before performing raw round-trips. +/// +/// From Rust, use [`ArcHandle::try_clone`] to make a reference-counted copy. +/// From the C FFI, the handle should probably be renamed to avoid generics +/// bloat garbage, and a *_try_clone API should be provided. +/// +/// Use [`ArcHandle::drop_resource`] to drop the resource and move this handle +/// into the empty handle state, which is the default state. +#[repr(transparent)] +#[derive(Debug)] +pub struct ArcHandle(*mut T); + +impl Default for ArcHandle { + fn default() -> Self { + Self(null_mut()) + } +} + +impl ArcHandle { + /// Constructs a new handle by allocating an `ArcHandle` and returning + /// its inner pointer as a handle. + /// + /// Returns OutOfMemory on allocation failure. + pub fn new(value: T) -> Result { + let arc = Arc::try_new(value)?; + let ptr = Arc::into_raw(arc).as_ptr(); + Ok(Self(ptr)) + } + + pub fn try_clone_into_arc(&self) -> Result, ProfileError> { + let clone = self.try_clone()?; + // SAFETY: try_clone succeeded so it must not be null. + let nn = unsafe { NonNull::new_unchecked(clone.0) }; + // SAFETY: validated that it isn't null, should otherwise be an Arc. + Ok(unsafe { Arc::from_raw(nn) }) + } + + #[inline] + pub fn as_inner(&self) -> Result<&T, EmptyHandleError> { + unsafe { self.0.as_ref() }.ok_or(EmptyHandleError) + } + + /// Tries to clone the resource this handle points to, and returns a new + /// handle to it. + pub fn try_clone(&self) -> Result { + let nn = NonNull::new(self.0).ok_or(EmptyHandleError)?; + // SAFETY: ArcHandle uses a pointer to T as its repr, and as long as + // callers have upheld safety requirements elsewhere, including the + // FFI, then there will be a valid object with refcount > 0. + unsafe { Arc::try_increment_count(nn.as_ptr())? }; + Ok(Self(self.0)) + } + + /// Drops the resource that this handle refers to. It will remain alive if + /// there are other handles to the resource which were created by + /// successful calls to try_clone. This handle will now be empty and + /// operations on it will fail. + pub fn drop_resource(&mut self) { + // pointers aren't default until Rust 1.88. + let ptr = core::mem::replace(&mut self.0, null_mut()); + if let Some(nn) = NonNull::new(ptr) { + drop(unsafe { Arc::from_raw(nn) }); + } + } +} diff --git a/libdd-profiling-ffi/src/lib.rs b/libdd-profiling-ffi/src/lib.rs index 067de9dbae..aac70dab2c 100644 --- a/libdd-profiling-ffi/src/lib.rs +++ b/libdd-profiling-ffi/src/lib.rs @@ -7,10 +7,17 @@ #![cfg_attr(not(test), deny(clippy::todo))] #![cfg_attr(not(test), deny(clippy::unimplemented))] +mod arc_handle; mod exporter; +mod profile_error; +mod profile_status; mod profiles; mod string_storage; +pub use arc_handle::*; +pub use profile_error::*; +pub use profile_status::*; + #[cfg(all(feature = "symbolizer", not(target_os = "windows")))] pub use symbolizer_ffi::*; diff --git a/libdd-profiling-ffi/src/profile_error.rs b/libdd-profiling-ffi/src/profile_error.rs new file mode 100644 index 0000000000..fd4ea417ce --- /dev/null +++ b/libdd-profiling-ffi/src/profile_error.rs @@ -0,0 +1,157 @@ +// Copyright 2025-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +use crate::profile_status::{string_try_shrink_to_fit, ProfileStatus}; +use libdd_common::error::FfiSafeErrorMessage; +use libdd_common_ffi::slice::SliceConversionError; +use libdd_profiling::profiles::collections::{ArcOverflow, SetError}; +use libdd_profiling::profiles::FallibleStringWriter; +use std::borrow::Cow; +use std::ffi::{CStr, CString}; +use std::fmt; +use std::io::ErrorKind; + +/// Represents errors which can occur in the profiling FFI. Its main purpose +/// is to hold a more Rust-friendly version of [`ProfileStatus`]. +#[derive(Debug)] +pub enum ProfileError { + AllocError, + CapacityOverflow, + ReferenceCountOverflow, + + Other(Cow<'static, CStr>), +} + +/// Represents an error that means the handle is empty, meaning it doesn't +/// point to a resource. +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub struct EmptyHandleError; + +impl From<&'static CStr> for ProfileError { + fn from(s: &'static CStr) -> ProfileError { + Self::Other(Cow::Borrowed(s)) + } +} + +impl From for ProfileError { + fn from(s: CString) -> ProfileError { + Self::Other(Cow::Owned(s)) + } +} + +impl From for Cow<'static, CStr> { + fn from(err: ProfileError) -> Cow<'static, CStr> { + match err { + ProfileError::AllocError => Cow::Borrowed(c"memory allocation failed because the memory allocator returned an error"), + ProfileError::CapacityOverflow => Cow::Borrowed(c"memory allocation failed because the computed capacity exceeded the collection's maximum"), + ProfileError::ReferenceCountOverflow => Cow::Borrowed(c"reference count overflow"), + ProfileError::Other(msg) => msg, + } + } +} + +impl From for ProfileStatus { + fn from(err: ProfileError) -> ProfileStatus { + let cow = >::from(err); + match cow { + Cow::Borrowed(borrowed) => ProfileStatus::from(borrowed), + Cow::Owned(owned) => ProfileStatus::from(owned), + } + } +} + +impl From for ProfileError { + fn from(_: ArcOverflow) -> ProfileError { + ProfileError::ReferenceCountOverflow + } +} + +impl From for ProfileError { + fn from(err: allocator_api2::collections::TryReserveError) -> ProfileError { + match err.kind() { + allocator_api2::collections::TryReserveErrorKind::CapacityOverflow => { + ProfileError::CapacityOverflow + } + allocator_api2::collections::TryReserveErrorKind::AllocError { .. } => { + ProfileError::AllocError + } + } + } +} + +impl From for ProfileError { + fn from(_: allocator_api2::alloc::AllocError) -> ProfileError { + ProfileError::AllocError + } +} + +impl From for ProfileError { + fn from(_: std::collections::TryReserveError) -> ProfileError { + // We just assume it's out of memory since kind isn't stable. + ProfileError::AllocError + } +} + +impl From for ProfileError { + fn from(err: SetError) -> ProfileError { + ProfileError::Other(Cow::Borrowed(err.as_ffi_str())) + } +} + +impl From for ProfileError { + fn from(err: EmptyHandleError) -> ProfileError { + ProfileError::from(err.as_ffi_str()) + } +} + +impl From for ProfileError { + fn from(err: SliceConversionError) -> ProfileError { + ProfileError::from(err.as_ffi_str()) + } +} + +/// # Safety +/// +/// Uses c-str literal to ensure valid UTF-8 and null termination. +unsafe impl FfiSafeErrorMessage for EmptyHandleError { + fn as_ffi_str(&self) -> &'static CStr { + c"handle used with an interior null pointer" + } +} + +impl fmt::Display for EmptyHandleError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.as_rust_str().fmt(f) + } +} + +impl core::error::Error for EmptyHandleError {} + +impl From for ProfileError { + fn from(err: std::io::Error) -> ProfileError { + match err.kind() { + ErrorKind::StorageFull => ProfileError::CapacityOverflow, + ErrorKind::WriteZero | ErrorKind::OutOfMemory => ProfileError::AllocError, + e => { + let mut writer = FallibleStringWriter::new(); + use core::fmt::Write; + // Add null terminator that from_vec_with_nul expects. + if write!(&mut writer, "{e}\0").is_ok() { + return ProfileError::Other(Cow::Borrowed( + c"memory allocation failed while trying to create an error message", + )); + } + let mut string = String::from(writer); + // We do this to avoid the potential panic case of failed + // allocation in CString::from_vec_with_nul. + if string_try_shrink_to_fit(&mut string).is_err() { + return ProfileError::Other(Cow::Borrowed(c"memory allocation failed while trying to shrink a vec to create an error message")); + } + match CString::from_vec_with_nul(string.into_bytes()) { + Ok(cstring) => ProfileError::Other(Cow::Owned(cstring)), + Err(_) => ProfileError::Other(Cow::Borrowed(c"encountered an interior null byte while converting a std::io::Error into a ProfileError")) + } + } + } + } +} diff --git a/libdd-profiling-ffi/src/profile_status.rs b/libdd-profiling-ffi/src/profile_status.rs new file mode 100644 index 0000000000..aff2deb3db --- /dev/null +++ b/libdd-profiling-ffi/src/profile_status.rs @@ -0,0 +1,476 @@ +// Copyright 2025-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +use allocator_api2::alloc::{AllocError, Allocator, Global, Layout}; +use libdd_profiling::profiles::FallibleStringWriter; +use std::borrow::Cow; +use std::ffi::{c_char, CStr, CString}; +use std::fmt::Display; +use std::hint::unreachable_unchecked; +use std::mem::ManuallyDrop; +use std::ptr::{null, NonNull}; + +const FLAG_OK: usize = 0b00; +const FLAG_STATIC: usize = 0b01; +const FLAG_ALLOCATED: usize = 0b11; + +const MASK_IS_ERROR: usize = 0b01; +const MASK_IS_ALLOCATED: usize = 0b10; +const MASK_UNUSED: usize = !(MASK_IS_ERROR | MASK_IS_ALLOCATED); + +/// Represents the result of an operation that either succeeds with no value, +/// or fails with an error message. This is like `Result<(), Cow` except +/// its representation is smaller, and is FFI-stable. +/// +/// The OK status is guaranteed to have a representation of `{ 0, null }`. +#[repr(C)] +#[derive(Debug)] +pub struct ProfileStatus { + /// 0 means okay, everything else is opaque in C. + /// In Rust, the bits help us know whether it is heap allocated or not. + pub flags: libc::size_t, + /// If not null, this is a pointer to a valid null-terminated string in + /// UTF-8 encoding. + /// This is null if `flags` == 0. + pub err: *const c_char, +} + +impl Default for ProfileStatus { + fn default() -> Self { + Self { + flags: 0, + err: null(), + } + } +} + +unsafe impl Send for ProfileStatus {} +unsafe impl Sync for ProfileStatus {} + +impl From> for ProfileStatus +where + ProfileStatus: From, +{ + fn from(result: Result<(), E>) -> Self { + match result { + Ok(_) => ProfileStatus::OK, + Err(err) => ProfileStatus::from(err), + } + } +} + +impl From for ProfileStatus { + fn from(err: anyhow::Error) -> ProfileStatus { + ProfileStatus::from_error(err) + } +} + +impl From<&'static CStr> for ProfileStatus { + fn from(value: &'static CStr) -> Self { + Self { + flags: FLAG_STATIC, + err: value.as_ptr(), + } + } +} + +impl From for ProfileStatus { + fn from(cstring: CString) -> Self { + Self { + flags: FLAG_ALLOCATED, + err: cstring.into_raw(), + } + } +} + +impl TryFrom for CString { + type Error = usize; + + fn try_from(status: ProfileStatus) -> Result { + if status.flags == FLAG_ALLOCATED { + Ok(unsafe { CString::from_raw(status.err.cast_mut()) }) + } else { + Err(status.flags) + } + } +} + +impl TryFrom<&ProfileStatus> for &CStr { + type Error = usize; + + fn try_from(status: &ProfileStatus) -> Result { + if status.flags != FLAG_OK { + Ok(unsafe { CStr::from_ptr(status.err.cast_mut()) }) + } else { + Err(status.flags) + } + } +} + +impl From for Result<(), Cow<'static, CStr>> { + fn from(status: ProfileStatus) -> Self { + let flags = status.flags; + let is_error = (flags & MASK_IS_ERROR) != 0; + let is_allocated = (flags & MASK_IS_ALLOCATED) != 0; + #[allow(clippy::panic)] + if cfg!(debug_assertions) && (status.flags & MASK_UNUSED) != 0 { + panic!("invalid bit pattern: {flags:b}"); + } + match (is_allocated, is_error) { + (false, false) => Ok(()), + (false, true) => Err(Cow::Borrowed(unsafe { CStr::from_ptr(status.err) })), + (true, true) => Err(Cow::Owned(unsafe { + CString::from_raw(status.err.cast_mut()) + })), + (true, false) => { + #[allow(clippy::panic)] + if cfg!(debug_assertions) { + panic!("invalid bit pattern: {flags:b}"); + } + unsafe { unreachable_unchecked() } + } + } + } +} + +impl From<()> for ProfileStatus { + fn from(_: ()) -> Self { + Self::OK + } +} + +/// Tries to shrink a vec to exactly fit its length. +/// On success, the vector's capacity equals its length. +/// Returns an allocation error if the allocator cannot shrink. +fn vec_try_shrink_to_fit(vec: &mut Vec) -> Result<(), AllocError> { + let len = vec.len(); + if vec.capacity() == len || core::mem::size_of::() == 0 { + return Ok(()); + } + + // Take ownership temporarily to manipulate raw parts; put an empty vec + // in its place. + let mut md = ManuallyDrop::new(core::mem::take(vec)); + + // Avoid len=0 case for allocators by dropping the allocation and replacing + // it with a new empty vec. + if len == 0 { + // SAFETY: we have exclusive access, and we're not exposing the zombie + // bits to safe code since we're just returning (original vec was + // replaced by an empty vec). + unsafe { ManuallyDrop::drop(&mut md) }; + return Ok(()); + } + + let ptr = md.as_mut_ptr(); + let cap = md.capacity(); + + // SAFETY: Vec invariants ensure `cap >= len`, and capacity/len fit isize. + let old_layout = unsafe { Layout::array::(cap).unwrap_unchecked() }; + let new_layout = unsafe { Layout::array::(len).unwrap_unchecked() }; + + // SAFETY: `ptr` is non-null and properly aligned for T (Vec invariant). + let old_ptr_u8 = unsafe { NonNull::new_unchecked(ptr.cast::()) }; + + match unsafe { Global.shrink(old_ptr_u8, old_layout, new_layout) } { + Ok(new_ptr_u8) => { + let new_ptr = new_ptr_u8.as_ptr().cast::(); + // SAFETY: new allocation valid for len Ts; capacity == len. + let new_vec = unsafe { Vec::from_raw_parts(new_ptr, len, len) }; + *vec = new_vec; + Ok(()) + } + Err(_) => { + // Reconstruct original and put it back; report OOM. + let orig = unsafe { Vec::from_raw_parts(ptr, len, cap) }; + *vec = orig; + Err(AllocError) + } + } +} + +pub(crate) fn string_try_shrink_to_fit(string: &mut String) -> Result<(), AllocError> { + // Take ownership to get access to the backing Vec. + let mut bytes = core::mem::take(string).into_bytes(); + let res = vec_try_shrink_to_fit(&mut bytes); + // SAFETY: bytes came from a valid UTF-8 String and were not mutated. + *string = unsafe { String::from_utf8_unchecked(bytes) }; + res +} + +impl ProfileStatus { + pub const OK: ProfileStatus = ProfileStatus { + flags: FLAG_OK, + err: null(), + }; + + const OUT_OF_MEMORY: ProfileStatus = ProfileStatus { + flags: FLAG_STATIC, + err: c"out of memory while trying to display error".as_ptr(), + }; + const NULL_BYTE_IN_ERROR_MESSAGE: ProfileStatus = ProfileStatus { + flags: FLAG_STATIC, + err: c"another error occured, but cannot be displayed because it has interior null bytes" + .as_ptr(), + }; + + pub fn from_ffi_safe_error_message( + err: E, + ) -> Self { + ProfileStatus::from(err.as_ffi_str()) + } + + pub fn from_error(err: E) -> Self { + use core::fmt::Write; + let mut writer = FallibleStringWriter::new(); + if write!(writer, "{err}").is_err() { + return ProfileStatus::OUT_OF_MEMORY; + } + + let mut str = String::from(writer); + + // std doesn't expose memchr even though it has it, but fortunately + // libc has it, and we use the libc crate already in FFI. + let pos = unsafe { libc::memchr(str.as_ptr().cast(), 0, str.len()) }; + if !pos.is_null() { + return ProfileStatus::NULL_BYTE_IN_ERROR_MESSAGE; + } + + // Reserve memory exactly. We have to shrink later in order to turn + // it into a box, so we don't want any excess capacity. + if str.try_reserve_exact(1).is_err() { + return ProfileStatus::OUT_OF_MEMORY; + } + str.push('\0'); + + if string_try_shrink_to_fit(&mut str).is_err() { + return ProfileStatus::OUT_OF_MEMORY; + } + + // Pop the null off because CString::from_vec_unchecked adds one. + _ = str.pop(); + + // And finally, this is why we went through the pain of + // string_try_shrink_to_fit: this method will call shrink_to_fit, so + // to avoid an allocation failure here, we had to make a String with + // no excess capacity. + let cstring = unsafe { CString::from_vec_unchecked(str.into_bytes()) }; + ProfileStatus::from(cstring) + } +} + +/// Frees any error associated with the status, and replaces it with an OK. +/// +/// # Safety +/// +/// The pointer should point at a valid Status object, if it's not null. +#[no_mangle] +pub unsafe extern "C" fn ddog_prof_Status_drop(status: *mut ProfileStatus) { + if status.is_null() { + return; + } + // SAFETY: safe when the user respects ddog_prof_Status_drop's conditions. + let status = unsafe { core::ptr::replace(status, ProfileStatus::OK) }; + drop(Result::from(status)); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::ffi::CStr; + + #[test] + fn test_ok_status() { + let status = ProfileStatus::OK; + assert_eq!(status.flags, 0); + assert!(status.err.is_null()); + + // Default should be OK + let default_status = ProfileStatus::default(); + assert_eq!(default_status.flags, 0); + assert!(default_status.err.is_null()); + + // From () should be OK + let from_unit = ProfileStatus::from(()); + assert_eq!(from_unit.flags, 0); + assert!(from_unit.err.is_null()); + + // Convert OK to Result + let result: Result<(), Cow<'static, CStr>> = status.into(); + assert!(result.is_ok()); + } + + #[test] + fn test_static_error() { + let msg = c"test error message"; + let status = ProfileStatus::from(msg); + + assert_eq!(status.flags, FLAG_STATIC); + assert_eq!(status.err, msg.as_ptr()); + + // Convert to CStr + let cstr: &CStr = (&status).try_into().unwrap(); + assert_eq!(cstr, msg); + + // Convert to Result + let result: Result<(), Cow<'static, CStr>> = status.into(); + assert!(result.is_err()); + match result { + Err(Cow::Borrowed(borrowed)) => assert_eq!(borrowed, msg), + _ => panic!("Expected Cow::Borrowed"), + } + } + + #[test] + fn test_allocated_error() { + let msg = CString::new("allocated error").unwrap(); + let msg_clone = msg.clone(); + let status = ProfileStatus::from(msg); + + assert_eq!(status.flags, FLAG_ALLOCATED); + assert!(!status.err.is_null()); + + // Convert to CStr + let cstr: &CStr = (&status).try_into().unwrap(); + assert_eq!(cstr, msg_clone.as_c_str()); + + // Convert to CString + let recovered = CString::try_from(status).unwrap(); + assert_eq!(recovered, msg_clone); + } + + #[test] + fn test_from_anyhow_error() { + let err = anyhow::anyhow!("something went wrong"); + let status = ProfileStatus::from(err); + + assert!(status.flags != 0); + assert!(!status.err.is_null()); + + let cstr: &CStr = (&status).try_into().unwrap(); + assert_eq!(cstr.to_str().unwrap(), "something went wrong"); + + // Clean up + let _result: Result<(), Cow<'static, CStr>> = status.into(); + } + + #[test] + fn test_from_result_ok() { + let result: Result<(), anyhow::Error> = Ok(()); + let status = ProfileStatus::from(result); + + assert_eq!(status.flags, 0); + assert!(status.err.is_null()); + } + + #[test] + fn test_from_result_err() { + let result: Result<(), anyhow::Error> = Err(anyhow::anyhow!("error from result")); + let status = ProfileStatus::from(result); + + assert!(status.flags != 0); + assert!(!status.err.is_null()); + + let cstr: &CStr = (&status).try_into().unwrap(); + assert_eq!(cstr.to_str().unwrap(), "error from result"); + + // Clean up + let _result: Result<(), Cow<'static, CStr>> = status.into(); + } + + #[test] + fn test_from_error_with_display() { + #[derive(Debug)] + struct CustomError(&'static str); + + impl std::fmt::Display for CustomError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "custom: {}", self.0) + } + } + + let status = ProfileStatus::from_error(CustomError("test")); + + assert_eq!(status.flags, FLAG_ALLOCATED); + assert!(!status.err.is_null()); + + let cstr: &CStr = (&status).try_into().unwrap(); + assert_eq!(cstr.to_str().unwrap(), "custom: test"); + + // Clean up + let _result: Result<(), Cow<'static, CStr>> = status.into(); + } + + #[test] + fn test_ffi_drop_null() { + // Should not crash + unsafe { ddog_prof_Status_drop(std::ptr::null_mut()) }; + } + + #[test] + fn test_ffi_drop_ok() { + let mut status = ProfileStatus::OK; + unsafe { ddog_prof_Status_drop(&mut status) }; + assert_eq!(status.flags, 0); + assert!(status.err.is_null()); + } + + #[test] + fn test_ffi_drop_static() { + let mut status = ProfileStatus::from(c"static message"); + let original_ptr = status.err; + + unsafe { ddog_prof_Status_drop(&mut status) }; + + // Should be OK now + assert_eq!(status.flags, 0); + assert!(status.err.is_null()); + + // Original pointer should still be valid (static) + let recovered = unsafe { CStr::from_ptr(original_ptr) }; + assert_eq!(recovered, c"static message"); + } + + #[test] + fn test_ffi_drop_allocated() { + let msg = CString::new("allocated message").unwrap(); + let mut status = ProfileStatus::from(msg); + + assert_eq!(status.flags, FLAG_ALLOCATED); + let err_ptr = status.err; + assert!(!err_ptr.is_null()); + + unsafe { ddog_prof_Status_drop(&mut status) }; + + // Should be OK now + assert_eq!(status.flags, 0); + assert!(status.err.is_null()); + // The allocated memory should have been freed (can't really test this without valgrind) + } + + #[test] + fn test_try_from_cstr_on_ok_fails() { + let status = ProfileStatus::OK; + let result: Result<&CStr, usize> = (&status).try_into(); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), FLAG_OK); + } + + #[test] + fn test_try_from_cstring_on_static_fails() { + let status = ProfileStatus::from(c"static"); + let result = CString::try_from(status); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), FLAG_STATIC); + } + + #[test] + fn test_send_sync() { + // Just check that ProfileStatus implements Send and Sync + fn assert_send() {} + fn assert_sync() {} + + assert_send::(); + assert_sync::(); + } +} diff --git a/libdd-profiling-ffi/src/profiles/utf8.rs b/libdd-profiling-ffi/src/profiles/utf8.rs new file mode 100644 index 0000000000..045105bfef --- /dev/null +++ b/libdd-profiling-ffi/src/profiles/utf8.rs @@ -0,0 +1,147 @@ +// Copyright 2025-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +use crate::ProfileError; +use libdd_common::error::FfiSafeErrorMessage; +use libdd_common_ffi::slice::{AsBytes, CharSlice, SliceConversionError}; +use libdd_profiling::profiles::collections::{ParallelStringSet, StringRef}; +use std::borrow::Cow; +use std::collections::TryReserveError; +use std::ffi::CStr; +use std::str::Utf8Error; + +#[repr(C)] +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +#[allow(dead_code)] // these are made through ffi +pub enum Utf8Option { + /// The string is assumed to be valid UTF-8. If it's not, the behavior + /// is undefined. + Assume, + /// The string is converted to UTF-8 using lossy conversion. + ConvertLossy, + /// The string is validated to be UTF-8. If it's not, an error is + /// returned. + Validate, +} + +#[allow(dead_code)] +pub enum Utf8ConversionError { + OutOfMemory(TryReserveError), + SliceConversionError(SliceConversionError), + Utf8Error(Utf8Error), +} + +impl From for Utf8ConversionError { + fn from(e: TryReserveError) -> Self { + Self::OutOfMemory(e) + } +} + +impl From for Utf8ConversionError { + fn from(e: SliceConversionError) -> Self { + Self::SliceConversionError(e) + } +} + +impl From for Utf8ConversionError { + fn from(e: Utf8Error) -> Self { + Self::Utf8Error(e) + } +} + +// SAFETY: all cases are c-str literals, or delegate to the same trait. +unsafe impl FfiSafeErrorMessage for Utf8ConversionError { + fn as_ffi_str(&self) -> &'static CStr { + match self { + Utf8ConversionError::OutOfMemory(_) => c"out of memory: utf8 conversion failed", + Utf8ConversionError::SliceConversionError(err) => err.as_ffi_str(), + Utf8ConversionError::Utf8Error(_) => c"invalid input: string was not utf-8", + } + } +} + +impl Utf8Option { + /// Converts a byte slice to a UTF-8 string according to the option. + /// - Assume: Borrow without validation (caller guarantees UTF-8) + /// - ConvertLossy: Lossy conversion with fallible allocation + /// - Validate: Validate and borrow on success + /// + /// # Safety + /// + /// When [`Utf8Option::Assume`] is passed, it must be valid UTF-8. + pub unsafe fn convert(self, bytes: &[u8]) -> Result, Utf8ConversionError> { + // SAFETY: caller asserts validity under Assume + Ok(match self { + Utf8Option::Assume => Cow::Borrowed(unsafe { std::str::from_utf8_unchecked(bytes) }), + Utf8Option::ConvertLossy => try_from_utf8_lossy(bytes)?, + Utf8Option::Validate => Cow::Borrowed(std::str::from_utf8(bytes)?), + }) + } + + /// # Safety + /// See the safety conditions on [`AsBytes::try_as_bytes`] and also + /// [`Utf8Option::convert`]; both must be upheld. + pub unsafe fn try_as_bytes_convert<'a, T: AsBytes<'a>>( + self, + t: T, + ) -> Result, Utf8ConversionError> { + let bytes = t.try_as_bytes()?; + self.convert(bytes) + } +} + +/// Tries to convert a slice of bytes to a string. The input may have invalid +/// characters. +/// +/// This is the same implementation as [`String::from_utf8_lossy`] except that +/// this uses fallible allocations. +pub fn try_from_utf8_lossy(v: &[u8]) -> Result, TryReserveError> { + let mut iter = v.utf8_chunks(); + + let first_valid = if let Some(chunk) = iter.next() { + let valid = chunk.valid(); + if chunk.invalid().is_empty() { + debug_assert_eq!(valid.len(), v.len()); + return Ok(Cow::Borrowed(valid)); + } + valid + } else { + return Ok(Cow::Borrowed("")); + }; + + const REPLACEMENT: &str = "\u{FFFD}"; + const REPLACEMENT_LEN: usize = REPLACEMENT.len(); + + let mut res = String::new(); + res.try_reserve(v.len())?; + res.push_str(first_valid); + res.try_reserve(REPLACEMENT_LEN)?; + res.push_str(REPLACEMENT); + + for chunk in iter { + let valid = chunk.valid(); + res.try_reserve(valid.len())?; + res.push_str(valid); + if !chunk.invalid().is_empty() { + res.try_reserve(REPLACEMENT_LEN)?; + res.push_str(REPLACEMENT); + } + } + + Ok(Cow::Owned(res)) +} + +pub fn insert_str( + set: &ParallelStringSet, + str: CharSlice<'_>, + utf8_options: Utf8Option, +) -> Result { + let string = unsafe { utf8_options.try_as_bytes_convert(str) }.map_err(|err| match err { + Utf8ConversionError::OutOfMemory(err) => ProfileError::from(err), + Utf8ConversionError::SliceConversionError(err) => ProfileError::from(err.as_ffi_str()), + Utf8ConversionError::Utf8Error(_) => { + ProfileError::from(c"tried to insert a non-UTF8 string into a ProfilesDictionary") + } + })?; + Ok(set.try_insert(string.as_ref())?) +} diff --git a/libdd-profiling/src/profiles/collections/error.rs b/libdd-profiling/src/profiles/collections/error.rs index 57d3da1bef..34ca01fac9 100644 --- a/libdd-profiling/src/profiles/collections/error.rs +++ b/libdd-profiling/src/profiles/collections/error.rs @@ -1,14 +1,15 @@ // Copyright 2025-Present Datadog, Inc. https://www.datadoghq.com/ // SPDX-License-Identifier: Apache-2.0 +use libdd_common::error::FfiSafeErrorMessage; +use std::ffi::CStr; +use std::fmt::{Display, Formatter}; + #[repr(C)] -#[derive(Debug, thiserror::Error)] +#[derive(Debug)] pub enum SetError { - #[error("set error: invalid argument")] InvalidArgument, - #[error("set error: out of memory")] OutOfMemory, - #[error("set error: reference count overflow")] ReferenceCountOverflow, } @@ -29,3 +30,30 @@ impl From for SetError { SetError::OutOfMemory } } + +unsafe impl FfiSafeErrorMessage for SetError { + fn as_ffi_str(&self) -> &'static CStr { + match self { + SetError::InvalidArgument => c"set error: invalid argument", + SetError::OutOfMemory => c"set error: out of memory", + SetError::ReferenceCountOverflow => c"set error: reference count overflow", + } + } + + fn as_rust_str(&self) -> &'static str { + // todo: MSRV 1.87: use str::from_utf8_unchecked + match self { + SetError::InvalidArgument => "set error: invalid argument", + SetError::OutOfMemory => "set error: out of memory", + SetError::ReferenceCountOverflow => "set error: reference count overflow", + } + } +} + +impl Display for SetError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + self.as_rust_str().fmt(f) + } +} + +impl core::error::Error for SetError {} diff --git a/libdd-profiling/src/profiles/fallible_string_writer.rs b/libdd-profiling/src/profiles/fallible_string_writer.rs new file mode 100644 index 0000000000..c1e5181199 --- /dev/null +++ b/libdd-profiling/src/profiles/fallible_string_writer.rs @@ -0,0 +1,187 @@ +// Copyright 2025-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +use core::fmt::{self, Write}; +use std::collections::TryReserveError; + +/// A `fmt::Write` adapter that grows a `String` using `try_reserve` before +/// each write, returning `fmt::Error` on allocation failure. +#[derive(Debug)] +pub struct FallibleStringWriter { + buf: String, +} + +impl Default for FallibleStringWriter { + fn default() -> FallibleStringWriter { + FallibleStringWriter::new() + } +} + +impl FallibleStringWriter { + /// Creates a new empty string writer. + pub const fn new() -> Self { + Self { buf: String::new() } + } + + /// Creates a new fallible string writer with a previously existing string + /// as the start of the buffer. New writes will append to the end of this. + pub const fn new_from_existing(buf: String) -> FallibleStringWriter { + FallibleStringWriter { buf } + } + + /// Tries to reserve capacity for at least additional bytes more than the + /// current length. The allocator may reserve more space to speculatively + /// avoid frequent allocations. + pub fn try_reserve(&mut self, len: usize) -> Result<(), TryReserveError> { + self.buf.try_reserve(len) + } + + /// Tries to reserve the minimum capacity for at least `additional` bytes + /// more than the current length. Unlike [`try_reserve`], this will not + /// deliberately over-allocate to speculatively avoid frequent allocations. + /// + /// Note that the allocator may give the collection more space than it + /// requests. Therefore, capacity can not be relied upon to be precisely + /// minimal. Prefer [`try_reserve`] if future insertions are expected. + pub fn try_reserve_exact(&mut self, len: usize) -> Result<(), TryReserveError> { + self.buf.try_reserve_exact(len) + } + + pub fn try_push_str(&mut self, str: &str) -> Result<(), TryReserveError> { + self.try_reserve(str.len())?; + self.buf.push_str(str); + Ok(()) + } +} + +impl From for String { + fn from(w: FallibleStringWriter) -> String { + w.buf + } +} + +impl From for FallibleStringWriter { + fn from(buf: String) -> FallibleStringWriter { + FallibleStringWriter { buf } + } +} + +impl Write for FallibleStringWriter { + fn write_str(&mut self, s: &str) -> fmt::Result { + self.try_push_str(s).map_err(|_| fmt::Error) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fmt::Write; + + #[test] + fn test_new_and_default() { + let writer = FallibleStringWriter::new(); + let s: String = writer.into(); + assert_eq!(s, ""); + + let writer = FallibleStringWriter::default(); + let s: String = writer.into(); + assert_eq!(s, ""); + } + + #[test] + fn test_write_str() { + let mut writer = FallibleStringWriter::new(); + writer.write_str("Hello").unwrap(); + writer.write_str(", ").unwrap(); + writer.write_str("World!").unwrap(); + + let s: String = writer.into(); + assert_eq!(s, "Hello, World!"); + } + + #[test] + fn test_write_formatted() { + let mut writer = FallibleStringWriter::new(); + write!(writer, "x = {}, ", 10).unwrap(); + write!(writer, "y = {}, ", 20).unwrap(); + write!(writer, "sum = {}", 10 + 20).unwrap(); + + let s: String = writer.into(); + assert_eq!(s, "x = 10, y = 20, sum = 30"); + } + + #[test] + fn test_try_push_str() { + let mut writer = FallibleStringWriter::new(); + writer.try_push_str("Hello").unwrap(); + writer.try_push_str(" ").unwrap(); + writer.try_push_str("World").unwrap(); + + let s: String = writer.into(); + assert_eq!(s, "Hello World"); + } + + #[test] + fn test_try_reserve() { + // Marcus Aurelius, Meditations (public domain) + let strings = [ + "The happiness of your life depends upon the quality of your thoughts: ", + "therefore, guard accordingly, and take care that you entertain ", + "no notions unsuitable to virtue and reasonable nature.", + ]; + let total_len: usize = strings.iter().map(|s| s.len()).sum(); + + let mut writer = FallibleStringWriter::new(); + // Asking for more than is needed just to ensure that the test isn't + // accidentally correct. + let capacity = 2 * total_len + 7; + writer.try_reserve_exact(capacity).unwrap(); + + // After reserving, we should be able to write all strings (and more). + for s in &strings { + writer.write_str(s).unwrap(); + } + + let result: String = writer.into(); + assert_eq!(result, strings.join("")); + + // It can't be less, but an allocator is free to round, even on a + // try_reserve_exact. + assert!(result.capacity() >= capacity); + } + + #[test] + fn test_from_existing_string() { + // Test From, new_from_existing, and appending + let s = String::from("start: "); + let mut writer = FallibleStringWriter::from(s); + write!(writer, "{}", 123).unwrap(); + assert_eq!(String::from(writer), "start: 123"); + + // Test new_from_existing + let mut writer = FallibleStringWriter::new_from_existing(String::from("prefix-")); + writer.try_push_str("suffix").unwrap(); + assert_eq!(String::from(writer), "prefix-suffix"); + } + + #[test] + fn test_write_unicode() { + let mut writer = FallibleStringWriter::new(); + write!(writer, "Hello 👋 World 🌍").unwrap(); + + let s: String = writer.into(); + assert_eq!(s, "Hello 👋 World 🌍"); + } + + #[test] + fn test_write_long_string() { + let mut writer = FallibleStringWriter::new(); + let long_str = "a".repeat(1000); + + writer.write_str(&long_str).unwrap(); + + let s: String = writer.into(); + assert_eq!(s.len(), 1000); + assert_eq!(s, long_str); + } +} diff --git a/libdd-profiling/src/profiles/mod.rs b/libdd-profiling/src/profiles/mod.rs index 2ed0ebe8ea..8e74b22781 100644 --- a/libdd-profiling/src/profiles/mod.rs +++ b/libdd-profiling/src/profiles/mod.rs @@ -4,5 +4,7 @@ pub mod collections; mod compressor; pub mod datatypes; +mod fallible_string_writer; pub use compressor::*; +pub use fallible_string_writer::*;