From 4cb6ad7ce7f0670a533ee0acc7611536657b53d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gustavo=20Andr=C3=A9=20dos=20Santos=20Lopes?= Date: Tue, 16 Jun 2026 16:23:15 +0100 Subject: [PATCH 01/12] More robust way to inline thread local var resolution --- Cargo.lock | 2 - libdd-otel-thread-ctx-ffi/README.md | 56 +---------- libdd-otel-thread-ctx-ffi/build-optimized.sh | 69 -------------- libdd-otel-thread-ctx-ffi/build.rs | 97 +------------------- libdd-otel-thread-ctx/Cargo.toml | 4 - libdd-otel-thread-ctx/README.md | 8 +- libdd-otel-thread-ctx/build.rs | 49 ---------- libdd-otel-thread-ctx/src/lib.rs | 87 ++++++++++++++---- libdd-otel-thread-ctx/src/tls_shim.c | 20 ---- 9 files changed, 81 insertions(+), 311 deletions(-) delete mode 100755 libdd-otel-thread-ctx-ffi/build-optimized.sh delete mode 100644 libdd-otel-thread-ctx/src/tls_shim.c diff --git a/Cargo.lock b/Cargo.lock index 7b6e9fbe57..aea5544d46 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3164,8 +3164,6 @@ name = "libdd-otel-thread-ctx" version = "1.0.0" dependencies = [ "anyhow", - "build_common", - "cc", "elf", ] diff --git a/libdd-otel-thread-ctx-ffi/README.md b/libdd-otel-thread-ctx-ffi/README.md index ae268d5770..936e1e15fe 100644 --- a/libdd-otel-thread-ctx-ffi/README.md +++ b/libdd-otel-thread-ctx-ffi/README.md @@ -6,56 +6,8 @@ that external readers (e.g. the eBPF profiler) can discover. Currently Linux-only (x86-64 and aarch64). -## Optimized build (cross-language inlining) +## TLS -The OTel thread-level context sharing specification requires the use of the -TLSDESC dialect for the thread-local variable that holds the current context. -Because (stable) `rustc` doesn't currently provide a way to control the TLS -dialect, we need to use a small C shim that defines the variable and expose a -one-line getter. This unfortunately adds one level of indirection (a function -call) when attaching or detaching a context. - -With the right toolchain, it's possible to use Link-Time Optimization (LTO) to -inline the C wrapper at link time. The requirements are: - -- `clang` is available to compile the C shim to LLVM IR (version requirements - aren't clear -- tested with clang18 and clang20, but ideally the version - should be the same or close to the LLVM version shipped with `rustc`) -- Either the Rust toolchain ships `lld` or there's a system-wide `lld` install - (Rust has been shipping `rust-lld` for a long time now, something like since - 1.53+, however some musl-based distro like Alpine might have the Rust - toolchain without `rust-lld`) -- `lld` version is at least 18.1 (TLSDESC support) - -**If those requirements are met, setting the environment variables -`CARGO_TARGET__RUSTFLAGS=-Clinker-plugin-lto -Clinker=clang` and -`LIBDD_OTEL_THREAD_CTX_INLINE=1` when calling to `cargo` will trigger the -optimized build where the C shim is inlined.** Here, `` is the target -triple in screaming snake case. - -External environment variables are needed because cross-language LTO requires -two `rustc` codegen flags (`-Clinker-plugin-lto` and `-Clinker=clang`) that -cannot be set from a Cargo build script: they must come from `RUSTFLAGS` or -`.cargo/config.toml`, which can't be entirely automated from Rust only. We -advise to set those flags via the target-scoped -`CARGO_TARGET__RUSTFLAGS` env var so they don't leak to build scripts -or proc-macros if cross-compiling. - -### Build script - -The `build-optimized.sh` wrapper script is provided as a convenience and as an -example. - -#### Usage - -```bash -./build-optimized.sh -``` - -The script auto-detects the host triple. To cross-compile: - -```bash -./build-optimized.sh --target aarch64-unknown-linux-gnu -``` - -Extra arguments are forwarded to `cargo build`. +The thread-local variable `otel_thread_ctx_v1` and its TLSDESC accessor are +implemented in pure Rust using `global_asm!` and `asm!` in the +`libdd-otel-thread-ctx` crate. diff --git a/libdd-otel-thread-ctx-ffi/build-optimized.sh b/libdd-otel-thread-ctx-ffi/build-optimized.sh deleted file mode 100755 index b52fd657a1..0000000000 --- a/libdd-otel-thread-ctx-ffi/build-optimized.sh +++ /dev/null @@ -1,69 +0,0 @@ -#!/usr/bin/env bash -# Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ -# SPDX-License-Identifier: Apache-2.0 -# -# Build libdd-otel-thread-ctx-ffi with cross-language LTO so the C TLS shim is -# inlined into the Rust FFI functions, eliminating a function-call indirection -# on every TLS access. -# -# Requirements: clang, lld (rust-lld from the toolchain is used automatically). -# The requirements are checked by the build.rs script. -# -# Usage: -# # auto-detect host triple -# ./build-optimized.sh -# # explicit target -# ./build-optimized.sh --target aarch64-unknown-linux-gnu -# -# Any extra arguments are forwarded to `cargo build`. -set -euo pipefail - -# Parse --target from args, or auto-detect the host triple. -TARGET="" -EXTRA_ARGS=() -while [[ $# -gt 0 ]]; do - case "$1" in - --target) - TARGET="$2"; shift 2 ;; - --target=*) - TARGET="${1#--target=}"; shift ;; - *) - EXTRA_ARGS+=("$1"); shift ;; - esac -done - -if [[ -z "$TARGET" ]]; then - TARGET=$(rustc -vV | sed -n 's/host: //p') -fi - -# CARGO_TARGET__RUSTFLAGS scopes the flags to the target only, keeping -# build scripts and proc-macros unaffected. -TARGET_ENV=$(echo "$TARGET" | tr 'a-z-' 'A-Z_') -FLAGS_VAR="CARGO_TARGET_${TARGET_ENV}_RUSTFLAGS" -EXISTING_FLAGS="${!FLAGS_VAR:-}" -export "$FLAGS_VAR=${EXISTING_FLAGS:+$EXISTING_FLAGS }-Clinker-plugin-lto -Clinker=clang" -export LIBDD_OTEL_THREAD_CTX_INLINE=1 - -cargo build --release \ - --target "$TARGET" \ - -p libdd-otel-thread-ctx-ffi \ - "${EXTRA_ARGS[@]}" - -# Sanity-check that the C shim was actually inlined, if `nm` is available. -if ! command -v nm &>/dev/null; then - echo >&2 "WARNING: skipping sanity check that the C TLS shim was inlined (\`nm\` not found)" -else - SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" - SO="$REPO_ROOT/target/$TARGET/release/liblibdd_otel_thread_ctx_ffi.so" - - if [[ -f "$SO" ]]; then - if ! NM_OUTPUT=$(nm "$SO" 2>&1); then - echo >&2 "WARNING: command \`nm\` failed on $SO. Skipping sanity check that the C TLS shim was inlined." - elif echo "$NM_OUTPUT" | grep -q 'libdd_get_otel_thread_ctx'; then - echo >&2 "ERROR: build succeeded but the C TLS shim (libdd_get_otel_thread_ctx_v1) was NOT inlined." - echo >&2 "Cross-language LTO may not be working. Check that clang and lld versions are recent enough and compatible with the Rust toolchain's LLVM." - exit 1 - fi - fi -fi diff --git a/libdd-otel-thread-ctx-ffi/build.rs b/libdd-otel-thread-ctx-ffi/build.rs index 61fa63764b..cbac605286 100644 --- a/libdd-otel-thread-ctx-ffi/build.rs +++ b/libdd-otel-thread-ctx-ffi/build.rs @@ -3,70 +3,7 @@ extern crate build_common; use build_common::{find_rust_lld_dir, generate_and_configure_header}; -use std::{env, fmt::Display, path::PathBuf, process::Command}; - -#[derive(Debug, Eq, PartialEq, Ord, PartialOrd)] -struct LldVersion { - major: u32, - minor: u32, -} - -impl Display for LldVersion { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}.{}", self.major, self.minor) - } -} - -/// Parse the major and minor version from `ld.lld --version` output. -/// -/// Typical formats: -/// "LLD 18.1.3 (compatible with GNU linkers)" -/// "LLD 19.1.0" -fn system_lld_version() -> Option { - let output = Command::new("ld.lld").arg("--version").output().ok()?; - if !output.status.success() { - return None; - } - String::from_utf8_lossy(&output.stdout) - .split_whitespace() - .find_map(|tok| { - let mut splitted = tok.split('.'); - let major = splitted.next()?.parse::().ok()?; - let minor = splitted.next()?.parse::().ok()?; - - Some(LldVersion { major, minor }) - }) -} - -/// TLSDESC is supported in LLD from version 18.1. -const MIN_LLD_VERSION_FOR_TLSDESC: LldVersion = LldVersion { - major: 18, - minor: 1, -}; - -/// Validate that a suitable LLD is available for cross-language LTO. -/// -/// Returns the rust-lld `gcc-ld/` directory if found; `None` means the system -/// `ld.lld` will be used instead. Panics with a clear message when the -/// requirements are not met. -fn resolve_lld_for_inline(target_arch: &str) -> Option { - if let Some(dir) = find_rust_lld_dir() { - return Some(dir); - } - - match system_lld_version() { - Some(v) if target_arch != "x86_64" || v >= MIN_LLD_VERSION_FOR_TLSDESC => None, - Some(v) => panic!( - "LIBDD_OTEL_THREAD_CTX_INLINE requires LLD >= {MIN_LLD_VERSION_FOR_TLSDESC} on \ - x86-64 (for -mllvm -enable-tlsdesc), but system ld.lld is version {v}. \ - Install a newer LLD or use a Rust toolchain that bundles rust-lld." - ), - None => panic!( - "LIBDD_OTEL_THREAD_CTX_INLINE requires LLD for cross-language LTO, but neither \ - rust-lld nor a system ld.lld was found." - ), - } -} +use std::env; fn main() { generate_and_configure_header("otel-thread-ctx.h"); @@ -76,11 +13,7 @@ fn main() { return; } - println!("cargo:rerun-if-env-changed=LIBDD_OTEL_THREAD_CTX_INLINE"); - - let inline_mode = env::var("LIBDD_OTEL_THREAD_CTX_INLINE").is_ok_and(|v| v == "1"); let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap(); - let target_arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap(); // Export the TLSDESC thread-local variable to the dynamic symbol table so external readers // (e.g. the eBPF profiler) can discover it. Rust's cdylib linker applies a version script with @@ -93,32 +26,10 @@ fn main() { // Merging multiple version scripts is not supported by GNU ld, so we need lld. We prefer the // toolchain's bundled rust-lld (LLD 19+ since Rust 1.84) over the system lld (if it even // exists). If rust-lld is not found we fall back to whatever `lld` the system provides. - - // If `LIBDD_OTEL_THREAD_CTX_INLINE` is set to `1`, we try to inline the C shim. See the README - // for more details. - if inline_mode { - let rust_lld_dir = resolve_lld_for_inline(&target_arch); - - // Emit link args for ALL link types (not just cdylib) so that test binaries also link - // correctly when RUSTFLAGS sets clang as the linker (in practice we should only build/care - // about the shared object file in inline mode). - if let Some(dir) = rust_lld_dir { - println!("cargo:rustc-link-arg=-B{}", dir.display()); - } - println!("cargo:rustc-link-arg=-fuse-ld=lld"); - - // On x86-64, tell the LLVM backend to use TLSDESC during LTO codegen. - // On aarch64 TLSDESC is the default and the only model. - if target_arch == "x86_64" { - println!("cargo:rustc-link-arg=-Wl,-mllvm,-enable-tlsdesc"); - } - } else { - // Default mode: only the cdylib needs lld (for the version script). - if let Some(gcc_ld_dir) = find_rust_lld_dir() { - println!("cargo:rustc-cdylib-link-arg=-B{}", gcc_ld_dir.display()); - } - println!("cargo:rustc-cdylib-link-arg=-fuse-ld=lld"); + if let Some(gcc_ld_dir) = find_rust_lld_dir() { + println!("cargo:rustc-cdylib-link-arg=-B{}", gcc_ld_dir.display()); } + println!("cargo:rustc-cdylib-link-arg=-fuse-ld=lld"); println!( "cargo:rustc-cdylib-link-arg=-Wl,--version-script={manifest_dir}/tls-dynamic-list.txt" diff --git a/libdd-otel-thread-ctx/Cargo.toml b/libdd-otel-thread-ctx/Cargo.toml index 0580970a96..9044c3eb4f 100644 --- a/libdd-otel-thread-ctx/Cargo.toml +++ b/libdd-otel-thread-ctx/Cargo.toml @@ -22,7 +22,3 @@ elf = { version = "0.7", optional = true } [features] sanity-check = ["dep:elf", "dep:anyhow"] - -[build-dependencies] -build_common = { path = "../build-common" } -cc = "1.1.31" diff --git a/libdd-otel-thread-ctx/README.md b/libdd-otel-thread-ctx/README.md index c55675f7d2..731edb9676 100644 --- a/libdd-otel-thread-ctx/README.md +++ b/libdd-otel-thread-ctx/README.md @@ -15,10 +15,10 @@ Linux only for now. ## TLS -The C shim (`src/tls_shim.c`) is required because `rustc` does not yet support -the TLSDESC TLS dialect required by the spec to export `otel_thread_ctx_v1`. -Since the reader and the writer must agree on the TLS dialect/model, we rely on -the C compiler to emit the right access pattern. +The TLS symbol `otel_thread_ctx_v1` and its TLSDESC accessor are defined +directly in Rust using `global_asm!` and `asm!` (both stable since Rust 1.65 / +1.59). This avoids a C build dependency while guaranteeing the TLSDESC dialect +on both x86-64 and aarch64 as required by the spec. ## Usage diff --git a/libdd-otel-thread-ctx/build.rs b/libdd-otel-thread-ctx/build.rs index adfda34153..a9082c9aaa 100644 --- a/libdd-otel-thread-ctx/build.rs +++ b/libdd-otel-thread-ctx/build.rs @@ -1,17 +1,7 @@ // Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ // SPDX-License-Identifier: Apache-2.0 -extern crate build_common; use std::env; -use std::process::Command; - -fn clang_is_available() -> bool { - Command::new("clang") - .arg("--version") - .output() - .map(|o| o.status.success()) - .unwrap_or(false) -} fn main() { let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap(); @@ -27,43 +17,4 @@ fn main() { target_arch ) } - - println!("cargo:rerun-if-env-changed=LIBDD_OTEL_THREAD_CTX_INLINE"); - println!("cargo:rerun-if-changed=src/tls_shim.c"); - - // The otel-thread-ctx FFI crate has a special flag to inline the C shim inside the final - // library. This setup has additional requirements for the build of this crate, which are - // enforced below when the flag is set. - let inline_mode = env::var_os("LIBDD_OTEL_THREAD_CTX_INLINE").is_some_and(|v| v == "1"); - - let mut build = cc::Build::new(); - - if inline_mode { - assert!( - clang_is_available(), - "LIBDD_OTEL_THREAD_CTX_INLINE is set but `clang` was not found. \ - Cross-language LTO requires clang as the C compiler." - ); - build.compiler("clang"); - build.flag("-flto=thin"); - - // Any binary linking this crate in inline mode (including test - // binaries) needs lld, because -Clinker-plugin-lto passes LTO plugin - // options that only lld understands. - if let Some(dir) = build_common::find_rust_lld_dir() { - println!("cargo:rustc-link-arg=-B{}", dir.display()); - } - println!("cargo:rustc-link-arg=-fuse-ld=lld"); - - // Note: in the inline setup, TLS dialect selection is handled by the linker and is taken - // care of by the build script of otel-thread-ctx-ffi - } else if target_arch == "x86_64" { - // - On aarch64, TLSDESC is already the only dynamic TLS model so no flag is needed. - // - On x86-64, we use `-mtls-dialect=gnu2` (supported since GCC 4.4 and Clang 19+) to force - // the use of TLSDESC as mandated by the spec. If it's not supported, this build will - // fail. - build.flag("-mtls-dialect=gnu2"); - } - - build.file("src/tls_shim.c").compile("tls_shim"); } diff --git a/libdd-otel-thread-ctx/src/lib.rs b/libdd-otel-thread-ctx/src/lib.rs index 10142a4c12..54417dddcf 100644 --- a/libdd-otel-thread-ctx/src/lib.rs +++ b/libdd-otel-thread-ctx/src/lib.rs @@ -5,8 +5,8 @@ //! //! This crate implements the publisher side of the Thread Context OTEP (PR #4947). //! -//! Since `rustc` doesn't currently support the TLSDESC dialect, we use a C shim to set and get -//! the thread-local storage used for the context. +//! Since `rustc` doesn't currently support the TLSDESC dialect, we define the thread-local +//! storage symbol and its accessor using inline assembly (`global_asm!` / `asm!`). //! //! ## Usage //! @@ -70,17 +70,75 @@ pub mod sanity_check; #[cfg(target_os = "linux")] pub mod linux { use std::{ - ffi::c_void, mem, ptr::{self, NonNull}, sync::atomic::{compiler_fence, AtomicPtr, AtomicU8, Ordering}, }; + // Define the thread-local pointer that external readers (e.g. the eBPF profiler) discover via + // the dynamic symbol table. It must be an exported ELF `STT_TLS` object accessed via the + // TLSDESC dialect, as mandated by the OTel thread-level context sharing spec. + // + // Stable `rustc` cannot select the TLS dialect for a `#[thread_local]` static, so we declare + // the symbol directly in assembly (an 8-byte, zero-initialised slot in `.tbss`) and resolve + // its per-thread address through TLSDESC in [`tls_slot`]. + #[cfg(all( + target_os = "linux", + any(target_arch = "x86_64", target_arch = "aarch64") + ))] + core::arch::global_asm!( + ".section .tbss,\"awT\",@nobits", + ".globl otel_thread_ctx_v1", + ".type otel_thread_ctx_v1, @tls_object", + ".size otel_thread_ctx_v1, 8", + "otel_thread_ctx_v1:", + ".zero 8", + ".previous", + ); + + /// Return the address of the current thread's `otel_thread_ctx_v1` TLS slot, resolved through + /// the TLSDESC dialect. + #[cfg(target_arch = "x86_64")] + #[inline(always)] + unsafe fn tls_slot() -> *mut *mut ThreadContextRecord { + let ptr: usize; + core::arch::asm!( + "leaq otel_thread_ctx_v1@tlsdesc(%rip), %rax", + "call *otel_thread_ctx_v1@TLSCALL(%rax)", + "addq %fs:0, %rax", + out("rax") ptr, + options(att_syntax), + ); + ptr as *mut *mut ThreadContextRecord + } + + /// Return the address of the current thread's `otel_thread_ctx_v1` TLS slot, resolved through + /// the TLSDESC dialect. + #[cfg(target_arch = "aarch64")] + #[inline(always)] + unsafe fn tls_slot() -> *mut *mut ThreadContextRecord { + let ptr: usize; + core::arch::asm!( + "adrp x0, :tlsdesc:otel_thread_ctx_v1", + "ldr x1, [x0, :tlsdesc_lo12:otel_thread_ctx_v1]", + "add x0, x0, :tlsdesc_lo12:otel_thread_ctx_v1", + ".tlsdesccall otel_thread_ctx_v1", + "blr x1", + "mrs x2, tpidr_el0", + "add x0, x0, x2", + out("x0") ptr, + out("x1") _, + out("x2") _, + out("x30") _, + ); + ptr as *mut *mut ThreadContextRecord + } + /// Run `f` with an atomic view of the current thread's TLS slot. /// - /// The address calculation requires a call to a C shim in order to use the TLSDESC dialect - /// from Rust. The returned address is stable (per thread), so callers should try to do as - /// much work as possible inside a single call to reduce the number of C-shim round-trips. + /// The address calculation goes through the TLSDESC dialect via [`tls_slot`]. The returned + /// address is stable (per thread), so callers should try to do as much work as possible + /// inside a single call. /// /// The slot is read by an async signal handler. Atomic operations should in general use /// [Ordering::Relaxed], but modifications to the record might need additional compiler-only @@ -89,11 +147,6 @@ pub mod linux { where F: FnOnce(&AtomicPtr) -> R, { - extern "C" { - /// Return the address of the current thread's `otel_thread_ctx_v1` local. - fn libdd_get_otel_thread_ctx_v1() -> *mut *mut c_void; - } - const { assert!( mem::align_of::>() @@ -102,11 +155,9 @@ pub mod linux { } // Safety: the const assertion above ensures the alignment is correct. The TLS slot is - // valid for the lifetime of the current thread. The `extern "C"` declaration is scoped - // to this function, guaranteeing that all accesses go through the `AtomicPtr` wrapper. - let slot = unsafe { - AtomicPtr::from_ptr(libdd_get_otel_thread_ctx_v1().cast::<*mut ThreadContextRecord>()) - }; + // valid for the lifetime of the current thread, and all accesses go through the + // `AtomicPtr` wrapper. + let slot = unsafe { AtomicPtr::from_ptr(tls_slot()) }; f(slot) } @@ -462,7 +513,7 @@ pub mod linux { } #[cfg(test)] - // The tests are set to be ignored by Miri, since accessing the TLS through C isn't supported. + // The tests are set to be ignored by Miri, since the inline-asm TLSDESC access isn't supported. mod tests { use super::{ThreadContext, ThreadContextRecord}; use std::sync::atomic::Ordering; @@ -672,7 +723,7 @@ pub mod linux { let _ = ThreadContext::detach(); } - // Make sure the C shim is indeed providing a thread-local address. + // Make sure the TLSDESC accessor is indeed providing a thread-local address. #[test] #[cfg_attr(miri, ignore)] fn tls_slots_are_per_thread() { diff --git a/libdd-otel-thread-ctx/src/tls_shim.c b/libdd-otel-thread-ctx/src/tls_shim.c deleted file mode 100644 index 0322967725..0000000000 --- a/libdd-otel-thread-ctx/src/tls_shim.c +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ -// SPDX-License-Identifier: Apache-2.0 - -// Declares the thread-local pointer that external readers (e.g. the eBPF -// profiler) discover via the dynsym table. The Rust layer accesses this -// pointer in lib.rs. -// -// The variable is declared in C in order to use the TLSDESC dialect for -// thread-local storage, which is required by the OTel thread-level context -// sharing spec. Unfortunately, it's not possible to have Rust use this dialect -// as of today. -#include - -__attribute__((visibility("default"))) -__thread void *otel_thread_ctx_v1 = NULL; - -// Return the resolved address of the thread-local variable. -void **libdd_get_otel_thread_ctx_v1(void) { - return &otel_thread_ctx_v1; -} From c053604073688a9cbb275a0dd23f1db6ce1694eb Mon Sep 17 00:00:00 2001 From: Gustavo Lopes Date: Wed, 17 Jun 2026 11:47:32 +0100 Subject: [PATCH 02/12] ensure otel_thread_ctx_v1 is aligned Co-authored-by: Yann Hamdaoui --- libdd-otel-thread-ctx/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/libdd-otel-thread-ctx/src/lib.rs b/libdd-otel-thread-ctx/src/lib.rs index 54417dddcf..35f6d8e398 100644 --- a/libdd-otel-thread-ctx/src/lib.rs +++ b/libdd-otel-thread-ctx/src/lib.rs @@ -91,6 +91,7 @@ pub mod linux { ".globl otel_thread_ctx_v1", ".type otel_thread_ctx_v1, @tls_object", ".size otel_thread_ctx_v1, 8", + ".balign 8", "otel_thread_ctx_v1:", ".zero 8", ".previous", From 2808aa7dddd9b0717eee095ecae7701f009df366 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gustavo=20Andr=C3=A9=20dos=20Santos=20Lopes?= Date: Wed, 17 Jun 2026 16:47:48 +0100 Subject: [PATCH 03/12] add comment and test for relaxation --- Cargo.lock | 1 + libdd-otel-thread-ctx-ffi/Cargo.toml | 1 + libdd-otel-thread-ctx-ffi/build.rs | 3 + .../tests/elf_properties.rs | 591 +++++++++++++++++- libdd-otel-thread-ctx/src/lib.rs | 5 + 5 files changed, 592 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index aea5544d46..18fa3e0c91 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3172,6 +3172,7 @@ name = "libdd-otel-thread-ctx-ffi" version = "1.0.0" dependencies = [ "build_common", + "elf", "libdd-common-ffi", "libdd-otel-thread-ctx", ] diff --git a/libdd-otel-thread-ctx-ffi/Cargo.toml b/libdd-otel-thread-ctx-ffi/Cargo.toml index 1c0f288628..6d7a43f98d 100644 --- a/libdd-otel-thread-ctx-ffi/Cargo.toml +++ b/libdd-otel-thread-ctx-ffi/Cargo.toml @@ -24,6 +24,7 @@ cbindgen = ["build_common/cbindgen", "libdd-common-ffi/cbindgen"] sanity-check = ["dep:libdd-common-ffi", "libdd-otel-thread-ctx/sanity-check"] [dev-dependencies] +elf = "0.7" libdd-otel-thread-ctx = { path = "../libdd-otel-thread-ctx", features = ["sanity-check"] } [build-dependencies] diff --git a/libdd-otel-thread-ctx-ffi/build.rs b/libdd-otel-thread-ctx-ffi/build.rs index cbac605286..930272f09a 100644 --- a/libdd-otel-thread-ctx-ffi/build.rs +++ b/libdd-otel-thread-ctx-ffi/build.rs @@ -8,6 +8,9 @@ use std::env; fn main() { generate_and_configure_header("otel-thread-ctx.h"); + let cross_compiling = env::var("HOST").unwrap() != env::var("TARGET").unwrap(); + println!("cargo:rustc-env=LIBDD_OTEL_THREAD_CTX_FFI_CROSS_COMPILING={cross_compiling}"); + let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap(); if target_os != "linux" { return; diff --git a/libdd-otel-thread-ctx-ffi/tests/elf_properties.rs b/libdd-otel-thread-ctx-ffi/tests/elf_properties.rs index 7d0e51a446..274a99b52c 100644 --- a/libdd-otel-thread-ctx-ffi/tests/elf_properties.rs +++ b/libdd-otel-thread-ctx-ffi/tests/elf_properties.rs @@ -6,29 +6,602 @@ //! test rather checks that the dynamic library is properly linked, which is why it lives within the //! FFI. //! -//! Delegates to [`libdd_otel_thread_ctx::autocheck::check_tls_slot_in`] which -//! checks that: +//! These tests check that: //! - `otel_thread_ctx_v1` is exported in the dynamic symbol table as a TLS GLOBAL symbol. -//! - `otel_thread_ctx_v1` follows the TLSDESC access model (if there's a relocation, it's a TLSDESC -//! one). +//! - `otel_thread_ctx_v1` follows the TLSDESC access model: if there is a relocation for it, it is +//! a TLSDESC relocation. +//! - A native executable that statically links libdd-otel-thread-ctx-ffi without exporting +//! `otel_thread_ctx_v1` has libdd's TLSDESC access relaxed to local-exec TLS, leaving no +//! relocation for `otel_thread_ctx_v1`. //! -//! The cdylib path is derived at runtime from the test executable location. -//! Both the test binary and the cdylib live in `target/<[triple/]profile>/deps/`. +//! Library artifact paths are derived at runtime from the test executable location. +//! The test binary and crate artifacts live in `target/<[triple/]profile>/deps/`. #![cfg(target_os = "linux")] -use std::path::PathBuf; +use std::{ + io::ErrorKind, + path::{Path, PathBuf}, + process::{Command, Stdio}, +}; -fn cdylib_path() -> PathBuf { +use elf::{abi, endian::AnyEndian, symbol::SymbolTable, ElfBytes}; + +const SYMBOL: &str = "otel_thread_ctx_v1"; + +fn deps_dir() -> PathBuf { + // test binary: target/<[triple/]profile>/deps/ let exe = std::env::current_exe().expect("failed to read current executable path"); exe.parent() .expect("unexpected test executable path structure") - .join("liblibdd_otel_thread_ctx_ffi.so") + .to_owned() +} + +fn artifact_path(name: &str) -> PathBuf { + deps_dir().join(name) +} + +fn cdylib_path() -> PathBuf { + artifact_path("liblibdd_otel_thread_ctx_ffi.so") +} + +fn staticlib_path() -> PathBuf { + artifact_path("liblibdd_otel_thread_ctx_ffi.a") +} + +fn check_readable(path: &Path) { + assert!( + std::fs::File::open(path).is_ok(), + "{} could not be opened for reading", + path.display() + ); +} + +fn tool_available(tool: &str) -> bool { + match Command::new(tool) + .arg("--version") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + { + Ok(_) => true, + Err(e) if e.kind() == ErrorKind::NotFound => { + eprintln!("skipping test: required tool `{tool}` is not available"); + false + } + Err(e) => panic!("failed to check whether `{tool}` is available: {e}"), + } +} + +fn required_tools_available(tools: &[&str]) -> bool { + tools.iter().all(|tool| tool_available(tool)) +} + +fn native_target() -> bool { + let cross_compiling = option_env!("LIBDD_OTEL_THREAD_CTX_FFI_CROSS_COMPILING") == Some("true"); + if cross_compiling { + eprintln!("skipping test: cross-compiling"); + } + !cross_compiling +} + +fn command_output(command: &mut Command) -> String { + let out = command + .output() + .unwrap_or_else(|e| panic!("failed to run {command:?}: {e}")); + assert!( + out.status.success(), + "{command:?} failed with status {}\nstdout:\n{}\nstderr:\n{}", + out.status, + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8_lossy(&out.stdout).into_owned() +} + +fn objdump(args: &[&str], path: &Path) -> String { + let mut command = Command::new("objdump"); + command.args(args).arg(path); + command_output(&mut command) +} + +fn assert_command_success(command: &mut Command) { + let out = command + .output() + .unwrap_or_else(|e| panic!("failed to run {command:?}: {e}")); + assert!( + out.status.success(), + "{command:?} failed with status {}\nstdout:\n{}\nstderr:\n{}", + out.status, + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); +} + +fn build_dir(name: &str) -> PathBuf { + let dir = deps_dir().join(format!("{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir) + .unwrap_or_else(|e| panic!("failed to create {}: {e}", dir.display())); + dir +} + +fn parse_elf<'data>(data: &'data [u8], label: &str) -> ElfBytes<'data, AnyEndian> { + ElfBytes::::minimal_parse(data) + .unwrap_or_else(|e| panic!("failed to parse ELF data from {label}: {e}")) +} + +fn symbol_indexes_in_table( + elf: &ElfBytes<'_, AnyEndian>, + symtab_index: usize, + symbol: &str, + label: &str, +) -> Vec { + let Some(section_headers) = elf.section_headers() else { + panic!("{label} has no ELF section headers"); + }; + let symtab_header = section_headers + .get(symtab_index) + .unwrap_or_else(|e| panic!("failed to read symbol table header {symtab_index}: {e}")); + + // Relocation sections link to the symbol table they use; archive members usually use + // `.symtab`, while linked dynamic artifacts may use `.dynsym`. + if !matches!(symtab_header.sh_type, abi::SHT_SYMTAB | abi::SHT_DYNSYM) { + return Vec::new(); + } + + let strtab_header = section_headers + .get(symtab_header.sh_link as usize) + .unwrap_or_else(|e| panic!("failed to read linked string table header: {e}")); + let strtab = elf + .section_data_as_strtab(&strtab_header) + .unwrap_or_else(|e| panic!("failed to read linked string table in {label}: {e}")); + let (symtab_data, _) = elf + .section_data(&symtab_header) + .unwrap_or_else(|e| panic!("failed to read symbol table data in {label}: {e}")); + let symtab = SymbolTable::new(elf.ehdr.endianness, elf.ehdr.class, symtab_data); + + symtab + .iter() + .enumerate() + .filter_map(|(index, sym)| { + strtab + .get(sym.st_name as usize) + .ok() + .filter(|name| *name == symbol) + .map(|_| index as u32) + }) + .collect() +} + +fn relocation_types_for_symbol_in_elf(data: &[u8], symbol: &str, label: &str) -> Vec { + let elf = parse_elf(data, label); + let Some(section_headers) = elf.section_headers() else { + panic!("{label} has no ELF section headers"); + }; + let mut relocation_types = Vec::new(); + + for section_header in section_headers + .iter() + .filter(|shdr| matches!(shdr.sh_type, abi::SHT_REL | abi::SHT_RELA)) + { + let symbol_indexes = + symbol_indexes_in_table(&elf, section_header.sh_link as usize, symbol, label); + if symbol_indexes.is_empty() { + continue; + } + + match section_header.sh_type { + abi::SHT_REL => { + let rels = elf + .section_data_as_rels(§ion_header) + .unwrap_or_else(|e| panic!("failed to read REL relocations in {label}: {e}")); + relocation_types.extend( + rels.filter(|rel| symbol_indexes.contains(&rel.r_sym)) + .map(|rel| rel.r_type), + ); + } + abi::SHT_RELA => { + let relas = elf + .section_data_as_relas(§ion_header) + .unwrap_or_else(|e| panic!("failed to read RELA relocations in {label}: {e}")); + relocation_types.extend( + relas + .filter(|rela| symbol_indexes.contains(&rela.r_sym)) + .map(|rela| rela.r_type), + ); + } + _ => unreachable!(), + } + } + + relocation_types +} + +fn relocation_types_for_symbol_in_file(path: &Path, symbol: &str) -> Vec { + let data = + std::fs::read(path).unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display())); + relocation_types_for_symbol_in_elf(&data, symbol, &path.display().to_string()) +} + +fn parse_ascii_usize(bytes: &[u8], what: &str) -> usize { + std::str::from_utf8(bytes) + .unwrap_or_else(|e| panic!("invalid UTF-8 in {what}: {e}")) + .trim() + .parse() + .unwrap_or_else(|e| panic!("failed to parse {what}: {e}")) +} + +fn trim_archive_name(bytes: &[u8]) -> String { + String::from_utf8_lossy(bytes) + .trim() + .trim_end_matches('/') + .to_owned() +} + +fn gnu_archive_name(name_table: &[u8], offset: usize) -> String { + assert!( + offset < name_table.len(), + "GNU archive name offset {offset} is outside the name table" + ); + let rest = &name_table[offset..]; + let end = rest.iter().position(|b| *b == b'\n').unwrap_or(rest.len()); + trim_archive_name(&rest[..end]) +} + +fn archive_member_name_and_data<'a>( + name_field: &[u8], + member: &'a [u8], + gnu_name_table: Option<&'a [u8]>, +) -> (String, &'a [u8]) { + let name = std::str::from_utf8(name_field) + .unwrap_or_else(|e| panic!("invalid UTF-8 in archive member name: {e}")) + .trim(); + + if matches!(name, "/" | "//") { + return (name.to_owned(), member); + } + + if let Some(name_len) = name.strip_prefix("#1/") { + let name_len = name_len + .parse::() + .unwrap_or_else(|e| panic!("failed to parse BSD archive name length: {e}")); + assert!( + name_len <= member.len(), + "BSD archive member name length {name_len} exceeds member data length {}", + member.len() + ); + return (trim_archive_name(&member[..name_len]), &member[name_len..]); + } + + if let Some(offset) = name.strip_prefix('/') { + if !offset.is_empty() && offset.bytes().all(|b| b.is_ascii_digit()) { + let offset = offset + .parse::() + .unwrap_or_else(|e| panic!("failed to parse GNU archive name offset: {e}")); + let gnu_name_table = + gnu_name_table.expect("GNU archive name offset used before the name table"); + return (gnu_archive_name(gnu_name_table, offset), member); + } + } + + (trim_archive_name(name_field), member) +} + +fn archive_relocation_types_for_symbol(path: &Path, symbol: &str) -> Vec<(String, Vec)> { + const ARMAG: &[u8] = b"!\n"; + const HEADER_LEN: usize = 60; + + let archive = + std::fs::read(path).unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display())); + assert!( + archive.starts_with(ARMAG), + "{} is not an ar archive", + path.display() + ); + + let mut offset = ARMAG.len(); + let mut gnu_name_table = None; + let mut relocations = Vec::new(); + + while offset < archive.len() { + assert!( + offset + HEADER_LEN <= archive.len(), + "truncated ar header in {} at offset {offset}", + path.display() + ); + let header = &archive[offset..offset + HEADER_LEN]; + assert_eq!( + &header[58..60], + b"`\n", + "invalid ar header trailer in {} at offset {offset}", + path.display() + ); + offset += HEADER_LEN; + + let member_size = parse_ascii_usize(&header[48..58], "archive member size"); + let member_end = offset + .checked_add(member_size) + .expect("archive member end offset overflowed"); + assert!( + member_end <= archive.len(), + "truncated ar member in {} at offset {offset}", + path.display() + ); + + let member = &archive[offset..member_end]; + let (member_name, member_data) = + archive_member_name_and_data(&header[0..16], member, gnu_name_table); + + if member_name == "//" { + gnu_name_table = Some(member); + } else if member_data.starts_with(&abi::ELFMAGIC) { + let label = format!("{}({member_name})", path.display()); + let relocation_types = relocation_types_for_symbol_in_elf(member_data, symbol, &label); + if !relocation_types.is_empty() { + relocations.push((member_name.clone(), relocation_types)); + } + } + + offset = member_end + member_size % 2; + assert!( + offset <= archive.len(), + "truncated ar padding in {} after member {member_name}", + path.display() + ); + } + + relocations +} + +#[cfg(target_arch = "x86_64")] +fn is_tlsdesc_object_relocation(relocation_type: u32) -> bool { + // These are object-file TLSDESC relocations. `R_X86_64_TLSDESC` is the dynamic-linker + // relocation emitted after linking, so it is intentionally excluded here. + matches!( + relocation_type, + abi::R_X86_64_GOTPC32_TLSDESC | abi::R_X86_64_TLSDESC_CALL + ) +} + +#[cfg(target_arch = "aarch64")] +fn is_tlsdesc_object_relocation(relocation_type: u32) -> bool { + // These are object-file TLSDESC relocations. `R_AARCH64_TLSDESC` is the dynamic-linker + // relocation emitted after linking, so it is intentionally excluded here. + matches!( + relocation_type, + abi::R_AARCH64_TLSDESC_LD_PREL19 + | abi::R_AARCH64_TLSDESC_ADR_PREL21 + | abi::R_AARCH64_TLSDESC_ADR_PAGE21 + | abi::R_AARCH64_TLSDESC_LD64_LO12 + | abi::R_AARCH64_TLSDESC_ADD_LO12 + | abi::R_AARCH64_TLSDESC_OFF_G1 + | abi::R_AARCH64_TLSDESC_OFF_G0_NC + | abi::R_AARCH64_TLSDESC_LDR + | abi::R_AARCH64_TLSDESC_ADD + | abi::R_AARCH64_TLSDESC_CALL + ) +} + +fn format_relocations(relocations: &[(String, Vec)]) -> String { + if relocations.is_empty() { + return "".to_owned(); + } + + relocations + .iter() + .map(|(name, types)| format!("{name}: {types:?}")) + .collect::>() + .join("\n") +} + +fn is_disassembly_header_for(line: &str, name: &str) -> bool { + let Some((_, symbol)) = line.split_once('<') else { + return false; + }; + let Some(symbol) = symbol.strip_suffix(">:") else { + return false; + }; + symbol == name + || symbol + .strip_prefix(name) + .is_some_and(|suffix| suffix.starts_with("::")) +} + +fn disassembled_functions(output: &str, name: &str) -> Vec { + let mut functions = Vec::new(); + let mut current_function = Vec::new(); + + for line in output.lines() { + if is_disassembly_header_for(line, name) { + if !current_function.is_empty() { + functions.push(current_function.join("\n")); + current_function.clear(); + } + current_function.push(line); + continue; + } + + if !current_function.is_empty() { + if line.is_empty() { + functions.push(current_function.join("\n")); + current_function.clear(); + continue; + } + current_function.push(line); + } + } + + if !current_function.is_empty() { + functions.push(current_function.join("\n")); + } + + assert!( + !functions.is_empty(), + "could not find disassembly for {name} in:\n{output}" + ); + functions +} + +#[cfg(target_arch = "aarch64")] +fn disassembly_window_around_line( + function: &str, + needle: &str, + before: usize, + after: usize, +) -> String { + let lines = function.lines().collect::>(); + let line_index = lines + .iter() + .position(|line| line.contains(needle)) + .unwrap_or_else(|| panic!("could not find {needle:?} in:\n{function}")); + let start = line_index.saturating_sub(before); + let end = usize::min(line_index + after + 1, lines.len()); + lines[start..end].join("\n") } #[test] #[cfg_attr(miri, ignore)] fn otel_thread_ctx_v1_tls_properties() { let path = cdylib_path(); + check_readable(&path); libdd_otel_thread_ctx::sanity_check::check_tls_slot_in(&path).unwrap(); } + +#[test] +#[cfg_attr(miri, ignore)] +#[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))] +fn statically_linked_executable_relaxes_libdd_tls_slot_to_local_exec() { + if !native_target() { + return; + } + + if !required_tools_available(&["cc", "objdump"]) { + return; + } + + let staticlib = staticlib_path(); + check_readable(&staticlib); + + let dir = build_dir("otel-thread-ctx-local-exec"); + let source = dir.join("consumer.c"); + let object = dir.join("consumer.o"); + let executable = dir.join("consumer"); + std::fs::write( + &source, + r#" +#include + +void ddog_otel_thread_ctx_update( + const uint8_t (*trace_id)[16], + const uint8_t (*span_id)[8], + const uint8_t (*local_root_span_id)[8]); +void *ddog_otel_thread_ctx_detach(void); +void ddog_otel_thread_ctx_free(void *ctx); + +int main(void) { + uint8_t trace_id[16] = {1}; + uint8_t span_id[8] = {2}; + uint8_t local_root_span_id[8] = {3}; + + ddog_otel_thread_ctx_update(&trace_id, &span_id, &local_root_span_id); + void *ctx = ddog_otel_thread_ctx_detach(); + ddog_otel_thread_ctx_free(ctx); + + return ctx == 0 ? 1 : 0; +} +"#, + ) + .unwrap_or_else(|e| panic!("failed to write {}: {e}", source.display())); + + let mut compile_object = Command::new("cc"); + compile_object.args(["-O2", "-ffunction-sections", "-fdata-sections"]); + compile_object.arg("-c").arg(&source).arg("-o").arg(&object); + assert_command_success(&mut compile_object); + + let staticlib_relocations = archive_relocation_types_for_symbol(&staticlib, SYMBOL); + assert!( + staticlib_relocations + .iter() + .any(|(_, types)| types.iter().any(|t| is_tlsdesc_object_relocation(*t))), + "expected an object-file TLSDESC relocation for {SYMBOL} in {}\nfound:\n{}", + staticlib.display(), + format_relocations(&staticlib_relocations) + ); + + let object_relocations = relocation_types_for_symbol_in_file(&object, SYMBOL); + assert!( + object_relocations.is_empty(), + "expected generated C object to have no relocations for {SYMBOL}; found {object_relocations:?}" + ); + + let mut link_executable = Command::new("cc"); + link_executable + .arg(&object) + .arg(&staticlib) + .args([ + "-Wl,--gc-sections", + "-lpthread", + "-ldl", + "-lm", + "-lrt", + "-lutil", + ]) + .arg("-o") + .arg(&executable); + assert_command_success(&mut link_executable); + + // Run the generated executable so the test validates the relaxed TLS access at runtime too. + let mut run_executable = Command::new(&executable); + assert_command_success(&mut run_executable); + + let executable_relocations = relocation_types_for_symbol_in_file(&executable, SYMBOL); + assert!( + executable_relocations.is_empty(), + "expected no remaining relocations for {SYMBOL} in {}; found {executable_relocations:?}", + executable.display() + ); + + let disassembly = objdump(&["-drwC"], &executable); + let tls_slot_functions = + disassembled_functions(&disassembly, "libdd_otel_thread_ctx::linux::with_tls_slot"); + + #[cfg(target_arch = "x86_64")] + { + assert!( + tls_slot_functions + .iter() + .any(|function| function.contains("%fs:0x0")), + "expected tls_slot() in libdd-otel-thread-ctx to be relaxed to local-exec x86-64 \ + TLS access through %fs:0x0\n{}", + tls_slot_functions.join("\n\n") + ); + assert!( + tls_slot_functions + .iter() + .all(|function| !function.contains("tlsdesc")), + "expected linker-relaxed local-exec TLS code without TLSDESC operands:\n{}", + tls_slot_functions.join("\n\n") + ); + } + + #[cfg(target_arch = "aarch64")] + { + let function = tls_slot_functions + .iter() + .find(|function| function.contains("tpidr_el0")) + .unwrap_or_else(|| { + panic!( + "expected tls_slot() in libdd-otel-thread-ctx to use tpidr_el0 after \ + relaxation\n{}", + tls_slot_functions.join("\n\n") + ) + }); + let window = disassembly_window_around_line(function, "tpidr_el0", 4, 3); + assert!( + !window.contains("tlsdesc") && !window.contains("\tblr"), + "expected linker-relaxed local-exec TLS code around tpidr_el0 without a TLSDESC call:\n\ + {window}" + ); + } +} diff --git a/libdd-otel-thread-ctx/src/lib.rs b/libdd-otel-thread-ctx/src/lib.rs index 35f6d8e398..b0fb2c8396 100644 --- a/libdd-otel-thread-ctx/src/lib.rs +++ b/libdd-otel-thread-ctx/src/lib.rs @@ -82,6 +82,11 @@ pub mod linux { // Stable `rustc` cannot select the TLS dialect for a `#[thread_local]` static, so we declare // the symbol directly in assembly (an 8-byte, zero-initialised slot in `.tbss`) and resolve // its per-thread address through TLSDESC in [`tls_slot`]. + // + // WARNING: keep the assembly below in the canonical compiler-emitted TLSDESC form. Linkers + // rely on these exact relocation-bearing instruction patterns for TLS relaxation, especially + // when this crate is linked statically. Harmless-looking rewrites can hide part of the sequence + // from the linker and produce a partially relaxed access that computes an invalid TLS address. #[cfg(all( target_os = "linux", any(target_arch = "x86_64", target_arch = "aarch64") From 1cb41d8b63fb04a684db60dc389a630b6643097a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gustavo=20Andr=C3=A9=20dos=20Santos=20Lopes?= Date: Thu, 18 Jun 2026 15:28:43 +0100 Subject: [PATCH 04/12] Compare inline assembly to what's generated by the toolchain --- Cargo.lock | 1 + libdd-otel-thread-ctx-ffi/Cargo.toml | 1 + .../tests/elf_properties.rs | 464 +++++++++++++----- libdd-otel-thread-ctx-ffi/tests/tls_shim.c | 8 + libdd-otel-thread-ctx/src/lib.rs | 9 +- 5 files changed, 347 insertions(+), 136 deletions(-) create mode 100644 libdd-otel-thread-ctx-ffi/tests/tls_shim.c diff --git a/Cargo.lock b/Cargo.lock index 18fa3e0c91..8ca028d5bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3175,6 +3175,7 @@ dependencies = [ "elf", "libdd-common-ffi", "libdd-otel-thread-ctx", + "object 0.36.5", ] [[package]] diff --git a/libdd-otel-thread-ctx-ffi/Cargo.toml b/libdd-otel-thread-ctx-ffi/Cargo.toml index 6d7a43f98d..00132acd89 100644 --- a/libdd-otel-thread-ctx-ffi/Cargo.toml +++ b/libdd-otel-thread-ctx-ffi/Cargo.toml @@ -26,6 +26,7 @@ sanity-check = ["dep:libdd-common-ffi", "libdd-otel-thread-ctx/sanity-check"] [dev-dependencies] elf = "0.7" libdd-otel-thread-ctx = { path = "../libdd-otel-thread-ctx", features = ["sanity-check"] } +object = { version = "0.36", default-features = false, features = ["archive", "read_core"] } [build-dependencies] build_common = { path = "../build-common" } diff --git a/libdd-otel-thread-ctx-ffi/tests/elf_properties.rs b/libdd-otel-thread-ctx-ffi/tests/elf_properties.rs index 274a99b52c..28cbcdccf5 100644 --- a/libdd-otel-thread-ctx-ffi/tests/elf_properties.rs +++ b/libdd-otel-thread-ctx-ffi/tests/elf_properties.rs @@ -20,14 +20,45 @@ #![cfg(target_os = "linux")] use std::{ + fmt, io::ErrorKind, path::{Path, PathBuf}, process::{Command, Stdio}, }; use elf::{abi, endian::AnyEndian, symbol::SymbolTable, ElfBytes}; +use object::read::archive::ArchiveFile; const SYMBOL: &str = "otel_thread_ctx_v1"; +const SKIP_TLS_SHIM_ASM_TEST_ENV: &str = "LIBDD_OTEL_THREAD_CTX_SKIP_TLS_SHIM_ASM_TEST"; + +#[derive(Clone, Copy, PartialEq, Eq)] +struct RelocationType(u32); + +impl fmt::Debug for RelocationType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} + +#[derive(Debug, PartialEq, Eq)] +struct TlsDescRelocation { + offset: usize, + relocation_type: RelocationType, + addend: i64, +} + +#[derive(Debug, PartialEq, Eq)] +struct TlsDescSequence { + bytes: Vec, + relocations: Vec, +} + +#[derive(Debug)] +struct ArchiveMemberRelocations { + member_name: String, + relocation_types: Vec, +} fn deps_dir() -> PathBuf { // test binary: target/<[triple/]profile>/deps/ @@ -85,6 +116,14 @@ fn native_target() -> bool { !cross_compiling } +fn skip_tls_shim_asm_test() -> bool { + let skip = std::env::var_os(SKIP_TLS_SHIM_ASM_TEST_ENV).is_some(); + if skip { + eprintln!("skipping test: {SKIP_TLS_SHIM_ASM_TEST_ENV} is set"); + } + skip +} + fn command_output(command: &mut Command) -> String { let out = command .output() @@ -174,7 +213,11 @@ fn symbol_indexes_in_table( .collect() } -fn relocation_types_for_symbol_in_elf(data: &[u8], symbol: &str, label: &str) -> Vec { +fn relocation_types_for_symbol_in_elf( + data: &[u8], + symbol: &str, + label: &str, +) -> Vec { let elf = parse_elf(data, label); let Some(section_headers) = elf.section_headers() else { panic!("{label} has no ELF section headers"); @@ -198,7 +241,7 @@ fn relocation_types_for_symbol_in_elf(data: &[u8], symbol: &str, label: &str) -> .unwrap_or_else(|e| panic!("failed to read REL relocations in {label}: {e}")); relocation_types.extend( rels.filter(|rel| symbol_indexes.contains(&rel.r_sym)) - .map(|rel| rel.r_type), + .map(|rel| RelocationType(rel.r_type)), ); } abi::SHT_RELA => { @@ -208,7 +251,7 @@ fn relocation_types_for_symbol_in_elf(data: &[u8], symbol: &str, label: &str) -> relocation_types.extend( relas .filter(|rela| symbol_indexes.contains(&rela.r_sym)) - .map(|rela| rela.r_type), + .map(|rela| RelocationType(rela.r_type)), ); } _ => unreachable!(), @@ -218,158 +261,74 @@ fn relocation_types_for_symbol_in_elf(data: &[u8], symbol: &str, label: &str) -> relocation_types } -fn relocation_types_for_symbol_in_file(path: &Path, symbol: &str) -> Vec { +fn relocation_types_for_symbol_in_file(path: &Path, symbol: &str) -> Vec { let data = std::fs::read(path).unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display())); relocation_types_for_symbol_in_elf(&data, symbol, &path.display().to_string()) } -fn parse_ascii_usize(bytes: &[u8], what: &str) -> usize { - std::str::from_utf8(bytes) - .unwrap_or_else(|e| panic!("invalid UTF-8 in {what}: {e}")) - .trim() - .parse() - .unwrap_or_else(|e| panic!("failed to parse {what}: {e}")) -} - -fn trim_archive_name(bytes: &[u8]) -> String { - String::from_utf8_lossy(bytes) - .trim() - .trim_end_matches('/') - .to_owned() -} - -fn gnu_archive_name(name_table: &[u8], offset: usize) -> String { - assert!( - offset < name_table.len(), - "GNU archive name offset {offset} is outside the name table" - ); - let rest = &name_table[offset..]; - let end = rest.iter().position(|b| *b == b'\n').unwrap_or(rest.len()); - trim_archive_name(&rest[..end]) -} - -fn archive_member_name_and_data<'a>( - name_field: &[u8], - member: &'a [u8], - gnu_name_table: Option<&'a [u8]>, -) -> (String, &'a [u8]) { - let name = std::str::from_utf8(name_field) - .unwrap_or_else(|e| panic!("invalid UTF-8 in archive member name: {e}")) - .trim(); - - if matches!(name, "/" | "//") { - return (name.to_owned(), member); - } - - if let Some(name_len) = name.strip_prefix("#1/") { - let name_len = name_len - .parse::() - .unwrap_or_else(|e| panic!("failed to parse BSD archive name length: {e}")); - assert!( - name_len <= member.len(), - "BSD archive member name length {name_len} exceeds member data length {}", - member.len() - ); - return (trim_archive_name(&member[..name_len]), &member[name_len..]); - } +fn archive_relocation_types_for_symbol(path: &Path, symbol: &str) -> Vec { + let mut relocations = Vec::new(); - if let Some(offset) = name.strip_prefix('/') { - if !offset.is_empty() && offset.bytes().all(|b| b.is_ascii_digit()) { - let offset = offset - .parse::() - .unwrap_or_else(|e| panic!("failed to parse GNU archive name offset: {e}")); - let gnu_name_table = - gnu_name_table.expect("GNU archive name offset used before the name table"); - return (gnu_archive_name(gnu_name_table, offset), member); + for_each_archive_elf_member(path, |member_name, member_data| { + let label = format!("{}({member_name})", path.display()); + let relocation_types = relocation_types_for_symbol_in_elf(member_data, symbol, &label); + if !relocation_types.is_empty() { + relocations.push(ArchiveMemberRelocations { + member_name: member_name.to_owned(), + relocation_types, + }); } - } + }); - (trim_archive_name(name_field), member) + relocations } -fn archive_relocation_types_for_symbol(path: &Path, symbol: &str) -> Vec<(String, Vec)> { - const ARMAG: &[u8] = b"!\n"; - const HEADER_LEN: usize = 60; - - let archive = +fn for_each_archive_elf_member(path: &Path, mut f: impl FnMut(&str, &[u8])) { + let archive_data = std::fs::read(path).unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display())); - assert!( - archive.starts_with(ARMAG), - "{} is not an ar archive", - path.display() - ); - - let mut offset = ARMAG.len(); - let mut gnu_name_table = None; - let mut relocations = Vec::new(); - - while offset < archive.len() { - assert!( - offset + HEADER_LEN <= archive.len(), - "truncated ar header in {} at offset {offset}", - path.display() - ); - let header = &archive[offset..offset + HEADER_LEN]; - assert_eq!( - &header[58..60], - b"`\n", - "invalid ar header trailer in {} at offset {offset}", - path.display() - ); - offset += HEADER_LEN; - - let member_size = parse_ascii_usize(&header[48..58], "archive member size"); - let member_end = offset - .checked_add(member_size) - .expect("archive member end offset overflowed"); - assert!( - member_end <= archive.len(), - "truncated ar member in {} at offset {offset}", - path.display() - ); - - let member = &archive[offset..member_end]; - let (member_name, member_data) = - archive_member_name_and_data(&header[0..16], member, gnu_name_table); - - if member_name == "//" { - gnu_name_table = Some(member); - } else if member_data.starts_with(&abi::ELFMAGIC) { - let label = format!("{}({member_name})", path.display()); - let relocation_types = relocation_types_for_symbol_in_elf(member_data, symbol, &label); - if !relocation_types.is_empty() { - relocations.push((member_name.clone(), relocation_types)); - } + let archive = ArchiveFile::parse(&*archive_data) + .unwrap_or_else(|e| panic!("failed to parse archive {}: {e}", path.display())); + + for member in archive.members() { + let member = + member.unwrap_or_else(|e| panic!("failed to read member in {}: {e}", path.display())); + let member_data = member.data(&*archive_data).unwrap_or_else(|e| { + panic!( + "failed to read member data for {} in {}: {e}", + String::from_utf8_lossy(member.name()), + path.display() + ) + }); + + if member_data.starts_with(&abi::ELFMAGIC) { + let member_name = std::str::from_utf8(member.name()).unwrap_or_else(|e| { + panic!( + "archive member name in {} is not valid UTF-8: {e}", + path.display() + ) + }); + f(member_name, member_data); } - - offset = member_end + member_size % 2; - assert!( - offset <= archive.len(), - "truncated ar padding in {} after member {member_name}", - path.display() - ); } - - relocations } #[cfg(target_arch = "x86_64")] -fn is_tlsdesc_object_relocation(relocation_type: u32) -> bool { +fn is_tlsdesc_object_relocation(relocation_type: RelocationType) -> bool { // These are object-file TLSDESC relocations. `R_X86_64_TLSDESC` is the dynamic-linker // relocation emitted after linking, so it is intentionally excluded here. matches!( - relocation_type, + relocation_type.0, abi::R_X86_64_GOTPC32_TLSDESC | abi::R_X86_64_TLSDESC_CALL ) } #[cfg(target_arch = "aarch64")] -fn is_tlsdesc_object_relocation(relocation_type: u32) -> bool { +fn is_tlsdesc_object_relocation(relocation_type: RelocationType) -> bool { // These are object-file TLSDESC relocations. `R_AARCH64_TLSDESC` is the dynamic-linker // relocation emitted after linking, so it is intentionally excluded here. matches!( - relocation_type, + relocation_type.0, abi::R_AARCH64_TLSDESC_LD_PREL19 | abi::R_AARCH64_TLSDESC_ADR_PREL21 | abi::R_AARCH64_TLSDESC_ADR_PAGE21 @@ -383,14 +342,207 @@ fn is_tlsdesc_object_relocation(relocation_type: u32) -> bool { ) } -fn format_relocations(relocations: &[(String, Vec)]) -> String { +#[derive(Debug)] +struct RawRelocation { + offset: u64, + relocation_type: RelocationType, + addend: i64, +} + +#[cfg(target_arch = "x86_64")] +const TLSDESC_RELOCATIONS_PER_ACCESS: usize = 2; + +#[cfg(target_arch = "aarch64")] +const TLSDESC_RELOCATIONS_PER_ACCESS: usize = 4; + +#[cfg(target_arch = "x86_64")] +fn tlsdesc_sequence_bounds(relocations: &[RawRelocation], section_len: usize) -> (usize, usize) { + let first_offset = usize::try_from(relocations[0].offset) + .expect("first relocation offset does not fit in usize"); + let call_offset = usize::try_from(relocations[1].offset) + .expect("call relocation offset does not fit in usize"); + let start = first_offset + .checked_sub(3) + .expect("x86-64 TLSDESC relocation offset is before the LEA instruction displacement"); + let end = call_offset + 11; + assert!( + end <= section_len, + "x86-64 TLSDESC sequence extends beyond section data" + ); + (start, end) +} + +#[cfg(target_arch = "aarch64")] +fn tlsdesc_sequence_bounds(relocations: &[RawRelocation], section_len: usize) -> (usize, usize) { + let first_offset = usize::try_from(relocations[0].offset) + .expect("first relocation offset does not fit in usize"); + let start = first_offset + .checked_sub(4) + .expect("AArch64 TLSDESC relocation offset is before the TPIDR_EL0 read"); + let last_offset = usize::try_from(relocations[relocations.len() - 1].offset) + .expect("last relocation offset does not fit in usize"); + let end = last_offset + 8; + assert!( + end <= section_len, + "AArch64 TLSDESC sequence extends beyond section data" + ); + (start, end) +} + +fn tlsdesc_sequence_from_relocations( + section_data: &[u8], + relocations: &[RawRelocation], +) -> TlsDescSequence { + let (start, end) = tlsdesc_sequence_bounds(relocations, section_data.len()); + TlsDescSequence { + bytes: section_data[start..end].to_vec(), + relocations: relocations + .iter() + .map(|relocation| TlsDescRelocation { + offset: usize::try_from(relocation.offset) + .expect("relocation offset does not fit in usize") + - start, + relocation_type: relocation.relocation_type, + addend: relocation.addend, + }) + .collect(), + } +} + +fn tlsdesc_sequences_for_symbol_in_elf( + data: &[u8], + symbol: &str, + label: &str, +) -> Vec { + let elf = parse_elf(data, label); + let Some(section_headers) = elf.section_headers() else { + panic!("{label} has no ELF section headers"); + }; + let mut sequences = Vec::new(); + + for section_header in section_headers + .iter() + .filter(|shdr| matches!(shdr.sh_type, abi::SHT_REL | abi::SHT_RELA)) + { + let symbol_indexes = + symbol_indexes_in_table(&elf, section_header.sh_link as usize, symbol, label); + if symbol_indexes.is_empty() { + continue; + } + + let target_header = section_headers + .get(section_header.sh_info as usize) + .unwrap_or_else(|e| panic!("failed to read relocation target section header: {e}")); + let (target_data, _) = elf + .section_data(&target_header) + .unwrap_or_else(|e| panic!("failed to read relocation target section in {label}: {e}")); + let mut relocations = Vec::new(); + + match section_header.sh_type { + abi::SHT_REL => { + let rels = elf + .section_data_as_rels(§ion_header) + .unwrap_or_else(|e| panic!("failed to read REL relocations in {label}: {e}")); + relocations.extend( + rels.filter(|rel| { + symbol_indexes.contains(&rel.r_sym) + && is_tlsdesc_object_relocation(RelocationType(rel.r_type)) + }) + .map(|rel| RawRelocation { + offset: rel.r_offset, + relocation_type: RelocationType(rel.r_type), + addend: 0, + }), + ); + } + abi::SHT_RELA => { + let relas = elf + .section_data_as_relas(§ion_header) + .unwrap_or_else(|e| panic!("failed to read RELA relocations in {label}: {e}")); + relocations.extend( + relas + .filter(|rela| { + symbol_indexes.contains(&rela.r_sym) + && is_tlsdesc_object_relocation(RelocationType(rela.r_type)) + }) + .map(|rela| RawRelocation { + offset: rela.r_offset, + relocation_type: RelocationType(rela.r_type), + addend: rela.r_addend, + }), + ); + } + _ => unreachable!(), + } + + relocations.sort_by_key(|relocation| relocation.offset); + assert!( + relocations.len() % TLSDESC_RELOCATIONS_PER_ACCESS == 0, + "expected TLSDESC relocations for {symbol} in {label} to come in groups of \ + {TLSDESC_RELOCATIONS_PER_ACCESS}; found {relocations:?}" + ); + + sequences.extend( + relocations + .chunks_exact(TLSDESC_RELOCATIONS_PER_ACCESS) + .map(|chunk| tlsdesc_sequence_from_relocations(target_data, chunk)), + ); + } + + sequences +} + +fn tlsdesc_sequences_for_symbol_in_file(path: &Path, symbol: &str) -> Vec { + let data = + std::fs::read(path).unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display())); + tlsdesc_sequences_for_symbol_in_elf(&data, symbol, &path.display().to_string()) +} + +fn archive_tlsdesc_sequences_for_symbol( + path: &Path, + symbol: &str, +) -> Vec<(String, TlsDescSequence)> { + let mut sequences = Vec::new(); + + for_each_archive_elf_member(path, |member_name, member_data| { + let label = format!("{}({member_name})", path.display()); + sequences.extend( + tlsdesc_sequences_for_symbol_in_elf(member_data, symbol, &label) + .into_iter() + .map(|sequence| (member_name.to_owned(), sequence)), + ); + }); + + sequences +} + +fn compile_tls_shim_object(dir: &Path) -> PathBuf { + let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/tls_shim.c"); + let object = dir.join("tls_shim.o"); + let mut compile_object = Command::new("cc"); + compile_object.args(["-O2", "-fPIC", "-fomit-frame-pointer", "-c"]); + + #[cfg(target_arch = "x86_64")] + compile_object.arg("-mtls-dialect=gnu2"); + + compile_object.arg(&source).arg("-o").arg(&object); + assert_command_success(&mut compile_object); + object +} + +fn format_relocations(relocations: &[ArchiveMemberRelocations]) -> String { if relocations.is_empty() { return "".to_owned(); } relocations .iter() - .map(|(name, types)| format!("{name}: {types:?}")) + .map(|relocations| { + format!( + "{}: {:?}", + relocations.member_name, relocations.relocation_types + ) + }) .collect::>() .join("\n") } @@ -460,6 +612,53 @@ fn disassembly_window_around_line( lines[start..end].join("\n") } +#[test] +#[cfg_attr(miri, ignore)] +#[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))] +fn tlsdesc_inline_assembly_matches_c_compiler_sequence() { + if !native_target() || skip_tls_shim_asm_test() { + return; + } + + if !required_tools_available(&["cc"]) { + return; + } + + let staticlib = staticlib_path(); + check_readable(&staticlib); + + let dir = build_dir("otel-thread-ctx-tls-shim"); + let c_object = compile_tls_shim_object(&dir); + let c_sequences = tlsdesc_sequences_for_symbol_in_file(&c_object, SYMBOL); + assert_eq!( + c_sequences.len(), + 1, + "expected one compiler-generated TLSDESC access in {}; found {c_sequences:?}. \ + Set {SKIP_TLS_SHIM_ASM_TEST_ENV}=1 to skip this guard with a different local compiler.", + c_object.display() + ); + let expected = &c_sequences[0]; + + let rust_sequences = archive_tlsdesc_sequences_for_symbol(&staticlib, SYMBOL); + assert!( + !rust_sequences.is_empty(), + "expected at least one Rust inline-asm TLSDESC access for {SYMBOL} in {}", + staticlib.display() + ); + + for (member_name, sequence) in rust_sequences { + assert_eq!( + &sequence, + expected, + "Rust inline assembly TLSDESC sequence in {}({member_name}) does not match \ + compiler output from {}. Set {SKIP_TLS_SHIM_ASM_TEST_ENV}=1 to skip this guard with \ + a different local compiler.", + staticlib.display(), + c_object.display() + ); + } +} + #[test] #[cfg_attr(miri, ignore)] fn otel_thread_ctx_v1_tls_properties() { @@ -521,9 +720,10 @@ int main(void) { let staticlib_relocations = archive_relocation_types_for_symbol(&staticlib, SYMBOL); assert!( - staticlib_relocations + staticlib_relocations.iter().any(|relocations| relocations + .relocation_types .iter() - .any(|(_, types)| types.iter().any(|t| is_tlsdesc_object_relocation(*t))), + .any(|t| is_tlsdesc_object_relocation(*t))), "expected an object-file TLSDESC relocation for {SYMBOL} in {}\nfound:\n{}", staticlib.display(), format_relocations(&staticlib_relocations) diff --git a/libdd-otel-thread-ctx-ffi/tests/tls_shim.c b/libdd-otel-thread-ctx-ffi/tests/tls_shim.c new file mode 100644 index 0000000000..cf31f150a5 --- /dev/null +++ b/libdd-otel-thread-ctx-ffi/tests/tls_shim.c @@ -0,0 +1,8 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +extern __thread void *otel_thread_ctx_v1 __attribute__((tls_model("global-dynamic"))); + +__attribute__((noinline)) void **tls_slot_from_c(void) { + return &otel_thread_ctx_v1; +} diff --git a/libdd-otel-thread-ctx/src/lib.rs b/libdd-otel-thread-ctx/src/lib.rs index b0fb2c8396..756d8c7a48 100644 --- a/libdd-otel-thread-ctx/src/lib.rs +++ b/libdd-otel-thread-ctx/src/lib.rs @@ -125,13 +125,14 @@ pub mod linux { unsafe fn tls_slot() -> *mut *mut ThreadContextRecord { let ptr: usize; core::arch::asm!( + "mrs x1, tpidr_el0", "adrp x0, :tlsdesc:otel_thread_ctx_v1", - "ldr x1, [x0, :tlsdesc_lo12:otel_thread_ctx_v1]", + "ldr x2, [x0, :tlsdesc_lo12:otel_thread_ctx_v1]", "add x0, x0, :tlsdesc_lo12:otel_thread_ctx_v1", ".tlsdesccall otel_thread_ctx_v1", - "blr x1", - "mrs x2, tpidr_el0", - "add x0, x0, x2", + // x1 is guaranteed not to be clobbered by the call + "blr x2", + "add x0, x1, x0", out("x0") ptr, out("x1") _, out("x2") _, From 4a6a2d0b73281324f57703fb7540b8ef7fb1c893 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gustavo=20Andr=C3=A9=20dos=20Santos=20Lopes?= Date: Thu, 18 Jun 2026 22:47:37 +0100 Subject: [PATCH 05/12] address review comments --- libdd-otel-thread-ctx-ffi/README.md | 6 - .../tests/elf_properties.rs | 328 +----------------- libdd-otel-thread-ctx/src/lib.rs | 11 +- 3 files changed, 12 insertions(+), 333 deletions(-) diff --git a/libdd-otel-thread-ctx-ffi/README.md b/libdd-otel-thread-ctx-ffi/README.md index 936e1e15fe..9957554043 100644 --- a/libdd-otel-thread-ctx-ffi/README.md +++ b/libdd-otel-thread-ctx-ffi/README.md @@ -5,9 +5,3 @@ attaching, detaching, and updating per-thread OpenTelemetry context records that external readers (e.g. the eBPF profiler) can discover. Currently Linux-only (x86-64 and aarch64). - -## TLS - -The thread-local variable `otel_thread_ctx_v1` and its TLSDESC accessor are -implemented in pure Rust using `global_asm!` and `asm!` in the -`libdd-otel-thread-ctx` crate. diff --git a/libdd-otel-thread-ctx-ffi/tests/elf_properties.rs b/libdd-otel-thread-ctx-ffi/tests/elf_properties.rs index 28cbcdccf5..5c4b696758 100644 --- a/libdd-otel-thread-ctx-ffi/tests/elf_properties.rs +++ b/libdd-otel-thread-ctx-ffi/tests/elf_properties.rs @@ -10,14 +10,16 @@ //! - `otel_thread_ctx_v1` is exported in the dynamic symbol table as a TLS GLOBAL symbol. //! - `otel_thread_ctx_v1` follows the TLSDESC access model: if there is a relocation for it, it is //! a TLSDESC relocation. -//! - A native executable that statically links libdd-otel-thread-ctx-ffi without exporting -//! `otel_thread_ctx_v1` has libdd's TLSDESC access relaxed to local-exec TLS, leaving no -//! relocation for `otel_thread_ctx_v1`. +//! - The Rust inline-asm TLSDESC access sequence byte-for-byte matches what a C compiler generates +//! (guaranteeing that linker TLS relaxation works identically to a compiler-generated access). //! //! Library artifact paths are derived at runtime from the test executable location. //! The test binary and crate artifacts live in `target/<[triple/]profile>/deps/`. -#![cfg(target_os = "linux")] +#![cfg(all( + target_os = "linux", + any(target_arch = "x86_64", target_arch = "aarch64") +))] use std::{ fmt, @@ -54,12 +56,6 @@ struct TlsDescSequence { relocations: Vec, } -#[derive(Debug)] -struct ArchiveMemberRelocations { - member_name: String, - relocation_types: Vec, -} - fn deps_dir() -> PathBuf { // test binary: target/<[triple/]profile>/deps/ let exe = std::env::current_exe().expect("failed to read current executable path"); @@ -124,26 +120,6 @@ fn skip_tls_shim_asm_test() -> bool { skip } -fn command_output(command: &mut Command) -> String { - let out = command - .output() - .unwrap_or_else(|e| panic!("failed to run {command:?}: {e}")); - assert!( - out.status.success(), - "{command:?} failed with status {}\nstdout:\n{}\nstderr:\n{}", - out.status, - String::from_utf8_lossy(&out.stdout), - String::from_utf8_lossy(&out.stderr) - ); - String::from_utf8_lossy(&out.stdout).into_owned() -} - -fn objdump(args: &[&str], path: &Path) -> String { - let mut command = Command::new("objdump"); - command.args(args).arg(path); - command_output(&mut command) -} - fn assert_command_success(command: &mut Command) { let out = command .output() @@ -213,77 +189,6 @@ fn symbol_indexes_in_table( .collect() } -fn relocation_types_for_symbol_in_elf( - data: &[u8], - symbol: &str, - label: &str, -) -> Vec { - let elf = parse_elf(data, label); - let Some(section_headers) = elf.section_headers() else { - panic!("{label} has no ELF section headers"); - }; - let mut relocation_types = Vec::new(); - - for section_header in section_headers - .iter() - .filter(|shdr| matches!(shdr.sh_type, abi::SHT_REL | abi::SHT_RELA)) - { - let symbol_indexes = - symbol_indexes_in_table(&elf, section_header.sh_link as usize, symbol, label); - if symbol_indexes.is_empty() { - continue; - } - - match section_header.sh_type { - abi::SHT_REL => { - let rels = elf - .section_data_as_rels(§ion_header) - .unwrap_or_else(|e| panic!("failed to read REL relocations in {label}: {e}")); - relocation_types.extend( - rels.filter(|rel| symbol_indexes.contains(&rel.r_sym)) - .map(|rel| RelocationType(rel.r_type)), - ); - } - abi::SHT_RELA => { - let relas = elf - .section_data_as_relas(§ion_header) - .unwrap_or_else(|e| panic!("failed to read RELA relocations in {label}: {e}")); - relocation_types.extend( - relas - .filter(|rela| symbol_indexes.contains(&rela.r_sym)) - .map(|rela| RelocationType(rela.r_type)), - ); - } - _ => unreachable!(), - } - } - - relocation_types -} - -fn relocation_types_for_symbol_in_file(path: &Path, symbol: &str) -> Vec { - let data = - std::fs::read(path).unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display())); - relocation_types_for_symbol_in_elf(&data, symbol, &path.display().to_string()) -} - -fn archive_relocation_types_for_symbol(path: &Path, symbol: &str) -> Vec { - let mut relocations = Vec::new(); - - for_each_archive_elf_member(path, |member_name, member_data| { - let label = format!("{}({member_name})", path.display()); - let relocation_types = relocation_types_for_symbol_in_elf(member_data, symbol, &label); - if !relocation_types.is_empty() { - relocations.push(ArchiveMemberRelocations { - member_name: member_name.to_owned(), - relocation_types, - }); - } - }); - - relocations -} - fn for_each_archive_elf_member(path: &Path, mut f: impl FnMut(&str, &[u8])) { let archive_data = std::fs::read(path).unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display())); @@ -530,88 +435,6 @@ fn compile_tls_shim_object(dir: &Path) -> PathBuf { object } -fn format_relocations(relocations: &[ArchiveMemberRelocations]) -> String { - if relocations.is_empty() { - return "".to_owned(); - } - - relocations - .iter() - .map(|relocations| { - format!( - "{}: {:?}", - relocations.member_name, relocations.relocation_types - ) - }) - .collect::>() - .join("\n") -} - -fn is_disassembly_header_for(line: &str, name: &str) -> bool { - let Some((_, symbol)) = line.split_once('<') else { - return false; - }; - let Some(symbol) = symbol.strip_suffix(">:") else { - return false; - }; - symbol == name - || symbol - .strip_prefix(name) - .is_some_and(|suffix| suffix.starts_with("::")) -} - -fn disassembled_functions(output: &str, name: &str) -> Vec { - let mut functions = Vec::new(); - let mut current_function = Vec::new(); - - for line in output.lines() { - if is_disassembly_header_for(line, name) { - if !current_function.is_empty() { - functions.push(current_function.join("\n")); - current_function.clear(); - } - current_function.push(line); - continue; - } - - if !current_function.is_empty() { - if line.is_empty() { - functions.push(current_function.join("\n")); - current_function.clear(); - continue; - } - current_function.push(line); - } - } - - if !current_function.is_empty() { - functions.push(current_function.join("\n")); - } - - assert!( - !functions.is_empty(), - "could not find disassembly for {name} in:\n{output}" - ); - functions -} - -#[cfg(target_arch = "aarch64")] -fn disassembly_window_around_line( - function: &str, - needle: &str, - before: usize, - after: usize, -) -> String { - let lines = function.lines().collect::>(); - let line_index = lines - .iter() - .position(|line| line.contains(needle)) - .unwrap_or_else(|| panic!("could not find {needle:?} in:\n{function}")); - let start = line_index.saturating_sub(before); - let end = usize::min(line_index + after + 1, lines.len()); - lines[start..end].join("\n") -} - #[test] #[cfg_attr(miri, ignore)] #[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))] @@ -666,142 +489,3 @@ fn otel_thread_ctx_v1_tls_properties() { check_readable(&path); libdd_otel_thread_ctx::sanity_check::check_tls_slot_in(&path).unwrap(); } - -#[test] -#[cfg_attr(miri, ignore)] -#[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))] -fn statically_linked_executable_relaxes_libdd_tls_slot_to_local_exec() { - if !native_target() { - return; - } - - if !required_tools_available(&["cc", "objdump"]) { - return; - } - - let staticlib = staticlib_path(); - check_readable(&staticlib); - - let dir = build_dir("otel-thread-ctx-local-exec"); - let source = dir.join("consumer.c"); - let object = dir.join("consumer.o"); - let executable = dir.join("consumer"); - std::fs::write( - &source, - r#" -#include - -void ddog_otel_thread_ctx_update( - const uint8_t (*trace_id)[16], - const uint8_t (*span_id)[8], - const uint8_t (*local_root_span_id)[8]); -void *ddog_otel_thread_ctx_detach(void); -void ddog_otel_thread_ctx_free(void *ctx); - -int main(void) { - uint8_t trace_id[16] = {1}; - uint8_t span_id[8] = {2}; - uint8_t local_root_span_id[8] = {3}; - - ddog_otel_thread_ctx_update(&trace_id, &span_id, &local_root_span_id); - void *ctx = ddog_otel_thread_ctx_detach(); - ddog_otel_thread_ctx_free(ctx); - - return ctx == 0 ? 1 : 0; -} -"#, - ) - .unwrap_or_else(|e| panic!("failed to write {}: {e}", source.display())); - - let mut compile_object = Command::new("cc"); - compile_object.args(["-O2", "-ffunction-sections", "-fdata-sections"]); - compile_object.arg("-c").arg(&source).arg("-o").arg(&object); - assert_command_success(&mut compile_object); - - let staticlib_relocations = archive_relocation_types_for_symbol(&staticlib, SYMBOL); - assert!( - staticlib_relocations.iter().any(|relocations| relocations - .relocation_types - .iter() - .any(|t| is_tlsdesc_object_relocation(*t))), - "expected an object-file TLSDESC relocation for {SYMBOL} in {}\nfound:\n{}", - staticlib.display(), - format_relocations(&staticlib_relocations) - ); - - let object_relocations = relocation_types_for_symbol_in_file(&object, SYMBOL); - assert!( - object_relocations.is_empty(), - "expected generated C object to have no relocations for {SYMBOL}; found {object_relocations:?}" - ); - - let mut link_executable = Command::new("cc"); - link_executable - .arg(&object) - .arg(&staticlib) - .args([ - "-Wl,--gc-sections", - "-lpthread", - "-ldl", - "-lm", - "-lrt", - "-lutil", - ]) - .arg("-o") - .arg(&executable); - assert_command_success(&mut link_executable); - - // Run the generated executable so the test validates the relaxed TLS access at runtime too. - let mut run_executable = Command::new(&executable); - assert_command_success(&mut run_executable); - - let executable_relocations = relocation_types_for_symbol_in_file(&executable, SYMBOL); - assert!( - executable_relocations.is_empty(), - "expected no remaining relocations for {SYMBOL} in {}; found {executable_relocations:?}", - executable.display() - ); - - let disassembly = objdump(&["-drwC"], &executable); - let tls_slot_functions = - disassembled_functions(&disassembly, "libdd_otel_thread_ctx::linux::with_tls_slot"); - - #[cfg(target_arch = "x86_64")] - { - assert!( - tls_slot_functions - .iter() - .any(|function| function.contains("%fs:0x0")), - "expected tls_slot() in libdd-otel-thread-ctx to be relaxed to local-exec x86-64 \ - TLS access through %fs:0x0\n{}", - tls_slot_functions.join("\n\n") - ); - assert!( - tls_slot_functions - .iter() - .all(|function| !function.contains("tlsdesc")), - "expected linker-relaxed local-exec TLS code without TLSDESC operands:\n{}", - tls_slot_functions.join("\n\n") - ); - } - - #[cfg(target_arch = "aarch64")] - { - let function = tls_slot_functions - .iter() - .find(|function| function.contains("tpidr_el0")) - .unwrap_or_else(|| { - panic!( - "expected tls_slot() in libdd-otel-thread-ctx to use tpidr_el0 after \ - relaxation\n{}", - tls_slot_functions.join("\n\n") - ) - }); - let window = disassembly_window_around_line(function, "tpidr_el0", 4, 3); - assert!( - !window.contains("tlsdesc") && !window.contains("\tblr"), - "expected linker-relaxed local-exec TLS code around tpidr_el0 without a TLSDESC call:\n\ - {window}" - ); - } -} diff --git a/libdd-otel-thread-ctx/src/lib.rs b/libdd-otel-thread-ctx/src/lib.rs index 756d8c7a48..1f252adaf1 100644 --- a/libdd-otel-thread-ctx/src/lib.rs +++ b/libdd-otel-thread-ctx/src/lib.rs @@ -82,11 +82,6 @@ pub mod linux { // Stable `rustc` cannot select the TLS dialect for a `#[thread_local]` static, so we declare // the symbol directly in assembly (an 8-byte, zero-initialised slot in `.tbss`) and resolve // its per-thread address through TLSDESC in [`tls_slot`]. - // - // WARNING: keep the assembly below in the canonical compiler-emitted TLSDESC form. Linkers - // rely on these exact relocation-bearing instruction patterns for TLS relaxation, especially - // when this crate is linked statically. Harmless-looking rewrites can hide part of the sequence - // from the linker and produce a partially relaxed access that computes an invalid TLS address. #[cfg(all( target_os = "linux", any(target_arch = "x86_64", target_arch = "aarch64") @@ -108,6 +103,11 @@ pub mod linux { #[inline(always)] unsafe fn tls_slot() -> *mut *mut ThreadContextRecord { let ptr: usize; + // WARNING: keep the assembly below in the canonical compiler-emitted TLSDESC form. Linkers + // rely on these exact relocation-bearing instruction patterns for TLS relaxation, + // especially when this crate is linked statically. Harmless-looking rewrites can hide part + // of the sequence from the linker and produce a partially relaxed access that computes an + // invalid TLS address. core::arch::asm!( "leaq otel_thread_ctx_v1@tlsdesc(%rip), %rax", "call *otel_thread_ctx_v1@TLSCALL(%rax)", @@ -124,6 +124,7 @@ pub mod linux { #[inline(always)] unsafe fn tls_slot() -> *mut *mut ThreadContextRecord { let ptr: usize; + // WARNING: do not change the assembly below. See the warning above for amd64. core::arch::asm!( "mrs x1, tpidr_el0", "adrp x0, :tlsdesc:otel_thread_ctx_v1", From d726f8d89d2118f7edfc2cd9eac18d9d03d03fa9 Mon Sep 17 00:00:00 2001 From: Yann Hamdaoui Date: Mon, 6 Jul 2026 11:21:11 +0200 Subject: [PATCH 06/12] refactor(otel-thread-ctx): replace build script with in-code arch check The libdd-otel-thread-ctx build script only validated the target OS/arch and panicked on unsupported Linux architectures. Move that into a compile_error! gated on cfg and drop the build script entirely. The linux module is now also gated on x86_64/aarch64 so an unsupported Linux arch produces a single clean error instead of a cascade from the module body. Co-Authored-By: Claude Opus 4.8 (1M context) --- libdd-otel-thread-ctx/build.rs | 20 -------------------- libdd-otel-thread-ctx/src/lib.rs | 21 ++++++++++++++++----- 2 files changed, 16 insertions(+), 25 deletions(-) delete mode 100644 libdd-otel-thread-ctx/build.rs diff --git a/libdd-otel-thread-ctx/build.rs b/libdd-otel-thread-ctx/build.rs deleted file mode 100644 index a9082c9aaa..0000000000 --- a/libdd-otel-thread-ctx/build.rs +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ -// SPDX-License-Identifier: Apache-2.0 - -use std::env; - -fn main() { - let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap(); - let target_arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap(); - - if target_os != "linux" { - return; - } - - if !matches!(target_arch.as_str(), "x86_64" | "aarch64") { - panic!( - "Unsupported architecture `{}` for otel-thread-ctx on Linux. Only x86_64 and aarch64 are currently supported.", - target_arch - ) - } -} diff --git a/libdd-otel-thread-ctx/src/lib.rs b/libdd-otel-thread-ctx/src/lib.rs index 1f252adaf1..897a8307f9 100644 --- a/libdd-otel-thread-ctx/src/lib.rs +++ b/libdd-otel-thread-ctx/src/lib.rs @@ -64,10 +64,25 @@ //! `atomic_signal_fence`) to keep field writes boxed between the `valid = 0` and `valid = 1` //! stores during in-place updates. +// The `linux` module below resolves the TLS slot with TLSDESC inline assembly that is only written +// for x86_64 and aarch64. Reject any other architecture on Linux at compile time. On non-Linux +// targets the `linux` module is not compiled, so there's no such constraint. +#[cfg(all( + target_os = "linux", + not(any(target_arch = "x86_64", target_arch = "aarch64")) +))] +compile_error!( + "Unsupported architecture for otel-thread-ctx on Linux. Only x86_64 and aarch64 are currently \ + supported." +); + #[cfg(all(target_os = "linux", feature = "sanity-check"))] pub mod sanity_check; -#[cfg(target_os = "linux")] +#[cfg(all( + target_os = "linux", + any(target_arch = "x86_64", target_arch = "aarch64") +))] pub mod linux { use std::{ mem, @@ -82,10 +97,6 @@ pub mod linux { // Stable `rustc` cannot select the TLS dialect for a `#[thread_local]` static, so we declare // the symbol directly in assembly (an 8-byte, zero-initialised slot in `.tbss`) and resolve // its per-thread address through TLSDESC in [`tls_slot`]. - #[cfg(all( - target_os = "linux", - any(target_arch = "x86_64", target_arch = "aarch64") - ))] core::arch::global_asm!( ".section .tbss,\"awT\",@nobits", ".globl otel_thread_ctx_v1", From 9eb0be898ece625a55572121a941804755c47063 Mon Sep 17 00:00:00 2001 From: Yann Hamdaoui Date: Mon, 6 Jul 2026 11:54:50 +0200 Subject: [PATCH 07/12] doc: explain why we don't need to clobber registers for call instruction --- libdd-otel-thread-ctx/src/lib.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/libdd-otel-thread-ctx/src/lib.rs b/libdd-otel-thread-ctx/src/lib.rs index 897a8307f9..ab2dbcc2d0 100644 --- a/libdd-otel-thread-ctx/src/lib.rs +++ b/libdd-otel-thread-ctx/src/lib.rs @@ -114,6 +114,7 @@ pub mod linux { #[inline(always)] unsafe fn tls_slot() -> *mut *mut ThreadContextRecord { let ptr: usize; + // // WARNING: keep the assembly below in the canonical compiler-emitted TLSDESC form. Linkers // rely on these exact relocation-bearing instruction patterns for TLS relaxation, // especially when this crate is linked statically. Harmless-looking rewrites can hide part @@ -123,6 +124,14 @@ pub mod linux { "leaq otel_thread_ctx_v1@tlsdesc(%rip), %rax", "call *otel_thread_ctx_v1@TLSCALL(%rax)", "addq %fs:0, %rax", + // There is a call instruction, but the whole point of TLSDESC is to use a fast calling + // convention. GCC's x86-64 port assumes that FLAGS_REG and RAX are changed while all + // other registers are preserved[^1]. LLVM similarly only clobbers RAX[^2] (and flags). + // So we don't need to clobber additional registers or to use `clobber_abi` here (which + // would negate most of the advantage of TLSDESC). + // + // [^1]: https://maskray.me/blog/2021-02-14-all-about-thread-local-storage + // [^2]: https://raw.githubusercontent.com/llvm/llvm-project/main/llvm/lib/Target/X86/X86InstrCompiler.td out("rax") ptr, options(att_syntax), ); @@ -145,6 +154,8 @@ pub mod linux { // x1 is guaranteed not to be clobbered by the call "blr x2", "add x0, x1, x0", + // As for x86, tlsdesccall is not clobbering other registers than `x0`, `x1` and `x30`, + // which are already declared as out. out("x0") ptr, out("x1") _, out("x2") _, From dbb0eebbd1de64e45228b224226dd502898f6eb1 Mon Sep 17 00:00:00 2001 From: Yann Hamdaoui Date: Mon, 6 Jul 2026 13:10:08 +0200 Subject: [PATCH 08/12] fix: use proper register following the official ABI --- libdd-otel-thread-ctx/src/lib.rs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/libdd-otel-thread-ctx/src/lib.rs b/libdd-otel-thread-ctx/src/lib.rs index ab2dbcc2d0..22bc2d064e 100644 --- a/libdd-otel-thread-ctx/src/lib.rs +++ b/libdd-otel-thread-ctx/src/lib.rs @@ -144,18 +144,19 @@ pub mod linux { #[inline(always)] unsafe fn tls_slot() -> *mut *mut ThreadContextRecord { let ptr: usize; - // WARNING: do not change the assembly below. See the warning above for amd64. + // WARNING: do not change the assembly below. See the warning above for amd64, and + // https://github.com/ARM-software/abi-aa/blob/main/sysvabi64/sysvabi64.rst#general-dynamic. core::arch::asm!( - "mrs x1, tpidr_el0", + "mrs x2, tpidr_el0", "adrp x0, :tlsdesc:otel_thread_ctx_v1", - "ldr x2, [x0, :tlsdesc_lo12:otel_thread_ctx_v1]", + "ldr x1, [x0, :tlsdesc_lo12:otel_thread_ctx_v1]", "add x0, x0, :tlsdesc_lo12:otel_thread_ctx_v1", ".tlsdesccall otel_thread_ctx_v1", - // x1 is guaranteed not to be clobbered by the call - "blr x2", - "add x0, x1, x0", - // As for x86, tlsdesccall is not clobbering other registers than `x0`, `x1` and `x30`, - // which are already declared as out. + // x2 is guaranteed not to be clobbered by the call + "blr x1", + "add x0, x2, x0", + // .tlsdesccall is not clobbering other registers than `x0`, `x1` and `x30`, which are + // already declared as out/clobbered. out("x0") ptr, out("x1") _, out("x2") _, From 0dc412ce3cb6aeaab7744e0bda9dc0e74447cbe6 Mon Sep 17 00:00:00 2001 From: Yann Hamdaoui Date: Mon, 6 Jul 2026 15:05:43 +0200 Subject: [PATCH 09/12] fix: instruction comparison test --- .../tests/elf_properties.rs | 70 ++++++++++++++++--- 1 file changed, 61 insertions(+), 9 deletions(-) diff --git a/libdd-otel-thread-ctx-ffi/tests/elf_properties.rs b/libdd-otel-thread-ctx-ffi/tests/elf_properties.rs index 5c4b696758..b124d2d52c 100644 --- a/libdd-otel-thread-ctx-ffi/tests/elf_properties.rs +++ b/libdd-otel-thread-ctx-ffi/tests/elf_properties.rs @@ -10,8 +10,9 @@ //! - `otel_thread_ctx_v1` is exported in the dynamic symbol table as a TLS GLOBAL symbol. //! - `otel_thread_ctx_v1` follows the TLSDESC access model: if there is a relocation for it, it is //! a TLSDESC relocation. -//! - The Rust inline-asm TLSDESC access sequence byte-for-byte matches what a C compiler generates -//! (guaranteeing that linker TLS relaxation works identically to a compiler-generated access). +//! - The Rust inline-asm TLSDESC access sequence matches what a C compiler generates almost +//! byte-by-byte (up to the scratch register for reading the thread pointer), guaranteeing that +//! linker TLS relaxation works identically to a compiler-generated access. //! //! Library artifact paths are derived at runtime from the test executable location. //! The test binary and crate artifacts live in `target/<[triple/]profile>/deps/`. @@ -279,14 +280,19 @@ fn tlsdesc_sequence_bounds(relocations: &[RawRelocation], section_len: usize) -> #[cfg(target_arch = "aarch64")] fn tlsdesc_sequence_bounds(relocations: &[RawRelocation], section_len: usize) -> (usize, usize) { - let first_offset = usize::try_from(relocations[0].offset) + // The core of the sequence is the four instructions `adrp`, `ldr`, `add` and (`.tlsdesccall`) + // `blr`, which all have an associated relocation. + // + // The first relocation is on the `adrp`, the last on the `blr`. The thread-pointer read (`mrs`) + // and the final `add` follow the `blr`. The window starts at the first relocation (not a fixed + // instruction before it) and extends past the `blr` to cover the trailing `mrs`/`add` (which + // don't have associated relocations). + let start = usize::try_from(relocations[0].offset) .expect("first relocation offset does not fit in usize"); - let start = first_offset - .checked_sub(4) - .expect("AArch64 TLSDESC relocation offset is before the TPIDR_EL0 read"); let last_offset = usize::try_from(relocations[relocations.len() - 1].offset) .expect("last relocation offset does not fit in usize"); - let end = last_offset + 8; + // From the `blr` (last relocation): `blr`, `mrs`, `add` makes up for three 4-byte instructions. + let end = last_offset + 4 + 4 + 4; assert!( end <= section_len, "AArch64 TLSDESC sequence extends beyond section data" @@ -294,13 +300,47 @@ fn tlsdesc_sequence_bounds(relocations: &[RawRelocation], section_len: usize) -> (start, end) } +/// Mask out the register fields of the thread-pointer read and final addition so the byte +/// comparison ignores which scratch register holds the thread pointer. +/// +/// TLS relaxation only cares about the relocation-bearing core (`adrp`/`ldr`/`add`/`.tlsdesccall` +/// +`blr`), which must match byte-for-byte. The register used for the thread-pointer read is +/// irrelevant: our inline asm re-uses `x1` while GCC/Clang usually picks a fresh register when +/// available. +/// +/// AArch64 instructions are fixed 32-bit little-endian words: +/// - `mrs Xd, TPIDR_EL0` encodes its destination register `Xd` in bits [4:0]. +/// - `add x0, Xn, x0` encodes its first source register `Xn` in bits [9:5]. +#[cfg(target_arch = "aarch64")] +fn normalize_aarch64_tp_registers(bytes: &mut [u8], relocations: &[RawRelocation], start: usize) { + let last_offset = usize::try_from(relocations[relocations.len() - 1].offset) + .expect("last relocation offset does not fit in usize") + - start; + mask_instruction_bits(bytes, last_offset + 4, 0x0000_001F); // `mrs` destination register + mask_instruction_bits(bytes, last_offset + 8, 0x0000_03E0); // `add` first source register +} + +/// Clear the bits set in `mask` from the 32-bit little-endian instruction word at `offset`. +#[cfg(target_arch = "aarch64")] +fn mask_instruction_bits(bytes: &mut [u8], offset: usize, mask: u32) { + let word: [u8; 4] = bytes[offset..offset + 4] + .try_into() + .expect("instruction word extends beyond the extracted sequence"); + let masked = u32::from_le_bytes(word) & !mask; + bytes[offset..offset + 4].copy_from_slice(&masked.to_le_bytes()); +} + fn tlsdesc_sequence_from_relocations( section_data: &[u8], relocations: &[RawRelocation], ) -> TlsDescSequence { let (start, end) = tlsdesc_sequence_bounds(relocations, section_data.len()); + #[cfg_attr(not(target_arch = "aarch64"), allow(unused_mut))] + let mut bytes = section_data[start..end].to_vec(); + #[cfg(target_arch = "aarch64")] + normalize_aarch64_tp_registers(&mut bytes, relocations, start); TlsDescSequence { - bytes: section_data[start..end].to_vec(), + bytes, relocations: relocations .iter() .map(|relocation| TlsDescRelocation { @@ -439,11 +479,23 @@ fn compile_tls_shim_object(dir: &Path) -> PathBuf { #[cfg_attr(miri, ignore)] #[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))] fn tlsdesc_inline_assembly_matches_c_compiler_sequence() { - if !native_target() || skip_tls_shim_asm_test() { + fn print_skip_msg(label: &str) { + eprintln!("WARNING: {label}. Skipping inline assembly matches C compiler sequence test"); + } + + if !native_target() { + print_skip_msg("cross-compilation detected"); + return; + } + if skip_tls_shim_asm_test() { + print_skip_msg(&format!( + "{SKIP_TLS_SHIM_ASM_TEST_ENV} environment varialbe set" + )); return; } if !required_tools_available(&["cc"]) { + print_skip_msg("no C compiler available"); return; } From 762bbe0f030eb83f1645f1471a4d328fda596665 Mon Sep 17 00:00:00 2001 From: Yann Hamdaoui Date: Mon, 6 Jul 2026 15:06:06 +0200 Subject: [PATCH 10/12] refactor: use one less register and match gcc sequence better --- libdd-otel-thread-ctx/src/lib.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/libdd-otel-thread-ctx/src/lib.rs b/libdd-otel-thread-ctx/src/lib.rs index 22bc2d064e..d8aedbc202 100644 --- a/libdd-otel-thread-ctx/src/lib.rs +++ b/libdd-otel-thread-ctx/src/lib.rs @@ -147,19 +147,18 @@ pub mod linux { // WARNING: do not change the assembly below. See the warning above for amd64, and // https://github.com/ARM-software/abi-aa/blob/main/sysvabi64/sysvabi64.rst#general-dynamic. core::arch::asm!( - "mrs x2, tpidr_el0", "adrp x0, :tlsdesc:otel_thread_ctx_v1", "ldr x1, [x0, :tlsdesc_lo12:otel_thread_ctx_v1]", "add x0, x0, :tlsdesc_lo12:otel_thread_ctx_v1", ".tlsdesccall otel_thread_ctx_v1", - // x2 is guaranteed not to be clobbered by the call "blr x1", - "add x0, x2, x0", - // .tlsdesccall is not clobbering other registers than `x0`, `x1` and `x30`, which are - // already declared as out/clobbered. + // Read the thread pointer after the TLSDESC call, mirroring the sequence GCC/Clang + // emit. We reuse x1 to avoid register pressure on the surrounding Rust code. It's dead once + // the call returns anyway and we already clobbered it at this point. + "mrs x1, tpidr_el0", + "add x0, x1, x0", out("x0") ptr, out("x1") _, - out("x2") _, out("x30") _, ); ptr as *mut *mut ThreadContextRecord From 744c7f1b15adae55155e4daefd01f518a35f498f Mon Sep 17 00:00:00 2001 From: Yann Hamdaoui Date: Mon, 6 Jul 2026 17:53:17 +0200 Subject: [PATCH 11/12] fix: relax the compiler test to work with both clang and gcc --- .../tests/elf_properties.rs | 333 +++++++++++++----- 1 file changed, 250 insertions(+), 83 deletions(-) diff --git a/libdd-otel-thread-ctx-ffi/tests/elf_properties.rs b/libdd-otel-thread-ctx-ffi/tests/elf_properties.rs index b124d2d52c..b0f9debd82 100644 --- a/libdd-otel-thread-ctx-ffi/tests/elf_properties.rs +++ b/libdd-otel-thread-ctx-ffi/tests/elf_properties.rs @@ -10,9 +10,18 @@ //! - `otel_thread_ctx_v1` is exported in the dynamic symbol table as a TLS GLOBAL symbol. //! - `otel_thread_ctx_v1` follows the TLSDESC access model: if there is a relocation for it, it is //! a TLSDESC relocation. -//! - The Rust inline-asm TLSDESC access sequence matches what a C compiler generates almost -//! byte-by-byte (up to the scratch register for reading the thread pointer), guaranteeing that -//! linker TLS relaxation works identically to a compiler-generated access. +//! - The Rust inline-asm TLSDESC access matches what a C compiler generates. The comparison has two +//! parts, because gcc and clang (and different gcc versions) legitimately differ on scratch +//! registers and on how they schedule the thread-pointer read. +//! 1. The relocation-bearing core the linker relaxes is compared byte-for-byte, up to the +//! descriptor scratch register: `adrp`/`ldr`/`add`/`blr` on aarch64, `lea`/`call` plus the +//! `%fs:0` add on x86-64. +//! 2. On aarch64, check the thread-pointer computation (`mrs tpidr_el0` and `add`) is located in +//! a small window around the core rather than pinned to a fixed position, since compilers +//! schedule it freely (gcc may hoist the `mrs` above the sequence and/or interleave the +//! function epilogue before the final `add`). Together this guarantees linker TLS relaxation +//! works identically to a compiler-generated access, while tolerating the parts that are +//! genuinely compiler-defined. //! //! Library artifact paths are derived at runtime from the test executable location. //! The test binary and crate artifacts live in `target/<[triple/]profile>/deps/`. @@ -45,16 +54,25 @@ impl fmt::Debug for RelocationType { } #[derive(Debug, PartialEq, Eq)] -struct TlsDescRelocation { - offset: usize, - relocation_type: RelocationType, - addend: i64, +struct TlsDescSequence { + /// The relocation-bearing instructions the linker relaxes, compared byte-for-byte (up to one + /// register) between our inline asm and the C compiler output. + /// + /// - x86-64: `lea`/`call` plus the trailing `add %fs:0x0, %rax` (identical across gcc/clang). + /// - aarch64: `adrp`/`ldr`/`add`/`blr`, with the descriptor scratch register masked out. gcc + /// parks the thread pointer in `x1` and uses `x2` for the descriptor, whereas clang and our + /// asm use `x1`. + core_instructions: Vec, + relocations: Vec, } -#[derive(Debug, PartialEq, Eq)] -struct TlsDescSequence { - bytes: Vec, - relocations: Vec, +/// A single TLSDESC access extracted from an object file: the comparable [`TlsDescSequence`] plus, +/// on aarch64, the surrounding instruction window used to locate the thread-pointer computation. +#[derive(Debug)] +struct TlsDescAccess { + sequence: TlsDescSequence, + #[cfg(target_arch = "aarch64")] + tp_window: TpWindow, } fn deps_dir() -> PathBuf { @@ -248,8 +266,8 @@ fn is_tlsdesc_object_relocation(relocation_type: RelocationType) -> bool { ) } -#[derive(Debug)] -struct RawRelocation { +#[derive(Debug, PartialEq, Eq)] +struct Relocation { offset: u64, relocation_type: RelocationType, addend: i64, @@ -262,7 +280,7 @@ const TLSDESC_RELOCATIONS_PER_ACCESS: usize = 2; const TLSDESC_RELOCATIONS_PER_ACCESS: usize = 4; #[cfg(target_arch = "x86_64")] -fn tlsdesc_sequence_bounds(relocations: &[RawRelocation], section_len: usize) -> (usize, usize) { +fn tlsdesc_sequence_bounds(relocations: &[Relocation], section_len: usize) -> (usize, usize) { let first_offset = usize::try_from(relocations[0].offset) .expect("first relocation offset does not fit in usize"); let call_offset = usize::try_from(relocations[1].offset) @@ -279,45 +297,107 @@ fn tlsdesc_sequence_bounds(relocations: &[RawRelocation], section_len: usize) -> } #[cfg(target_arch = "aarch64")] -fn tlsdesc_sequence_bounds(relocations: &[RawRelocation], section_len: usize) -> (usize, usize) { - // The core of the sequence is the four instructions `adrp`, `ldr`, `add` and (`.tlsdesccall`) - // `blr`, which all have an associated relocation. - // - // The first relocation is on the `adrp`, the last on the `blr`. The thread-pointer read (`mrs`) - // and the final `add` follow the `blr`. The window starts at the first relocation (not a fixed - // instruction before it) and extends past the `blr` to cover the trailing `mrs`/`add` (which - // don't have associated relocations). +const AARCH64_INSN_LEN: usize = 4; + +/// Instructions to include before the first relocation and after the last relocation when +/// searching the compiler output for the thread-pointer computation on aarch64. +/// +/// The `mrs tpidr_el0` and the final `add` are not relocation-bearing, so compilers schedule them +/// freely: +/// - Older gcc (as on the CentOS/Alpine CI) hoists the `mrs` *above* the `adrp` (one or two +/// instructions before the first relocation). +/// - gcc 15.2 keeps the `mrs` after the `blr` but interleaves the function epilogue between it and +/// the final `add`, so the `add` lands three instructions past the `blr`. +/// - clang and our own inline asm keep both right after the `blr`. +/// +/// The window is sized to cover all of these; bump [`TP_SEARCH_INSNS_AFTER`] if a future toolchain +/// spreads the computation out further. +#[cfg(target_arch = "aarch64")] +const TP_SEARCH_INSNS_BEFORE: usize = 2; +#[cfg(target_arch = "aarch64")] +const TP_SEARCH_INSNS_AFTER: usize = 4; + +/// `mrs Xt, TPIDR_EL0` encodes its destination register in Rt, bits [4:0]. +#[cfg(target_arch = "aarch64")] +const AARCH64_MRS_TP_REG_MASK: u32 = 0x0000_001F; +/// `add x0, Xn, x0` encodes its first source register in Rn, bits [9:5]. +#[cfg(target_arch = "aarch64")] +const AARCH64_ADD_TP_REG_MASK: u32 = 0x0000_03E0; +/// `ldr Xt, [x0, :tlsdesc_lo12:]` encodes the descriptor register in Rt, bits [4:0]. +#[cfg(target_arch = "aarch64")] +const AARCH64_LDR_DESC_REG_MASK: u32 = 0x0000_001F; +/// `blr Xn` encodes the descriptor register in Rn, bits [9:5]. +#[cfg(target_arch = "aarch64")] +const AARCH64_BLR_DESC_REG_MASK: u32 = 0x0000_03E0; + +/// `mrs Xt, TPIDR_EL0` with its destination register masked out. +#[cfg(target_arch = "aarch64")] +const AARCH64_MRS_TPIDR_EL0_MASKED: u32 = 0xd53b_d040; +/// `add x0, Xn, x0` with its first source register masked out. +#[cfg(target_arch = "aarch64")] +const AARCH64_ADD_TP_MASKED: u32 = 0x8b00_0000; + +/// The core TLSDESC sequence on aarch64 is the four relocation-bearing instructions `adrp`, `ldr`, +/// `add` and (`.tlsdesccall`) `blr`. The first relocation is on the `adrp`, the last on the `blr`. +/// The window stops at the `blr`: the trailing `mrs`/`add` are handled separately by +/// [`assert_thread_pointer_computation`] because their placement is not stable across compilers. +#[cfg(target_arch = "aarch64")] +fn tlsdesc_sequence_bounds(relocations: &[Relocation], section_len: usize) -> (usize, usize) { let start = usize::try_from(relocations[0].offset) .expect("first relocation offset does not fit in usize"); let last_offset = usize::try_from(relocations[relocations.len() - 1].offset) .expect("last relocation offset does not fit in usize"); - // From the `blr` (last relocation): `blr`, `mrs`, `add` makes up for three 4-byte instructions. - let end = last_offset + 4 + 4 + 4; + let end = last_offset + AARCH64_INSN_LEN; assert!( end <= section_len, - "AArch64 TLSDESC sequence extends beyond section data" + "AArch64 TLSDESC core sequence extends beyond section data" ); (start, end) } -/// Mask out the register fields of the thread-pointer read and final addition so the byte -/// comparison ignores which scratch register holds the thread pointer. +/// Mask the descriptor scratch register out of the core `ldr`/`blr` so the byte comparison ignores +/// which register holds the TLS descriptor. /// -/// TLS relaxation only cares about the relocation-bearing core (`adrp`/`ldr`/`add`/`.tlsdesccall` -/// +`blr`), which must match byte-for-byte. The register used for the thread-pointer read is -/// irrelevant: our inline asm re-uses `x1` while GCC/Clang usually picks a fresh register when -/// available. -/// -/// AArch64 instructions are fixed 32-bit little-endian words: -/// - `mrs Xd, TPIDR_EL0` encodes its destination register `Xd` in bits [4:0]. -/// - `add x0, Xn, x0` encodes its first source register `Xn` in bits [9:5]. +/// gcc reads the thread pointer into `x1` and therefore picks `x2` for the descriptor; clang and +/// our inline asm use `x1`. #[cfg(target_arch = "aarch64")] -fn normalize_aarch64_tp_registers(bytes: &mut [u8], relocations: &[RawRelocation], start: usize) { - let last_offset = usize::try_from(relocations[relocations.len() - 1].offset) - .expect("last relocation offset does not fit in usize") +fn mask_aarch64_descriptor_register(core: &mut [u8], relocations: &[Relocation], start: usize) { + // Relocations are sorted by offset: [ADR_PAGE21, LD64_LO12, ADD_LO12, CALL] => adrp, ldr, add, + // blr. The descriptor register appears in the `ldr` (second relocation) and the `blr` (last). + let ldr_offset = usize::try_from(relocations[1].offset) + .expect("ldr relocation offset does not fit in usize") - start; - mask_instruction_bits(bytes, last_offset + 4, 0x0000_001F); // `mrs` destination register - mask_instruction_bits(bytes, last_offset + 8, 0x0000_03E0); // `add` first source register + let blr_offset = usize::try_from(relocations[relocations.len() - 1].offset) + .expect("blr relocation offset does not fit in usize") + - start; + mask_instruction_bits(core, ldr_offset, AARCH64_LDR_DESC_REG_MASK); + mask_instruction_bits(core, blr_offset, AARCH64_BLR_DESC_REG_MASK); +} + +/// A window of instruction bytes around the relocation-bearing core, used to locate the +/// thread-pointer computation. +#[cfg(target_arch = "aarch64")] +#[derive(Debug)] +struct TpWindow { + bytes: Vec, + /// Offset of the `blr` (last relocation) within `bytes`. + blr_offset: usize, +} + +#[cfg(target_arch = "aarch64")] +fn extract_tp_window(section_data: &[u8], relocations: &[Relocation]) -> TpWindow { + let first = usize::try_from(relocations[0].offset) + .expect("first relocation offset does not fit in usize"); + let last = usize::try_from(relocations[relocations.len() - 1].offset) + .expect("last relocation offset does not fit in usize"); + // Relocation offsets are 4-byte aligned, so subtracting whole instructions keeps the window + // aligned to instruction boundaries. + let start = first.saturating_sub(TP_SEARCH_INSNS_BEFORE * AARCH64_INSN_LEN); + let end = (last + AARCH64_INSN_LEN * (1 + TP_SEARCH_INSNS_AFTER)).min(section_data.len()); + TpWindow { + bytes: section_data[start..end].to_vec(), + blr_offset: last - start, + } } /// Clear the bits set in `mask` from the 32-bit little-endian instruction word at `offset`. @@ -330,40 +410,62 @@ fn mask_instruction_bits(bytes: &mut [u8], offset: usize, mask: u32) { bytes[offset..offset + 4].copy_from_slice(&masked.to_le_bytes()); } -fn tlsdesc_sequence_from_relocations( +/// Read the 32-bit little-endian instruction at `offset` with the bits in `mask` cleared. +#[cfg(target_arch = "aarch64")] +fn masked_instruction_at(bytes: &[u8], offset: usize, mask: u32) -> u32 { + let word: [u8; 4] = bytes[offset..offset + 4] + .try_into() + .expect("instruction word extends beyond the extracted window"); + u32::from_le_bytes(word) & !mask +} + +/// Find the first instruction at or after `from` (scanning on 4-byte boundaries) whose value, once +/// `mask` is cleared, equals `target`. +#[cfg(target_arch = "aarch64")] +fn find_masked_instruction(bytes: &[u8], from: usize, mask: u32, target: u32) -> Option { + (from..bytes.len().saturating_sub(AARCH64_INSN_LEN - 1)) + .step_by(AARCH64_INSN_LEN) + .find(|&offset| masked_instruction_at(bytes, offset, mask) == target) +} + +fn tlsdesc_access_from_relocations( section_data: &[u8], - relocations: &[RawRelocation], -) -> TlsDescSequence { + relocations: &[Relocation], +) -> TlsDescAccess { let (start, end) = tlsdesc_sequence_bounds(relocations, section_data.len()); #[cfg_attr(not(target_arch = "aarch64"), allow(unused_mut))] - let mut bytes = section_data[start..end].to_vec(); + let mut core = section_data[start..end].to_vec(); #[cfg(target_arch = "aarch64")] - normalize_aarch64_tp_registers(&mut bytes, relocations, start); - TlsDescSequence { - bytes, + mask_aarch64_descriptor_register(&mut core, relocations, start); + let sequence = TlsDescSequence { + core_instructions: core, relocations: relocations .iter() - .map(|relocation| TlsDescRelocation { - offset: usize::try_from(relocation.offset) - .expect("relocation offset does not fit in usize") - - start, + .map(|relocation| Relocation { + offset: relocation.offset + - u64::try_from(start).expect("could not fit `start` into a u64"), relocation_type: relocation.relocation_type, addend: relocation.addend, }) .collect(), + }; + TlsDescAccess { + sequence, + #[cfg(target_arch = "aarch64")] + tp_window: extract_tp_window(section_data, relocations), } } -fn tlsdesc_sequences_for_symbol_in_elf( +fn tlsdesc_accesses_for_symbol_in_elf( data: &[u8], symbol: &str, label: &str, -) -> Vec { +) -> Vec { let elf = parse_elf(data, label); let Some(section_headers) = elf.section_headers() else { panic!("{label} has no ELF section headers"); }; - let mut sequences = Vec::new(); + let mut accesses = Vec::new(); for section_header in section_headers .iter() @@ -393,7 +495,7 @@ fn tlsdesc_sequences_for_symbol_in_elf( symbol_indexes.contains(&rel.r_sym) && is_tlsdesc_object_relocation(RelocationType(rel.r_type)) }) - .map(|rel| RawRelocation { + .map(|rel| Relocation { offset: rel.r_offset, relocation_type: RelocationType(rel.r_type), addend: 0, @@ -410,7 +512,7 @@ fn tlsdesc_sequences_for_symbol_in_elf( symbol_indexes.contains(&rela.r_sym) && is_tlsdesc_object_relocation(RelocationType(rela.r_type)) }) - .map(|rela| RawRelocation { + .map(|rela| Relocation { offset: rela.r_offset, relocation_type: RelocationType(rela.r_type), addend: rela.r_addend, @@ -427,38 +529,91 @@ fn tlsdesc_sequences_for_symbol_in_elf( {TLSDESC_RELOCATIONS_PER_ACCESS}; found {relocations:?}" ); - sequences.extend( + accesses.extend( relocations .chunks_exact(TLSDESC_RELOCATIONS_PER_ACCESS) - .map(|chunk| tlsdesc_sequence_from_relocations(target_data, chunk)), + .map(|chunk| tlsdesc_access_from_relocations(target_data, chunk)), ); } - sequences + accesses } -fn tlsdesc_sequences_for_symbol_in_file(path: &Path, symbol: &str) -> Vec { +fn tlsdesc_accesses_for_symbol_in_file(path: &Path, symbol: &str) -> Vec { let data = std::fs::read(path).unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display())); - tlsdesc_sequences_for_symbol_in_elf(&data, symbol, &path.display().to_string()) + tlsdesc_accesses_for_symbol_in_elf(&data, symbol, &path.display().to_string()) } -fn archive_tlsdesc_sequences_for_symbol( - path: &Path, - symbol: &str, -) -> Vec<(String, TlsDescSequence)> { - let mut sequences = Vec::new(); +fn archive_tlsdesc_accesses_for_symbol(path: &Path, symbol: &str) -> Vec<(String, TlsDescAccess)> { + let mut accesses = Vec::new(); for_each_archive_elf_member(path, |member_name, member_data| { let label = format!("{}({member_name})", path.display()); - sequences.extend( - tlsdesc_sequences_for_symbol_in_elf(member_data, symbol, &label) + accesses.extend( + tlsdesc_accesses_for_symbol_in_elf(member_data, symbol, &label) .into_iter() - .map(|sequence| (member_name.to_owned(), sequence)), + .map(|access| (member_name.to_owned(), access)), ); }); - sequences + accesses +} + +/// Verify the compiler output performs the same thread-pointer computation our inline asm does: an +/// `mrs Xn, TPIDR_EL0` followed, in program order (possibly with unrelated instructions in between +/// — gcc may hoist the `mrs` and/or interleave the epilogue), by an `add x0, Xn, x0`. Both are +/// matched up to the scratch register, which the compiler is free to choose. +#[cfg(target_arch = "aarch64")] +fn assert_thread_pointer_computation( + rust: &TpWindow, + c: &TpWindow, + rust_label: &str, + c_label: &str, +) { + // Our inline asm emits the `mrs` and `add` as the two instructions right after the `blr`. + let mrs = masked_instruction_at( + &rust.bytes, + rust.blr_offset + AARCH64_INSN_LEN, + AARCH64_MRS_TP_REG_MASK, + ); + let add = masked_instruction_at( + &rust.bytes, + rust.blr_offset + 2 * AARCH64_INSN_LEN, + AARCH64_ADD_TP_REG_MASK, + ); + // Guard against our own asm drifting away from the shape this search assumes. + assert_eq!( + mrs, AARCH64_MRS_TPIDR_EL0_MASKED, + "expected our inline asm to read `tpidr_el0` right after the TLSDESC call in {rust_label}" + ); + assert_eq!( + add, AARCH64_ADD_TP_MASKED, + "expected our inline asm to `add x0, , x0` right after reading the thread pointer in \ + {rust_label}" + ); + + let mrs_pos = find_masked_instruction(&c.bytes, 0, AARCH64_MRS_TP_REG_MASK, mrs) + .unwrap_or_else(|| { + panic!( + "no `mrs Xn, tpidr_el0` found near the TLSDESC access in {c_label}: the compiler's \ + thread-pointer read does not match our inline asm. Set \ + {SKIP_TLS_SHIM_ASM_TEST_ENV}=1 to skip this guard with a different local compiler." + ) + }); + find_masked_instruction( + &c.bytes, + mrs_pos + AARCH64_INSN_LEN, + AARCH64_ADD_TP_REG_MASK, + add, + ) + .unwrap_or_else(|| { + panic!( + "no `add x0, Xn, x0` after the `tpidr_el0` read near the TLSDESC access in {c_label}: \ + the compiler's thread-pointer computation does not match our inline asm. Set \ + {SKIP_TLS_SHIM_ASM_TEST_ENV}=1 to skip this guard with a different local compiler." + ) + }); } fn compile_tls_shim_object(dir: &Path) -> PathBuf { @@ -504,33 +659,45 @@ fn tlsdesc_inline_assembly_matches_c_compiler_sequence() { let dir = build_dir("otel-thread-ctx-tls-shim"); let c_object = compile_tls_shim_object(&dir); - let c_sequences = tlsdesc_sequences_for_symbol_in_file(&c_object, SYMBOL); + let c_accesses = tlsdesc_accesses_for_symbol_in_file(&c_object, SYMBOL); assert_eq!( - c_sequences.len(), + c_accesses.len(), 1, - "expected one compiler-generated TLSDESC access in {}; found {c_sequences:?}. \ + "expected one compiler-generated TLSDESC access in {}; found {}. \ Set {SKIP_TLS_SHIM_ASM_TEST_ENV}=1 to skip this guard with a different local compiler.", - c_object.display() + c_object.display(), + c_accesses.len() ); - let expected = &c_sequences[0]; + let expected = &c_accesses[0]; - let rust_sequences = archive_tlsdesc_sequences_for_symbol(&staticlib, SYMBOL); + let rust_accesses = archive_tlsdesc_accesses_for_symbol(&staticlib, SYMBOL); assert!( - !rust_sequences.is_empty(), + !rust_accesses.is_empty(), "expected at least one Rust inline-asm TLSDESC access for {SYMBOL} in {}", staticlib.display() ); - for (member_name, sequence) in rust_sequences { + for (member_name, access) in rust_accesses { + // The relocation-bearing core the linker relaxes must match byte-for-byte (up to the + // descriptor scratch register, which relaxation discards). assert_eq!( - &sequence, - expected, - "Rust inline assembly TLSDESC sequence in {}({member_name}) does not match \ - compiler output from {}. Set {SKIP_TLS_SHIM_ASM_TEST_ENV}=1 to skip this guard with \ - a different local compiler.", + &access.sequence, + &expected.sequence, + "Rust inline assembly TLSDESC core in {}({member_name}) does not match compiler output \ + from {}. Set {SKIP_TLS_SHIM_ASM_TEST_ENV}=1 to skip this guard with a different local \ + compiler.", staticlib.display(), c_object.display() ); + // The thread-pointer computation is not relocation-bearing; compilers schedule it freely, + // so we only require the same instructions to appear in order near the core (aarch64). + #[cfg(target_arch = "aarch64")] + assert_thread_pointer_computation( + &access.tp_window, + &expected.tp_window, + &format!("{}({member_name})", staticlib.display()), + &c_object.display().to_string(), + ); } } From 53e627a1455ea1f09bf20d7aa9bf5914173b7116 Mon Sep 17 00:00:00 2001 From: Yann Hamdaoui Date: Wed, 8 Jul 2026 11:12:47 +0200 Subject: [PATCH 12/12] refactor: use hash-based integration test instead of recompiling the C shim all the time --- Cargo.lock | 4 +- libdd-otel-thread-ctx-ffi/Cargo.toml | 17 +- .../src/bin/gen_tls_shim_hash.rs | 129 ++++ .../tests/elf_properties.rs | 696 +----------------- libdd-otel-thread-ctx-ffi/tests/tls_shim.c | 8 - .../tests/tlsdesc_inline_sequence.rs | 94 +++ libdd-otel-thread-ctx/Cargo.toml | 4 + libdd-otel-thread-ctx/src/lib.rs | 24 +- .../src/test_utils/artifacts.rs | 32 + libdd-otel-thread-ctx/src/test_utils/mod.rs | 8 + .../src/test_utils/tls_shim_window.rs | 358 +++++++++ 11 files changed, 664 insertions(+), 710 deletions(-) create mode 100644 libdd-otel-thread-ctx-ffi/src/bin/gen_tls_shim_hash.rs delete mode 100644 libdd-otel-thread-ctx-ffi/tests/tls_shim.c create mode 100644 libdd-otel-thread-ctx-ffi/tests/tlsdesc_inline_sequence.rs create mode 100644 libdd-otel-thread-ctx/src/test_utils/artifacts.rs create mode 100644 libdd-otel-thread-ctx/src/test_utils/mod.rs create mode 100644 libdd-otel-thread-ctx/src/test_utils/tls_shim_window.rs diff --git a/Cargo.lock b/Cargo.lock index 8ca028d5bf..d83ffcbab3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3165,6 +3165,8 @@ version = "1.0.0" dependencies = [ "anyhow", "elf", + "object 0.36.5", + "sha2", ] [[package]] @@ -3172,10 +3174,8 @@ name = "libdd-otel-thread-ctx-ffi" version = "1.0.0" dependencies = [ "build_common", - "elf", "libdd-common-ffi", "libdd-otel-thread-ctx", - "object 0.36.5", ] [[package]] diff --git a/libdd-otel-thread-ctx-ffi/Cargo.toml b/libdd-otel-thread-ctx-ffi/Cargo.toml index 00132acd89..234c325915 100644 --- a/libdd-otel-thread-ctx-ffi/Cargo.toml +++ b/libdd-otel-thread-ctx-ffi/Cargo.toml @@ -22,11 +22,22 @@ libdd-otel-thread-ctx = { path = "../libdd-otel-thread-ctx" } default = ["cbindgen"] cbindgen = ["build_common/cbindgen", "libdd-common-ffi/cbindgen"] sanity-check = ["dep:libdd-common-ffi", "libdd-otel-thread-ctx/sanity-check"] +# Gate for the gen_tls_shim_hash binary. Do not build it by default; it's very +# rarely needed (and maybe won't ever be): only if the corresponding test is +# failing one day, and we need to update the TLSDESC inline assembly to match +# what newer versions of clang generate. +gen-tls-shim-hash = ["libdd-otel-thread-ctx/test-utils"] + +# Dev tool to regenerate the TLSDESC golden hash for tests. +[[bin]] +name = "gen_tls_shim_hash" +required-features = ["gen-tls-shim-hash"] [dev-dependencies] -elf = "0.7" -libdd-otel-thread-ctx = { path = "../libdd-otel-thread-ctx", features = ["sanity-check"] } -object = { version = "0.36", default-features = false, features = ["archive", "read_core"] } +libdd-otel-thread-ctx = { path = "../libdd-otel-thread-ctx", features = [ + "sanity-check", + "test-utils", +] } [build-dependencies] build_common = { path = "../build-common" } diff --git a/libdd-otel-thread-ctx-ffi/src/bin/gen_tls_shim_hash.rs b/libdd-otel-thread-ctx-ffi/src/bin/gen_tls_shim_hash.rs new file mode 100644 index 0000000000..29c8aaa44a --- /dev/null +++ b/libdd-otel-thread-ctx-ffi/src/bin/gen_tls_shim_hash.rs @@ -0,0 +1,129 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +//! Generate the "golden" hash of the TLSDESC access sequence for `otel_thread_ctx_v1` from a +//! clang's output for a small C shim (inlined as [`TLS_SHIM_C`], written to a temp file and +//! compiled at runtime). +//! +//! This is the reference side of the `tlsdesc_inline_sequence` integration test: the test hashes +//! the sequence our inline assembly produces and asserts it equals the hash printed here. When the +//! inline assembly (or the pinned toolchain) legitimately changes, re-run this to obtain the new +//! hash and paste it into the test. +//! +//! This is a dev tool, not part of a normal build: it lives behind the `gen-tls-shim-hash` feature +//! (via the `[[bin]]` `required-features`), so it is only compiled when that feature is enabled. +//! +//! # Reference compilers +//! +//! - **aarch64** uses `clang` (cross-compiling with `--target=`): TLSDESC is the default dialect +//! for a `global-dynamic` access, so no extra flag is needed. +//! - **x86-64** uses `gcc` with `-mtls-dialect=gnu2`. The pinned CI image below ships Clang 18, +//! which predates x86-64 TLSDESC support (`-mtls-dialect=gnu2` is Clang 19+), so we fall back to +//! gcc there, which is just a work-around. The clang and gcc sequences are currently byte-by-byte +//! identical for x86_64 anyway. Feel free to update to using clang once Clang19+ lands in CI +//! images. +//! +//! The compilers can be overridden with `$CC` (x86-64) and `$CLANG` (aarch64). +//! +//! # Reproducibility +//! +//! For a *reproducible* hash, run this inside the latest [Ubuntu Datadog CI build +//! image](https://github.com/DataDog/libddprof-build/blob/main/ubuntu.Dockerfile). The current hash +//! was built from rev `758a114281f3545b40598272ea1c41404b43f2f6`. +//! +//! ```text +//! docker run --rm -v "$PWD":/repo:ro -e CARGO_TARGET_DIR=/tmp/target -e CARGO_HOME=/tmp/cargo \ +//! -w /repo registry.ddbuild.io/ci/libddprof-build:dependencies_ubuntu_30 \ +//! bash -c 'for a in x86_64 aarch64; do \ +//! cargo run --quiet --no-default-features --features gen-tls-shim-hash \ +//! --bin gen_tls_shim_hash -p libdd-otel-thread-ctx-ffi -- "$a"; done' +//! ``` + +use std::{path::Path, process::Command}; + +use libdd_otel_thread_ctx::test_utils::tls_shim_window::{windows_in_object_file, Arch}; + +/// The reference C translation unit: a public `otel_thread_ctx_v1` TLS symbol compiled as a shared +/// object plus an `accessor, so the compiler emits exactly one TLSDESC access sequence. It is +/// written to a temp file and compiled at runtime. +const TLS_SHIM_C: &str = r#"extern __thread void *otel_thread_ctx_v1 __attribute__((tls_model("global-dynamic"))); + +__attribute__((noinline)) void **tls_slot_from_c(void) { + return &otel_thread_ctx_v1; +} +"#; + +/// Compile `tls_shim.c` for `arch` with the flags that produce a `global-dynamic` TLSDESC access, +/// matching the access model our inline assembly implements. See the module docs for why x86-64 +/// uses gcc and aarch64 uses Clang. +fn compile_reference(arch: Arch, source: &Path, object: &Path) { + let mut cmd = match arch { + Arch::X86_64 => { + // gcc: the psABI-canonical x86-64 TLSDESC sequence. `gnu2` selects the TLSDESC dialect. + let cc = std::env::var("CC").unwrap_or_else(|_| "gcc".to_owned()); + let mut cmd = Command::new(cc); + cmd.arg("-mtls-dialect=gnu2"); + cmd + } + Arch::Aarch64 => { + // clang, cross-compiling: TLSDESC is the default dialect for a `global-dynamic` access. + let clang = std::env::var("CLANG").unwrap_or_else(|_| "clang".to_owned()); + let mut cmd = Command::new(clang); + cmd.arg(format!("--target={}", arch.clang_target_triple())); + cmd + } + }; + cmd.args(["-O2", "-fPIC", "-fomit-frame-pointer", "-c"]); + cmd.arg(source).arg("-o").arg(object); + + eprintln!("running: {cmd:?}"); + let status = cmd + .status() + .unwrap_or_else(|e| panic!("failed to run reference compiler: {e}")); + assert!( + status.success(), + "reference compiler failed with status {status}" + ); +} + +fn main() { + let arch = match std::env::args().nth(1) { + Some(arg) => arg.parse().unwrap_or_else(|e| { + eprintln!("error: {e}"); + std::process::exit(2); + }), + None => Arch::host(), + }; + + let out_dir = std::env::temp_dir().join(format!("gen_tls_shim_hash-{}", std::process::id())); + std::fs::create_dir_all(&out_dir) + .unwrap_or_else(|e| panic!("failed to create {}: {e}", out_dir.display())); + let source = out_dir.join("tls_shim.c"); + std::fs::write(&source, TLS_SHIM_C) + .unwrap_or_else(|e| panic!("failed to write {}: {e}", source.display())); + let object = out_dir.join(format!("tls_shim_{arch:?}.o")); + + compile_reference(arch, &source, &object); + + let windows = windows_in_object_file(&object); + assert_eq!( + windows.len(), + 1, + "expected exactly one TLSDESC access in the reference object {}; found {}", + object.display(), + windows.len() + ); + + let window = &windows[0]; + assert_eq!( + window.arch, arch, + "reference object architecture ({:?}) does not match requested architecture ({arch:?})", + window.arch + ); + + let _ = std::fs::remove_dir_all(&out_dir); + + println!("arch: {arch:?}"); + println!("bytes: {}", window.hex_dump()); + println!("hash: {}", window.hash_hex()); +} diff --git a/libdd-otel-thread-ctx-ffi/tests/elf_properties.rs b/libdd-otel-thread-ctx-ffi/tests/elf_properties.rs index b0f9debd82..d96c75e5ce 100644 --- a/libdd-otel-thread-ctx-ffi/tests/elf_properties.rs +++ b/libdd-otel-thread-ctx-ffi/tests/elf_properties.rs @@ -6,705 +6,27 @@ //! test rather checks that the dynamic library is properly linked, which is why it lives within the //! FFI. //! -//! These tests check that: -//! - `otel_thread_ctx_v1` is exported in the dynamic symbol table as a TLS GLOBAL symbol. -//! - `otel_thread_ctx_v1` follows the TLSDESC access model: if there is a relocation for it, it is -//! a TLSDESC relocation. -//! - The Rust inline-asm TLSDESC access matches what a C compiler generates. The comparison has two -//! parts, because gcc and clang (and different gcc versions) legitimately differ on scratch -//! registers and on how they schedule the thread-pointer read. -//! 1. The relocation-bearing core the linker relaxes is compared byte-for-byte, up to the -//! descriptor scratch register: `adrp`/`ldr`/`add`/`blr` on aarch64, `lea`/`call` plus the -//! `%fs:0` add on x86-64. -//! 2. On aarch64, check the thread-pointer computation (`mrs tpidr_el0` and `add`) is located in -//! a small window around the core rather than pinned to a fixed position, since compilers -//! schedule it freely (gcc may hoist the `mrs` above the sequence and/or interleave the -//! function epilogue before the final `add`). Together this guarantees linker TLS relaxation -//! works identically to a compiler-generated access, while tolerating the parts that are -//! genuinely compiler-defined. +//! This checks that, in the linked `cdylib`: +//! - `otel_thread_ctx_v1` is exported in the dynamic symbol table as a TLS GLOBAL symbol; +//! - it follows the TLSDESC access model: if there is a relocation for it, it is a TLSDESC +//! relocation. +//! +//! The complementary check that our inline assembly emits the exact TLSDESC instruction sequence a +//! compiler would (so the linker relaxes it correctly) lives in `tlsdesc_inline_sequence.rs`. //! //! Library artifact paths are derived at runtime from the test executable location. -//! The test binary and crate artifacts live in `target/<[triple/]profile>/deps/`. #![cfg(all( target_os = "linux", any(target_arch = "x86_64", target_arch = "aarch64") ))] -use std::{ - fmt, - io::ErrorKind, - path::{Path, PathBuf}, - process::{Command, Stdio}, -}; - -use elf::{abi, endian::AnyEndian, symbol::SymbolTable, ElfBytes}; -use object::read::archive::ArchiveFile; - -const SYMBOL: &str = "otel_thread_ctx_v1"; -const SKIP_TLS_SHIM_ASM_TEST_ENV: &str = "LIBDD_OTEL_THREAD_CTX_SKIP_TLS_SHIM_ASM_TEST"; - -#[derive(Clone, Copy, PartialEq, Eq)] -struct RelocationType(u32); - -impl fmt::Debug for RelocationType { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - self.0.fmt(f) - } -} - -#[derive(Debug, PartialEq, Eq)] -struct TlsDescSequence { - /// The relocation-bearing instructions the linker relaxes, compared byte-for-byte (up to one - /// register) between our inline asm and the C compiler output. - /// - /// - x86-64: `lea`/`call` plus the trailing `add %fs:0x0, %rax` (identical across gcc/clang). - /// - aarch64: `adrp`/`ldr`/`add`/`blr`, with the descriptor scratch register masked out. gcc - /// parks the thread pointer in `x1` and uses `x2` for the descriptor, whereas clang and our - /// asm use `x1`. - core_instructions: Vec, - relocations: Vec, -} - -/// A single TLSDESC access extracted from an object file: the comparable [`TlsDescSequence`] plus, -/// on aarch64, the surrounding instruction window used to locate the thread-pointer computation. -#[derive(Debug)] -struct TlsDescAccess { - sequence: TlsDescSequence, - #[cfg(target_arch = "aarch64")] - tp_window: TpWindow, -} - -fn deps_dir() -> PathBuf { - // test binary: target/<[triple/]profile>/deps/ - let exe = std::env::current_exe().expect("failed to read current executable path"); - exe.parent() - .expect("unexpected test executable path structure") - .to_owned() -} - -fn artifact_path(name: &str) -> PathBuf { - deps_dir().join(name) -} - -fn cdylib_path() -> PathBuf { - artifact_path("liblibdd_otel_thread_ctx_ffi.so") -} - -fn staticlib_path() -> PathBuf { - artifact_path("liblibdd_otel_thread_ctx_ffi.a") -} - -fn check_readable(path: &Path) { - assert!( - std::fs::File::open(path).is_ok(), - "{} could not be opened for reading", - path.display() - ); -} - -fn tool_available(tool: &str) -> bool { - match Command::new(tool) - .arg("--version") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - { - Ok(_) => true, - Err(e) if e.kind() == ErrorKind::NotFound => { - eprintln!("skipping test: required tool `{tool}` is not available"); - false - } - Err(e) => panic!("failed to check whether `{tool}` is available: {e}"), - } -} - -fn required_tools_available(tools: &[&str]) -> bool { - tools.iter().all(|tool| tool_available(tool)) -} - -fn native_target() -> bool { - let cross_compiling = option_env!("LIBDD_OTEL_THREAD_CTX_FFI_CROSS_COMPILING") == Some("true"); - if cross_compiling { - eprintln!("skipping test: cross-compiling"); - } - !cross_compiling -} - -fn skip_tls_shim_asm_test() -> bool { - let skip = std::env::var_os(SKIP_TLS_SHIM_ASM_TEST_ENV).is_some(); - if skip { - eprintln!("skipping test: {SKIP_TLS_SHIM_ASM_TEST_ENV} is set"); - } - skip -} - -fn assert_command_success(command: &mut Command) { - let out = command - .output() - .unwrap_or_else(|e| panic!("failed to run {command:?}: {e}")); - assert!( - out.status.success(), - "{command:?} failed with status {}\nstdout:\n{}\nstderr:\n{}", - out.status, - String::from_utf8_lossy(&out.stdout), - String::from_utf8_lossy(&out.stderr) - ); -} - -fn build_dir(name: &str) -> PathBuf { - let dir = deps_dir().join(format!("{name}-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir) - .unwrap_or_else(|e| panic!("failed to create {}: {e}", dir.display())); - dir -} - -fn parse_elf<'data>(data: &'data [u8], label: &str) -> ElfBytes<'data, AnyEndian> { - ElfBytes::::minimal_parse(data) - .unwrap_or_else(|e| panic!("failed to parse ELF data from {label}: {e}")) -} - -fn symbol_indexes_in_table( - elf: &ElfBytes<'_, AnyEndian>, - symtab_index: usize, - symbol: &str, - label: &str, -) -> Vec { - let Some(section_headers) = elf.section_headers() else { - panic!("{label} has no ELF section headers"); - }; - let symtab_header = section_headers - .get(symtab_index) - .unwrap_or_else(|e| panic!("failed to read symbol table header {symtab_index}: {e}")); - - // Relocation sections link to the symbol table they use; archive members usually use - // `.symtab`, while linked dynamic artifacts may use `.dynsym`. - if !matches!(symtab_header.sh_type, abi::SHT_SYMTAB | abi::SHT_DYNSYM) { - return Vec::new(); - } - - let strtab_header = section_headers - .get(symtab_header.sh_link as usize) - .unwrap_or_else(|e| panic!("failed to read linked string table header: {e}")); - let strtab = elf - .section_data_as_strtab(&strtab_header) - .unwrap_or_else(|e| panic!("failed to read linked string table in {label}: {e}")); - let (symtab_data, _) = elf - .section_data(&symtab_header) - .unwrap_or_else(|e| panic!("failed to read symbol table data in {label}: {e}")); - let symtab = SymbolTable::new(elf.ehdr.endianness, elf.ehdr.class, symtab_data); - - symtab - .iter() - .enumerate() - .filter_map(|(index, sym)| { - strtab - .get(sym.st_name as usize) - .ok() - .filter(|name| *name == symbol) - .map(|_| index as u32) - }) - .collect() -} - -fn for_each_archive_elf_member(path: &Path, mut f: impl FnMut(&str, &[u8])) { - let archive_data = - std::fs::read(path).unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display())); - let archive = ArchiveFile::parse(&*archive_data) - .unwrap_or_else(|e| panic!("failed to parse archive {}: {e}", path.display())); - - for member in archive.members() { - let member = - member.unwrap_or_else(|e| panic!("failed to read member in {}: {e}", path.display())); - let member_data = member.data(&*archive_data).unwrap_or_else(|e| { - panic!( - "failed to read member data for {} in {}: {e}", - String::from_utf8_lossy(member.name()), - path.display() - ) - }); - - if member_data.starts_with(&abi::ELFMAGIC) { - let member_name = std::str::from_utf8(member.name()).unwrap_or_else(|e| { - panic!( - "archive member name in {} is not valid UTF-8: {e}", - path.display() - ) - }); - f(member_name, member_data); - } - } -} - -#[cfg(target_arch = "x86_64")] -fn is_tlsdesc_object_relocation(relocation_type: RelocationType) -> bool { - // These are object-file TLSDESC relocations. `R_X86_64_TLSDESC` is the dynamic-linker - // relocation emitted after linking, so it is intentionally excluded here. - matches!( - relocation_type.0, - abi::R_X86_64_GOTPC32_TLSDESC | abi::R_X86_64_TLSDESC_CALL - ) -} - -#[cfg(target_arch = "aarch64")] -fn is_tlsdesc_object_relocation(relocation_type: RelocationType) -> bool { - // These are object-file TLSDESC relocations. `R_AARCH64_TLSDESC` is the dynamic-linker - // relocation emitted after linking, so it is intentionally excluded here. - matches!( - relocation_type.0, - abi::R_AARCH64_TLSDESC_LD_PREL19 - | abi::R_AARCH64_TLSDESC_ADR_PREL21 - | abi::R_AARCH64_TLSDESC_ADR_PAGE21 - | abi::R_AARCH64_TLSDESC_LD64_LO12 - | abi::R_AARCH64_TLSDESC_ADD_LO12 - | abi::R_AARCH64_TLSDESC_OFF_G1 - | abi::R_AARCH64_TLSDESC_OFF_G0_NC - | abi::R_AARCH64_TLSDESC_LDR - | abi::R_AARCH64_TLSDESC_ADD - | abi::R_AARCH64_TLSDESC_CALL - ) -} - -#[derive(Debug, PartialEq, Eq)] -struct Relocation { - offset: u64, - relocation_type: RelocationType, - addend: i64, -} - -#[cfg(target_arch = "x86_64")] -const TLSDESC_RELOCATIONS_PER_ACCESS: usize = 2; - -#[cfg(target_arch = "aarch64")] -const TLSDESC_RELOCATIONS_PER_ACCESS: usize = 4; - -#[cfg(target_arch = "x86_64")] -fn tlsdesc_sequence_bounds(relocations: &[Relocation], section_len: usize) -> (usize, usize) { - let first_offset = usize::try_from(relocations[0].offset) - .expect("first relocation offset does not fit in usize"); - let call_offset = usize::try_from(relocations[1].offset) - .expect("call relocation offset does not fit in usize"); - let start = first_offset - .checked_sub(3) - .expect("x86-64 TLSDESC relocation offset is before the LEA instruction displacement"); - let end = call_offset + 11; - assert!( - end <= section_len, - "x86-64 TLSDESC sequence extends beyond section data" - ); - (start, end) -} - -#[cfg(target_arch = "aarch64")] -const AARCH64_INSN_LEN: usize = 4; - -/// Instructions to include before the first relocation and after the last relocation when -/// searching the compiler output for the thread-pointer computation on aarch64. -/// -/// The `mrs tpidr_el0` and the final `add` are not relocation-bearing, so compilers schedule them -/// freely: -/// - Older gcc (as on the CentOS/Alpine CI) hoists the `mrs` *above* the `adrp` (one or two -/// instructions before the first relocation). -/// - gcc 15.2 keeps the `mrs` after the `blr` but interleaves the function epilogue between it and -/// the final `add`, so the `add` lands three instructions past the `blr`. -/// - clang and our own inline asm keep both right after the `blr`. -/// -/// The window is sized to cover all of these; bump [`TP_SEARCH_INSNS_AFTER`] if a future toolchain -/// spreads the computation out further. -#[cfg(target_arch = "aarch64")] -const TP_SEARCH_INSNS_BEFORE: usize = 2; -#[cfg(target_arch = "aarch64")] -const TP_SEARCH_INSNS_AFTER: usize = 4; - -/// `mrs Xt, TPIDR_EL0` encodes its destination register in Rt, bits [4:0]. -#[cfg(target_arch = "aarch64")] -const AARCH64_MRS_TP_REG_MASK: u32 = 0x0000_001F; -/// `add x0, Xn, x0` encodes its first source register in Rn, bits [9:5]. -#[cfg(target_arch = "aarch64")] -const AARCH64_ADD_TP_REG_MASK: u32 = 0x0000_03E0; -/// `ldr Xt, [x0, :tlsdesc_lo12:]` encodes the descriptor register in Rt, bits [4:0]. -#[cfg(target_arch = "aarch64")] -const AARCH64_LDR_DESC_REG_MASK: u32 = 0x0000_001F; -/// `blr Xn` encodes the descriptor register in Rn, bits [9:5]. -#[cfg(target_arch = "aarch64")] -const AARCH64_BLR_DESC_REG_MASK: u32 = 0x0000_03E0; - -/// `mrs Xt, TPIDR_EL0` with its destination register masked out. -#[cfg(target_arch = "aarch64")] -const AARCH64_MRS_TPIDR_EL0_MASKED: u32 = 0xd53b_d040; -/// `add x0, Xn, x0` with its first source register masked out. -#[cfg(target_arch = "aarch64")] -const AARCH64_ADD_TP_MASKED: u32 = 0x8b00_0000; - -/// The core TLSDESC sequence on aarch64 is the four relocation-bearing instructions `adrp`, `ldr`, -/// `add` and (`.tlsdesccall`) `blr`. The first relocation is on the `adrp`, the last on the `blr`. -/// The window stops at the `blr`: the trailing `mrs`/`add` are handled separately by -/// [`assert_thread_pointer_computation`] because their placement is not stable across compilers. -#[cfg(target_arch = "aarch64")] -fn tlsdesc_sequence_bounds(relocations: &[Relocation], section_len: usize) -> (usize, usize) { - let start = usize::try_from(relocations[0].offset) - .expect("first relocation offset does not fit in usize"); - let last_offset = usize::try_from(relocations[relocations.len() - 1].offset) - .expect("last relocation offset does not fit in usize"); - let end = last_offset + AARCH64_INSN_LEN; - assert!( - end <= section_len, - "AArch64 TLSDESC core sequence extends beyond section data" - ); - (start, end) -} - -/// Mask the descriptor scratch register out of the core `ldr`/`blr` so the byte comparison ignores -/// which register holds the TLS descriptor. -/// -/// gcc reads the thread pointer into `x1` and therefore picks `x2` for the descriptor; clang and -/// our inline asm use `x1`. -#[cfg(target_arch = "aarch64")] -fn mask_aarch64_descriptor_register(core: &mut [u8], relocations: &[Relocation], start: usize) { - // Relocations are sorted by offset: [ADR_PAGE21, LD64_LO12, ADD_LO12, CALL] => adrp, ldr, add, - // blr. The descriptor register appears in the `ldr` (second relocation) and the `blr` (last). - let ldr_offset = usize::try_from(relocations[1].offset) - .expect("ldr relocation offset does not fit in usize") - - start; - let blr_offset = usize::try_from(relocations[relocations.len() - 1].offset) - .expect("blr relocation offset does not fit in usize") - - start; - mask_instruction_bits(core, ldr_offset, AARCH64_LDR_DESC_REG_MASK); - mask_instruction_bits(core, blr_offset, AARCH64_BLR_DESC_REG_MASK); -} - -/// A window of instruction bytes around the relocation-bearing core, used to locate the -/// thread-pointer computation. -#[cfg(target_arch = "aarch64")] -#[derive(Debug)] -struct TpWindow { - bytes: Vec, - /// Offset of the `blr` (last relocation) within `bytes`. - blr_offset: usize, -} - -#[cfg(target_arch = "aarch64")] -fn extract_tp_window(section_data: &[u8], relocations: &[Relocation]) -> TpWindow { - let first = usize::try_from(relocations[0].offset) - .expect("first relocation offset does not fit in usize"); - let last = usize::try_from(relocations[relocations.len() - 1].offset) - .expect("last relocation offset does not fit in usize"); - // Relocation offsets are 4-byte aligned, so subtracting whole instructions keeps the window - // aligned to instruction boundaries. - let start = first.saturating_sub(TP_SEARCH_INSNS_BEFORE * AARCH64_INSN_LEN); - let end = (last + AARCH64_INSN_LEN * (1 + TP_SEARCH_INSNS_AFTER)).min(section_data.len()); - TpWindow { - bytes: section_data[start..end].to_vec(), - blr_offset: last - start, - } -} - -/// Clear the bits set in `mask` from the 32-bit little-endian instruction word at `offset`. -#[cfg(target_arch = "aarch64")] -fn mask_instruction_bits(bytes: &mut [u8], offset: usize, mask: u32) { - let word: [u8; 4] = bytes[offset..offset + 4] - .try_into() - .expect("instruction word extends beyond the extracted sequence"); - let masked = u32::from_le_bytes(word) & !mask; - bytes[offset..offset + 4].copy_from_slice(&masked.to_le_bytes()); -} - -/// Read the 32-bit little-endian instruction at `offset` with the bits in `mask` cleared. -#[cfg(target_arch = "aarch64")] -fn masked_instruction_at(bytes: &[u8], offset: usize, mask: u32) -> u32 { - let word: [u8; 4] = bytes[offset..offset + 4] - .try_into() - .expect("instruction word extends beyond the extracted window"); - u32::from_le_bytes(word) & !mask -} - -/// Find the first instruction at or after `from` (scanning on 4-byte boundaries) whose value, once -/// `mask` is cleared, equals `target`. -#[cfg(target_arch = "aarch64")] -fn find_masked_instruction(bytes: &[u8], from: usize, mask: u32, target: u32) -> Option { - (from..bytes.len().saturating_sub(AARCH64_INSN_LEN - 1)) - .step_by(AARCH64_INSN_LEN) - .find(|&offset| masked_instruction_at(bytes, offset, mask) == target) -} - -fn tlsdesc_access_from_relocations( - section_data: &[u8], - relocations: &[Relocation], -) -> TlsDescAccess { - let (start, end) = tlsdesc_sequence_bounds(relocations, section_data.len()); - #[cfg_attr(not(target_arch = "aarch64"), allow(unused_mut))] - let mut core = section_data[start..end].to_vec(); - #[cfg(target_arch = "aarch64")] - mask_aarch64_descriptor_register(&mut core, relocations, start); - let sequence = TlsDescSequence { - core_instructions: core, - relocations: relocations - .iter() - .map(|relocation| Relocation { - offset: relocation.offset - - u64::try_from(start).expect("could not fit `start` into a u64"), - relocation_type: relocation.relocation_type, - addend: relocation.addend, - }) - .collect(), - }; - TlsDescAccess { - sequence, - #[cfg(target_arch = "aarch64")] - tp_window: extract_tp_window(section_data, relocations), - } -} - -fn tlsdesc_accesses_for_symbol_in_elf( - data: &[u8], - symbol: &str, - label: &str, -) -> Vec { - let elf = parse_elf(data, label); - let Some(section_headers) = elf.section_headers() else { - panic!("{label} has no ELF section headers"); - }; - let mut accesses = Vec::new(); - - for section_header in section_headers - .iter() - .filter(|shdr| matches!(shdr.sh_type, abi::SHT_REL | abi::SHT_RELA)) - { - let symbol_indexes = - symbol_indexes_in_table(&elf, section_header.sh_link as usize, symbol, label); - if symbol_indexes.is_empty() { - continue; - } - - let target_header = section_headers - .get(section_header.sh_info as usize) - .unwrap_or_else(|e| panic!("failed to read relocation target section header: {e}")); - let (target_data, _) = elf - .section_data(&target_header) - .unwrap_or_else(|e| panic!("failed to read relocation target section in {label}: {e}")); - let mut relocations = Vec::new(); - - match section_header.sh_type { - abi::SHT_REL => { - let rels = elf - .section_data_as_rels(§ion_header) - .unwrap_or_else(|e| panic!("failed to read REL relocations in {label}: {e}")); - relocations.extend( - rels.filter(|rel| { - symbol_indexes.contains(&rel.r_sym) - && is_tlsdesc_object_relocation(RelocationType(rel.r_type)) - }) - .map(|rel| Relocation { - offset: rel.r_offset, - relocation_type: RelocationType(rel.r_type), - addend: 0, - }), - ); - } - abi::SHT_RELA => { - let relas = elf - .section_data_as_relas(§ion_header) - .unwrap_or_else(|e| panic!("failed to read RELA relocations in {label}: {e}")); - relocations.extend( - relas - .filter(|rela| { - symbol_indexes.contains(&rela.r_sym) - && is_tlsdesc_object_relocation(RelocationType(rela.r_type)) - }) - .map(|rela| Relocation { - offset: rela.r_offset, - relocation_type: RelocationType(rela.r_type), - addend: rela.r_addend, - }), - ); - } - _ => unreachable!(), - } - - relocations.sort_by_key(|relocation| relocation.offset); - assert!( - relocations.len() % TLSDESC_RELOCATIONS_PER_ACCESS == 0, - "expected TLSDESC relocations for {symbol} in {label} to come in groups of \ - {TLSDESC_RELOCATIONS_PER_ACCESS}; found {relocations:?}" - ); - - accesses.extend( - relocations - .chunks_exact(TLSDESC_RELOCATIONS_PER_ACCESS) - .map(|chunk| tlsdesc_access_from_relocations(target_data, chunk)), - ); - } - - accesses -} - -fn tlsdesc_accesses_for_symbol_in_file(path: &Path, symbol: &str) -> Vec { - let data = - std::fs::read(path).unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display())); - tlsdesc_accesses_for_symbol_in_elf(&data, symbol, &path.display().to_string()) -} - -fn archive_tlsdesc_accesses_for_symbol(path: &Path, symbol: &str) -> Vec<(String, TlsDescAccess)> { - let mut accesses = Vec::new(); - - for_each_archive_elf_member(path, |member_name, member_data| { - let label = format!("{}({member_name})", path.display()); - accesses.extend( - tlsdesc_accesses_for_symbol_in_elf(member_data, symbol, &label) - .into_iter() - .map(|access| (member_name.to_owned(), access)), - ); - }); - - accesses -} - -/// Verify the compiler output performs the same thread-pointer computation our inline asm does: an -/// `mrs Xn, TPIDR_EL0` followed, in program order (possibly with unrelated instructions in between -/// — gcc may hoist the `mrs` and/or interleave the epilogue), by an `add x0, Xn, x0`. Both are -/// matched up to the scratch register, which the compiler is free to choose. -#[cfg(target_arch = "aarch64")] -fn assert_thread_pointer_computation( - rust: &TpWindow, - c: &TpWindow, - rust_label: &str, - c_label: &str, -) { - // Our inline asm emits the `mrs` and `add` as the two instructions right after the `blr`. - let mrs = masked_instruction_at( - &rust.bytes, - rust.blr_offset + AARCH64_INSN_LEN, - AARCH64_MRS_TP_REG_MASK, - ); - let add = masked_instruction_at( - &rust.bytes, - rust.blr_offset + 2 * AARCH64_INSN_LEN, - AARCH64_ADD_TP_REG_MASK, - ); - // Guard against our own asm drifting away from the shape this search assumes. - assert_eq!( - mrs, AARCH64_MRS_TPIDR_EL0_MASKED, - "expected our inline asm to read `tpidr_el0` right after the TLSDESC call in {rust_label}" - ); - assert_eq!( - add, AARCH64_ADD_TP_MASKED, - "expected our inline asm to `add x0, , x0` right after reading the thread pointer in \ - {rust_label}" - ); - - let mrs_pos = find_masked_instruction(&c.bytes, 0, AARCH64_MRS_TP_REG_MASK, mrs) - .unwrap_or_else(|| { - panic!( - "no `mrs Xn, tpidr_el0` found near the TLSDESC access in {c_label}: the compiler's \ - thread-pointer read does not match our inline asm. Set \ - {SKIP_TLS_SHIM_ASM_TEST_ENV}=1 to skip this guard with a different local compiler." - ) - }); - find_masked_instruction( - &c.bytes, - mrs_pos + AARCH64_INSN_LEN, - AARCH64_ADD_TP_REG_MASK, - add, - ) - .unwrap_or_else(|| { - panic!( - "no `add x0, Xn, x0` after the `tpidr_el0` read near the TLSDESC access in {c_label}: \ - the compiler's thread-pointer computation does not match our inline asm. Set \ - {SKIP_TLS_SHIM_ASM_TEST_ENV}=1 to skip this guard with a different local compiler." - ) - }); -} - -fn compile_tls_shim_object(dir: &Path) -> PathBuf { - let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/tls_shim.c"); - let object = dir.join("tls_shim.o"); - let mut compile_object = Command::new("cc"); - compile_object.args(["-O2", "-fPIC", "-fomit-frame-pointer", "-c"]); - - #[cfg(target_arch = "x86_64")] - compile_object.arg("-mtls-dialect=gnu2"); - - compile_object.arg(&source).arg("-o").arg(&object); - assert_command_success(&mut compile_object); - object -} - -#[test] -#[cfg_attr(miri, ignore)] -#[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))] -fn tlsdesc_inline_assembly_matches_c_compiler_sequence() { - fn print_skip_msg(label: &str) { - eprintln!("WARNING: {label}. Skipping inline assembly matches C compiler sequence test"); - } - - if !native_target() { - print_skip_msg("cross-compilation detected"); - return; - } - if skip_tls_shim_asm_test() { - print_skip_msg(&format!( - "{SKIP_TLS_SHIM_ASM_TEST_ENV} environment varialbe set" - )); - return; - } - - if !required_tools_available(&["cc"]) { - print_skip_msg("no C compiler available"); - return; - } - - let staticlib = staticlib_path(); - check_readable(&staticlib); - - let dir = build_dir("otel-thread-ctx-tls-shim"); - let c_object = compile_tls_shim_object(&dir); - let c_accesses = tlsdesc_accesses_for_symbol_in_file(&c_object, SYMBOL); - assert_eq!( - c_accesses.len(), - 1, - "expected one compiler-generated TLSDESC access in {}; found {}. \ - Set {SKIP_TLS_SHIM_ASM_TEST_ENV}=1 to skip this guard with a different local compiler.", - c_object.display(), - c_accesses.len() - ); - let expected = &c_accesses[0]; - - let rust_accesses = archive_tlsdesc_accesses_for_symbol(&staticlib, SYMBOL); - assert!( - !rust_accesses.is_empty(), - "expected at least one Rust inline-asm TLSDESC access for {SYMBOL} in {}", - staticlib.display() - ); - - for (member_name, access) in rust_accesses { - // The relocation-bearing core the linker relaxes must match byte-for-byte (up to the - // descriptor scratch register, which relaxation discards). - assert_eq!( - &access.sequence, - &expected.sequence, - "Rust inline assembly TLSDESC core in {}({member_name}) does not match compiler output \ - from {}. Set {SKIP_TLS_SHIM_ASM_TEST_ENV}=1 to skip this guard with a different local \ - compiler.", - staticlib.display(), - c_object.display() - ); - // The thread-pointer computation is not relocation-bearing; compilers schedule it freely, - // so we only require the same instructions to appear in order near the core (aarch64). - #[cfg(target_arch = "aarch64")] - assert_thread_pointer_computation( - &access.tp_window, - &expected.tp_window, - &format!("{}({member_name})", staticlib.display()), - &c_object.display().to_string(), - ); - } -} +use libdd_otel_thread_ctx::test_utils::artifacts::{artifact_path, check_readable}; #[test] #[cfg_attr(miri, ignore)] fn otel_thread_ctx_v1_tls_properties() { - let path = cdylib_path(); + let path = artifact_path("liblibdd_otel_thread_ctx_ffi.so"); check_readable(&path); libdd_otel_thread_ctx::sanity_check::check_tls_slot_in(&path).unwrap(); } diff --git a/libdd-otel-thread-ctx-ffi/tests/tls_shim.c b/libdd-otel-thread-ctx-ffi/tests/tls_shim.c deleted file mode 100644 index cf31f150a5..0000000000 --- a/libdd-otel-thread-ctx-ffi/tests/tls_shim.c +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ -// SPDX-License-Identifier: Apache-2.0 - -extern __thread void *otel_thread_ctx_v1 __attribute__((tls_model("global-dynamic"))); - -__attribute__((noinline)) void **tls_slot_from_c(void) { - return &otel_thread_ctx_v1; -} diff --git a/libdd-otel-thread-ctx-ffi/tests/tlsdesc_inline_sequence.rs b/libdd-otel-thread-ctx-ffi/tests/tlsdesc_inline_sequence.rs new file mode 100644 index 0000000000..f3bd1a55f9 --- /dev/null +++ b/libdd-otel-thread-ctx-ffi/tests/tlsdesc_inline_sequence.rs @@ -0,0 +1,94 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +//! Verify that the TLSDESC access to `otel_thread_ctx_v1` emitted by our inline assembly +//! ([`libdd_otel_thread_ctx::linux::tls_slot`]) is byte-for-byte identical to what a reference C +//! compiler emits for a `global-dynamic` TLSDESC access. +//! +//! Rather than compiling a C reference at test time, which requires to control precisely the +//! toolchain for reproducibility, and doing a instruction per instruction comparison, we compare a +//! SHA-256 hash of our sequence against a +//! hardcoded "golden" hash generated once instead. +//! +//! The golden hash is generated once outside of tests from a pinned toolchain and a C shim (see +//! [`EXPECTED_TLSDESC_HASH_X86_64`] / [`EXPECTED_TLSDESC_HASH_AARCH64`] via the `gen_tls_shim_hash` +//! binary). This ensures we have the exact same assembly than the one generated by the pinned +//! toolchain. We can update this hash if in the future the sequence generated by clang/gcc change +//! and we want to match the newer versions. +//! +//! We inspect the `staticlib` artifact (unlinked, so the relocations are still present) rather than +//! the relaxed `cdylib`. Library artifact paths are derived at runtime from the test executable +//! location. + +#![cfg(all( + target_os = "linux", + any(target_arch = "x86_64", target_arch = "aarch64") +))] + +use libdd_otel_thread_ctx::test_utils::artifacts::{artifact_path, check_readable}; +use libdd_otel_thread_ctx::test_utils::tls_shim_window::{windows_in_archive, Arch, SYMBOL}; + +/// Golden SHA-256 of the full x86-64 TLSDESC sequence (`lea …@tlsdesc` / `call …@tlscall` / +/// `add %fs:0x0, %rax`), generated by the `gen_tls_shim_hash` binary. See [`regenerate_hint`]. +const EXPECTED_TLSDESC_HASH_X86_64: &str = + "5b38b6b2a151997785463a5d9d2b1f1cb9027660ea37f2aafe94867508cedc66"; + +/// Golden SHA-256 of the full aarch64 TLSDESC sequence (`adrp`/`ldr`/`add`/`blr` followed by the +/// thread-pointer computation `mrs x8, tpidr_el0` / `add x0, x8, x0`), generated by the +/// `gen_tls_shim_hash` binary. See [`regenerate_hint`]. +const EXPECTED_TLSDESC_HASH_AARCH64: &str = + "f8adc6415409a726b51332a2c36931de9d3537dc33705c4a4aa160268e40ca97"; + +fn expected_hash(arch: Arch) -> &'static str { + match arch { + Arch::X86_64 => EXPECTED_TLSDESC_HASH_X86_64, + Arch::Aarch64 => EXPECTED_TLSDESC_HASH_AARCH64, + } +} + +/// Explains, for a failure message, how the golden hash is produced and how to refresh it. +fn regenerate_hint() -> String { + "The golden hash is the SHA-256 of the TLSDESC access sequence a reference C compiler emits for \ + a small TLS shim generated by the `gen_tls_shim_hash` binary. \ + If you changed the inline assembly in `libdd_otel_thread_ctx::linux::tls_slot` (or the pinned \ + toolchain) on purpose, see `gen_tls_shim_hash.rs` for instructions to re-run the \ + generator and update `EXPECTED_TLSDESC_HASH_*` in this file. Otherwise, the sequence our \ + assembly produces has drifted from what some compiler emits (and from what the linker expects \ + for TLS relaxation): run the generator and diff its `bytes:` line against the `actual bytes` \ + above to see which instruction differs." + .to_owned() +} + +#[test] +#[cfg_attr(miri, ignore)] +fn tlsdesc_inline_assembly_matches_reference_hash() { + let staticlib = artifact_path("liblibdd_otel_thread_ctx_ffi.a"); + check_readable(&staticlib); + + let windows = windows_in_archive(&staticlib); + assert!( + !windows.is_empty(), + "expected at least one Rust inline-asm TLSDESC access for {SYMBOL} in {}", + staticlib.display() + ); + + for window in windows { + let expected = expected_hash(window.arch); + let actual = window.hash_hex(); + assert_eq!( + actual, + expected, + "\nTLSDESC sequence hash mismatch for {SYMBOL} in {}\n \ + arch: {:?}\n \ + actual bytes: {}\n \ + actual hash: {}\n \ + expected hash: {}\n\n{}", + window.label, + window.arch, + window.hex_dump(), + actual, + expected, + regenerate_hint(), + ); + } +} diff --git a/libdd-otel-thread-ctx/Cargo.toml b/libdd-otel-thread-ctx/Cargo.toml index 9044c3eb4f..e9b58b7e28 100644 --- a/libdd-otel-thread-ctx/Cargo.toml +++ b/libdd-otel-thread-ctx/Cargo.toml @@ -19,6 +19,10 @@ bench = false [dependencies] anyhow = { version = "1.0", optional = true } elf = { version = "0.7", optional = true } +object = { version = "0.36", default-features = false, features = ["archive", "read_core"], optional = true } +sha2 = { version = "0.10", optional = true } [features] sanity-check = ["dep:elf", "dep:anyhow"] +# Test helpers shared with `libdd-otel-thread-ctx-ffi`'s integration tests and examples. +test-utils = ["dep:elf", "dep:object", "dep:sha2"] diff --git a/libdd-otel-thread-ctx/src/lib.rs b/libdd-otel-thread-ctx/src/lib.rs index d8aedbc202..868e3a17a2 100644 --- a/libdd-otel-thread-ctx/src/lib.rs +++ b/libdd-otel-thread-ctx/src/lib.rs @@ -17,15 +17,16 @@ //! The simplest pattern, when applicable, is to attach one record and then mutate it in place. //! This avoids allocation in the hot path. //! -//! ```ignore +//! ```rust //! use libdd_otel_thread_ctx::linux::ThreadContext; //! //! let trace_id = [0u8; 16]; //! let span_id = [1u8; 8]; +//! let local_root_span_id = [2u8; 8]; //! //! // First call allocates a record and attaches it. -//! ThreadContext::new(trace_id, span_id, &[(0, "first")]).attach(); -//! ThreadContext::update(trace_id, span_id, &[(0, "second")]); +//! ThreadContext::new(trace_id, span_id, local_root_span_id, &[(0, "first")]).attach(); +//! ThreadContext::update(trace_id, span_id, local_root_span_id, &[(0, "second")]); //! ThreadContext::detach(); //! ``` //! @@ -35,15 +36,16 @@ //! to be saved and restored repeatedly. Could be the case with async-runtimes where several tasks //! might run on the same thread, or even move from one thread to another, for example. //! -//! ```ignore +//! ```rust //! use libdd_otel_thread_ctx::linux::ThreadContext; //! //! let trace_id = [0u8; 16]; //! let span_id = [1u8; 8]; +//! let local_root_span_id = [2u8; 8]; //! let attrs: &[(u8, &str)] = &[(0, "GET"), (1, "/api/v1")]; //! //! // Publish a new context and save the previously attached one (if any). -//! let ctx = ThreadContext::new(trace_id, span_id, attrs); +//! let ctx = ThreadContext::new(trace_id, span_id, local_root_span_id, attrs); //! let previous = ctx.attach(); //! //! // ... do work inside the span ... @@ -79,6 +81,9 @@ compile_error!( #[cfg(all(target_os = "linux", feature = "sanity-check"))] pub mod sanity_check; +#[cfg(feature = "test-utils")] +pub mod test_utils; + #[cfg(all( target_os = "linux", any(target_arch = "x86_64", target_arch = "aarch64") @@ -146,19 +151,18 @@ pub mod linux { let ptr: usize; // WARNING: do not change the assembly below. See the warning above for amd64, and // https://github.com/ARM-software/abi-aa/blob/main/sysvabi64/sysvabi64.rst#general-dynamic. + // This code match byte-per-byte what clang generates, and this is verified during tests. core::arch::asm!( "adrp x0, :tlsdesc:otel_thread_ctx_v1", "ldr x1, [x0, :tlsdesc_lo12:otel_thread_ctx_v1]", "add x0, x0, :tlsdesc_lo12:otel_thread_ctx_v1", ".tlsdesccall otel_thread_ctx_v1", "blr x1", - // Read the thread pointer after the TLSDESC call, mirroring the sequence GCC/Clang - // emit. We reuse x1 to avoid register pressure on the surrounding Rust code. It's dead once - // the call returns anyway and we already clobbered it at this point. - "mrs x1, tpidr_el0", - "add x0, x1, x0", + "mrs x8, tpidr_el0", + "add x0, x8, x0", out("x0") ptr, out("x1") _, + out("x8") _, out("x30") _, ); ptr as *mut *mut ThreadContextRecord diff --git a/libdd-otel-thread-ctx/src/test_utils/artifacts.rs b/libdd-otel-thread-ctx/src/test_utils/artifacts.rs new file mode 100644 index 0000000000..c2521b3223 --- /dev/null +++ b/libdd-otel-thread-ctx/src/test_utils/artifacts.rs @@ -0,0 +1,32 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +//! Helpers to locate a crate's build artifacts from within an integration test binary. +//! +//! A test binary and the crate artifacts it exercises (`cdylib`, `staticlib`) live side by side in +//! `target/<[triple/]profile>/deps/`, so the paths are derived from the running test executable. + +use std::path::{Path, PathBuf}; + +/// Directory that holds the running test binary and the crate artifacts. +pub fn deps_dir() -> PathBuf { + // test binary: target/<[triple/]profile>/deps/ + let exe = std::env::current_exe().expect("failed to read current executable path"); + exe.parent() + .expect("unexpected test executable path structure") + .to_owned() +} + +/// Path to the artifact named `name` sitting next to the running test binary. +pub fn artifact_path(name: &str) -> PathBuf { + deps_dir().join(name) +} + +/// Assert that `path` can be opened for reading. +pub fn check_readable(path: &Path) { + assert!( + std::fs::File::open(path).is_ok(), + "{} could not be opened for reading", + path.display() + ); +} diff --git a/libdd-otel-thread-ctx/src/test_utils/mod.rs b/libdd-otel-thread-ctx/src/test_utils/mod.rs new file mode 100644 index 0000000000..1ff7af72be --- /dev/null +++ b/libdd-otel-thread-ctx/src/test_utils/mod.rs @@ -0,0 +1,8 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +//! Test utilities shared between this crate's tests, the `libdd-otel-thread-ctx-ffi` integration +//! tests and the `gen_tls_shim_hash` dev tool. + +pub mod artifacts; +pub mod tls_shim_window; diff --git a/libdd-otel-thread-ctx/src/test_utils/tls_shim_window.rs b/libdd-otel-thread-ctx/src/test_utils/tls_shim_window.rs new file mode 100644 index 0000000000..a06f92e248 --- /dev/null +++ b/libdd-otel-thread-ctx/src/test_utils/tls_shim_window.rs @@ -0,0 +1,358 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +//! Shared logic for locating and hashing the TLSDESC access sequence of `otel_thread_ctx_v1` in an +//! ELF object. +//! +//! This module has two consumers in `libdd-otel-thread-ctx-ffi`: +//! - the `tlsdesc_inline_sequence` integration test, which extracts the sequence from our own +//! statically linked artifact (produced by the inline assembly in `libdd_otel_thread_ctx`) and +//! hashes it +//! - the `gen_tls_shim_hash` binary, which extracts the sequence from a Clang-compiled reference +//! object and hashes it to produce the "golden" hash +//! +//! Both consumers must agree byte-for-byte, so the extraction lives here once and is shared. +//! +//! The target architecture is read from the ELF header at runtime, so the generator can process a +//! cross-compiled reference object for either architecture from any host. +//! +//! The "window" is the full TLSDESC access sequence, in program order: +//! - **x86-64**: `lea …@tlsdesc(%rip), %rax` / `call *…@tlscall(%rax)` / `add %fs:0x0, %rax`. +//! - **aarch64**: `adrp`/`ldr`/`add`/`blr` (the relocation-bearing core) followed by the +//! thread-pointer computation `mrs x8, tpidr_el0` / `add x0, x8, x0`. +//! +//! The window is located from the object's TLSDESC relocations and then sliced out with a fixed, +//! architecture-specific offset formula that matches how both Clang and our inline assembly lay the +//! sequence out (contiguously, thread-pointer read immediately after the call). The +//! relocation-bearing immediates are zero in the object file (the linker fills them in), so the +//! sliced bytes are stable. +//! +//! This window location and size might have to be patched if future versions of clang generate a +//! different access sequence in the future. + +use std::{path::Path, str::FromStr}; + +use elf::{abi, endian::AnyEndian, symbol::SymbolTable, ElfBytes}; +use object::read::archive::ArchiveFile; +use sha2::{Digest, Sha256}; + +/// The exported TLS symbol whose access sequence we hash. +pub const SYMBOL: &str = "otel_thread_ctx_v1"; + +/// The architectures for which we know how to locate and slice a TLSDESC sequence. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Arch { + X86_64, + Aarch64, +} + +impl FromStr for Arch { + type Err = String; + + fn from_str(name: &str) -> Result { + match name { + "x86_64" | "amd64" | "x86-64" => Ok(Arch::X86_64), + "aarch64" | "arm64" => Ok(Arch::Aarch64), + other => Err(format!( + "unknown architecture `{other}` (expected x86_64 or aarch64)" + )), + } + } +} + +impl Arch { + /// The architecture of the host this code was compiled for. + pub fn host() -> Arch { + if cfg!(target_arch = "aarch64") { + Arch::Aarch64 + } else { + Arch::X86_64 + } + } + + /// The `--target=` triple to hand a cross-compiling Clang for this architecture. + pub fn clang_target_triple(self) -> &'static str { + match self { + Arch::X86_64 => "x86_64-unknown-linux-gnu", + Arch::Aarch64 => "aarch64-unknown-linux-gnu", + } + } + + fn from_e_machine(e_machine: u16, label: &str) -> Arch { + match e_machine { + abi::EM_X86_64 => Arch::X86_64, + abi::EM_AARCH64 => Arch::Aarch64, + other => panic!("{label} has unsupported ELF machine type {other}"), + } + } + + /// Number of object-file TLSDESC relocations a single access emits. + fn relocations_per_access(self) -> usize { + match self { + // `lea` (GOTPC32_TLSDESC) + `call` (TLSDESC_CALL). + Arch::X86_64 => 2, + // `adrp` (ADR_PAGE21) + `ldr` (LD64_LO12) + `add` (ADD_LO12) + `blr` (CALL). + Arch::Aarch64 => 4, + } + } + + fn is_tlsdesc_object_relocation(self, relocation_type: u32) -> bool { + // `R_*_TLSDESC` (x86-64) / `R_AARCH64_TLSDESC` are the dynamic-linker relocations emitted + // after linking, so they are intentionally excluded here. + match self { + Arch::X86_64 => matches!( + relocation_type, + abi::R_X86_64_GOTPC32_TLSDESC | abi::R_X86_64_TLSDESC_CALL + ), + Arch::Aarch64 => matches!( + relocation_type, + abi::R_AARCH64_TLSDESC_LD_PREL19 + | abi::R_AARCH64_TLSDESC_ADR_PREL21 + | abi::R_AARCH64_TLSDESC_ADR_PAGE21 + | abi::R_AARCH64_TLSDESC_LD64_LO12 + | abi::R_AARCH64_TLSDESC_ADD_LO12 + | abi::R_AARCH64_TLSDESC_OFF_G1 + | abi::R_AARCH64_TLSDESC_OFF_G0_NC + | abi::R_AARCH64_TLSDESC_LDR + | abi::R_AARCH64_TLSDESC_ADD + | abi::R_AARCH64_TLSDESC_CALL + ), + } + } + + /// Compute the `[start, end)` byte bounds of the full TLSDESC sequence within its section, from + /// the (offset-sorted) relocation offsets of a single access. + fn sequence_bounds(self, offsets: &[u64], section_len: usize) -> (usize, usize) { + let (start, end) = match self { + // x86-64: the first relocation sits on the `lea` displacement (3 bytes into the + // instruction) and the second on the `call`. The trailing `add %fs:0x0, %rax` ends 11 + // bytes past the `call` relocation. + Arch::X86_64 => { + let first = to_usize(offsets[0], "first relocation offset"); + let call = to_usize(offsets[1], "call relocation offset"); + let start = first + .checked_sub(3) + .expect("x86-64 relocation offset is before the LEA displacement"); + (start, call + 11) + } + // aarch64: the four relocations sit on `adrp`/`ldr`/`add`/`blr`. The window starts at + // the `adrp` and extends 12 bytes past the last relocation: `blr` (4) followed by the + // two non-relocated instructions `mrs x8, tpidr_el0` (4) and `add x0, x8, x0` (4). + Arch::Aarch64 => { + let start = to_usize(offsets[0], "first relocation offset"); + let last = to_usize(offsets[offsets.len() - 1], "last relocation offset"); + (start, last + 12) + } + }; + assert!( + end <= section_len, + "{self:?} TLSDESC sequence extends beyond section data" + ); + (start, end) + } +} + +fn to_usize(value: u64, what: &str) -> usize { + usize::try_from(value).unwrap_or_else(|_| panic!("{what} does not fit in usize")) +} + +/// A single TLSDESC access sequence extracted from an object, ready to be hashed. +pub struct TlsDescWindow { + /// Human-readable origin (file, archive member, …), used in messages. + pub label: String, + /// The detected architecture of the object the sequence came from. + pub arch: Arch, + /// The full sequence bytes, sliced out with the architecture-specific bounds. + pub bytes: Vec, +} + +impl TlsDescWindow { + /// Lowercase hex SHA-256 of the sequence bytes. + pub fn hash_hex(&self) -> String { + let digest = Sha256::digest(&self.bytes); + let mut out = String::with_capacity(digest.len() * 2); + for byte in digest { + out.push_str(&format!("{byte:02x}")); + } + out + } + + /// Render the sequence bytes as hex, one 4-byte little-endian word per group on aarch64 + /// (matching the fixed-width instruction encoding) or a single hex string on x86-64. Used in + /// failure messages so a human can eyeball which bytes differ. + pub fn hex_dump(&self) -> String { + match self.arch { + Arch::Aarch64 => self + .bytes + .chunks(4) + .map(|word| word.iter().map(|b| format!("{b:02x}")).collect::()) + .collect::>() + .join(" "), + Arch::X86_64 => self.bytes.iter().map(|b| format!("{b:02x}")).collect(), + } + } +} + +fn parse_elf<'data>(data: &'data [u8], label: &str) -> ElfBytes<'data, AnyEndian> { + ElfBytes::::minimal_parse(data) + .unwrap_or_else(|e| panic!("failed to parse ELF data from {label}: {e}")) +} + +fn symbol_indexes_in_table( + elf: &ElfBytes<'_, AnyEndian>, + symtab_index: usize, + label: &str, +) -> Vec { + let Some(section_headers) = elf.section_headers() else { + panic!("{label} has no ELF section headers"); + }; + let symtab_header = section_headers + .get(symtab_index) + .unwrap_or_else(|e| panic!("failed to read symbol table header {symtab_index}: {e}")); + + // Relocation sections link to the symbol table they use; archive members usually use + // `.symtab`, while linked dynamic artifacts may use `.dynsym`. + if !matches!(symtab_header.sh_type, abi::SHT_SYMTAB | abi::SHT_DYNSYM) { + return Vec::new(); + } + + let strtab_header = section_headers + .get(symtab_header.sh_link as usize) + .unwrap_or_else(|e| panic!("failed to read linked string table header: {e}")); + let strtab = elf + .section_data_as_strtab(&strtab_header) + .unwrap_or_else(|e| panic!("failed to read linked string table in {label}: {e}")); + let (symtab_data, _) = elf + .section_data(&symtab_header) + .unwrap_or_else(|e| panic!("failed to read symbol table data in {label}: {e}")); + let symtab = SymbolTable::new(elf.ehdr.endianness, elf.ehdr.class, symtab_data); + + symtab + .iter() + .enumerate() + .filter_map(|(index, sym)| { + strtab + .get(sym.st_name as usize) + .ok() + .filter(|name| *name == SYMBOL) + .map(|_| index as u32) + }) + .collect() +} + +/// Extract every TLSDESC access sequence for [`SYMBOL`] from a single ELF object blob. +pub fn windows_in_elf(data: &[u8], label: &str) -> Vec { + let elf = parse_elf(data, label); + let arch = Arch::from_e_machine(elf.ehdr.e_machine, label); + let Some(section_headers) = elf.section_headers() else { + panic!("{label} has no ELF section headers"); + }; + let mut windows = Vec::new(); + + for section_header in section_headers + .iter() + .filter(|shdr| matches!(shdr.sh_type, abi::SHT_REL | abi::SHT_RELA)) + { + let symbol_indexes = symbol_indexes_in_table(&elf, section_header.sh_link as usize, label); + if symbol_indexes.is_empty() { + continue; + } + + let target_header = section_headers + .get(section_header.sh_info as usize) + .unwrap_or_else(|e| panic!("failed to read relocation target section header: {e}")); + let (target_data, _) = elf + .section_data(&target_header) + .unwrap_or_else(|e| panic!("failed to read relocation target section in {label}: {e}")); + + let mut offsets = Vec::new(); + match section_header.sh_type { + abi::SHT_REL => { + let rels = elf + .section_data_as_rels(§ion_header) + .unwrap_or_else(|e| panic!("failed to read REL relocations in {label}: {e}")); + offsets.extend( + rels.filter(|rel| { + symbol_indexes.contains(&rel.r_sym) + && arch.is_tlsdesc_object_relocation(rel.r_type) + }) + .map(|rel| rel.r_offset), + ); + } + abi::SHT_RELA => { + let relas = elf + .section_data_as_relas(§ion_header) + .unwrap_or_else(|e| panic!("failed to read RELA relocations in {label}: {e}")); + offsets.extend( + relas + .filter(|rela| { + symbol_indexes.contains(&rela.r_sym) + && arch.is_tlsdesc_object_relocation(rela.r_type) + }) + .map(|rela| rela.r_offset), + ); + } + _ => unreachable!(), + } + + if offsets.is_empty() { + continue; + } + + offsets.sort_unstable(); + let per_access = arch.relocations_per_access(); + assert!( + offsets.len() % per_access == 0, + "expected TLSDESC relocations for {SYMBOL} in {label} to come in groups of \ + {per_access}; found {} offsets", + offsets.len() + ); + + for chunk in offsets.chunks_exact(per_access) { + let (start, end) = arch.sequence_bounds(chunk, target_data.len()); + windows.push(TlsDescWindow { + label: label.to_owned(), + arch, + bytes: target_data[start..end].to_vec(), + }); + } + } + + windows +} + +/// Extract every TLSDESC access sequence for [`SYMBOL`] from a single ELF object file on disk. +pub fn windows_in_object_file(path: &Path) -> Vec { + let data = + std::fs::read(path).unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display())); + windows_in_elf(&data, &path.display().to_string()) +} + +/// Extract every TLSDESC access sequence for [`SYMBOL`] from every ELF member of a static archive. +pub fn windows_in_archive(path: &Path) -> Vec { + let archive_data = + std::fs::read(path).unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display())); + let archive = ArchiveFile::parse(&*archive_data) + .unwrap_or_else(|e| panic!("failed to parse archive {}: {e}", path.display())); + + let mut windows = Vec::new(); + for member in archive.members() { + let member = + member.unwrap_or_else(|e| panic!("failed to read member in {}: {e}", path.display())); + let member_data = member.data(&*archive_data).unwrap_or_else(|e| { + panic!( + "failed to read member data for {} in {}: {e}", + String::from_utf8_lossy(member.name()), + path.display() + ) + }); + + if member_data.starts_with(&abi::ELFMAGIC) { + let member_name = String::from_utf8_lossy(member.name()).into_owned(); + let label = format!("{}({member_name})", path.display()); + windows.extend(windows_in_elf(member_data, &label)); + } + } + + windows +}