diff --git a/README.md b/README.md index 811132aca..2a33f31e1 100644 --- a/README.md +++ b/README.md @@ -160,11 +160,14 @@ codspeed exec --mode simulation -- ./my-binary ### Memory -Tracks heap allocations (peak usage, count, allocation size) with eBPF profiling. +Tracks native heap allocations (peak usage, count, allocation size) with eBPF profiling. **Best for:** Memory optimization, leak detection, constrained environments -**Supported:** Rust, C/C++ with libc, jemalloc, mimalloc +**Supported:** Rust, C/C++ with libc, jemalloc, mimalloc; Python and Node.js +frames in allocation stacks (`codspeed exec` enables their perf maps). Only +allocations made through tracked native allocators are measured, not all +Python objects or V8 heap allocations. ```bash codspeed exec --mode memory -- ./my-binary diff --git a/crates/exec-harness/src/analysis/mod.rs b/crates/exec-harness/src/analysis/mod.rs index 8bb4eaf44..537e6d496 100644 --- a/crates/exec-harness/src/analysis/mod.rs +++ b/crates/exec-harness/src/analysis/mod.rs @@ -20,6 +20,9 @@ pub fn perform(commands: Vec) -> Result<()> { let mut cmd = Command::new(&benchmark_cmd.command[0]); cmd.args(&benchmark_cmd.command[1..]); + // Python and Node frames are only symbolized from runtime perf maps. + cmd.env("PYTHONPERFSUPPORT", "1"); + crate::node::set_node_options(&mut cmd, crate::node::MEMORY_NODE_OPTIONS); hooks.start_benchmark().unwrap(); let status = cmd.status(); hooks.stop_benchmark().unwrap(); @@ -58,7 +61,7 @@ pub fn perform_with_valgrind(commands: Vec) -> Result<()> { cmd.env("PYTHONPERFSUPPORT", "1"); cmd.env(constants::URI_ENV, &name_and_uri.uri); - crate::node::set_node_options(&mut cmd); + crate::node::set_node_options(&mut cmd, &[]); let mut child = cmd.spawn().context("Failed to spawn command")?; diff --git a/crates/exec-harness/src/node.rs b/crates/exec-harness/src/node.rs index d2c6c4771..e097c8863 100644 --- a/crates/exec-harness/src/node.rs +++ b/crates/exec-harness/src/node.rs @@ -2,13 +2,17 @@ use std::process::Command; const NODE_OPTIONS_TO_ADD: &[&str] = &["--perf-basic-prof"]; -/// Appends CodSpeed-required Node.js options to `NODE_OPTIONS` on a [`Command`], -/// preserving any existing value from the environment. -pub fn set_node_options(cmd: &mut Command) { +/// Without this flag, all interpreted JS frames share V8's interpreter +/// trampoline, so memory stacks cannot name the allocating JS function. +pub const MEMORY_NODE_OPTIONS: &[&str] = &["--interpreted-frames-native-stack"]; + +/// Appends CodSpeed-required Node.js options, plus `extra`, to `NODE_OPTIONS` +/// on a [`Command`], preserving any existing value from the environment. +pub fn set_node_options(cmd: &mut Command, extra: &[&str]) { let existing = std::env::var("NODE_OPTIONS").unwrap_or_default(); let mut parts: Vec<&str> = existing.split_whitespace().collect(); - for opt in NODE_OPTIONS_TO_ADD { + for opt in NODE_OPTIONS_TO_ADD.iter().chain(extra) { if !parts.contains(opt) { parts.push(opt); } diff --git a/crates/exec-harness/src/walltime/benchmark_loop.rs b/crates/exec-harness/src/walltime/benchmark_loop.rs index a66b075fe..411172a7a 100644 --- a/crates/exec-harness/src/walltime/benchmark_loop.rs +++ b/crates/exec-harness/src/walltime/benchmark_loop.rs @@ -17,7 +17,7 @@ pub fn run_rounds( let do_one_round = || -> Result<(u64, u64)> { let mut cmd = Command::new(&command[0]); cmd.args(&command[1..]); - crate::node::set_node_options(&mut cmd); + crate::node::set_node_options(&mut cmd, &[]); let mut child = cmd.spawn().context("Failed to execute command")?; let bench_round_start_ts_ns = InstrumentHooks::current_timestamp(); let status = child diff --git a/crates/memtrack/testdata/node_alloc.js b/crates/memtrack/testdata/node_alloc.js new file mode 100644 index 000000000..b08a16e7c --- /dev/null +++ b/crates/memtrack/testdata/node_alloc.js @@ -0,0 +1,12 @@ +// Run with `node --perf-basic-prof --interpreted-frames-native-stack`: V8 then +// gives `allocation` a `JS:~allocation` entry in /tmp/perf-.map, so the +// native ArrayBuffer allocation below can be attributed to this JS function. +const ALLOCATION_SIZE = 2_000_001; + +function allocation() { + // `allocUnsafeSlow` skips the Buffer pool, so V8 asks the allocator for + // exactly ALLOCATION_SIZE bytes. + return Buffer.allocUnsafeSlow(ALLOCATION_SIZE); +} + +allocation(); diff --git a/crates/memtrack/testdata/python_alloc.py b/crates/memtrack/testdata/python_alloc.py new file mode 100644 index 000000000..ab44e8bd1 --- /dev/null +++ b/crates/memtrack/testdata/python_alloc.py @@ -0,0 +1,19 @@ +# Run with `python3 -X perf`: the perf trampoline gives `allocation` a +# `py::allocation:` entry in /tmp/perf-.map, so the native malloc +# below can be attributed to this Python function offline. +import ctypes + +libc = ctypes.CDLL(None) +libc.malloc.restype = ctypes.c_void_p +libc.malloc.argtypes = [ctypes.c_size_t] +libc.free.argtypes = [ctypes.c_void_p] + +ALLOCATION_SIZE = 2_000_001 + + +def allocation(): + ptr = libc.malloc(ALLOCATION_SIZE) + libc.free(ptr) + + +allocation() diff --git a/crates/memtrack/tests/interpreter_tests.rs b/crates/memtrack/tests/interpreter_tests.rs new file mode 100644 index 000000000..ec9add681 --- /dev/null +++ b/crates/memtrack/tests/interpreter_tests.rs @@ -0,0 +1,86 @@ +#[macro_use] +mod shared; + +use memtrack::TrackerOptions; +use runner_shared::artifacts::{MemtrackEvent, MemtrackEventKind}; +use std::path::Path; +use std::process::Command; + +const ALLOCATION_SIZE: u64 = 2_000_001; + +/// Find the fixture's allocation and return the pid that made it. The stack +/// must be captured, since offline attribution walks it through the JIT frame. +fn allocation_pid(events: &[MemtrackEvent]) -> libc::pid_t { + let (pid, stack_hash) = events + .iter() + .find_map(|event| match event.kind { + MemtrackEventKind::Malloc { size, stack_hash } + | MemtrackEventKind::Calloc { size, stack_hash } + | MemtrackEventKind::AlignedAlloc { size, stack_hash } + if size == ALLOCATION_SIZE => + { + Some((event.pid, stack_hash)) + } + _ => None, + }) + .unwrap_or_else(|| panic!("no {ALLOCATION_SIZE}-byte allocation was tracked")); + assert_ne!(stack_hash, 0, "allocation has no captured stack"); + + pid +} + +/// The runtime wrote a perf map for the allocating process naming the +/// fixture's `allocation` function, which the runner harvests from /tmp. +fn assert_perf_map_symbol(pid: libc::pid_t, symbol: &str) { + let path = format!("/tmp/perf-{pid}.map"); + let perf_map = + std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("failed to read {path}: {e}")); + let _ = std::fs::remove_file(&path); + + assert!( + perf_map.lines().any(|line| line.contains(symbol)), + "{path} has no `{symbol}` entry" + ); +} + +fn fixture(name: &str) -> String { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("testdata") + .join(name) + .to_string_lossy() + .into_owned() +} + +fn stack_capture() -> TrackerOptions { + TrackerOptions::builder().stack_capture(true).build() +} + +#[test_with::env(GITHUB_ACTIONS)] +#[test_log::test] +fn test_python_allocation_has_jit_symbol() -> anyhow::Result<()> { + let mut command = Command::new("python3"); + command.args(["-X", "perf", &fixture("python_alloc.py")]); + let (events, thread_handle) = shared::track_command(command, stack_capture())?; + + assert_perf_map_symbol(allocation_pid(&events), "py::allocation:"); + + thread_handle.join().unwrap(); + Ok(()) +} + +#[test_with::env(GITHUB_ACTIONS)] +#[test_log::test] +fn test_node_allocation_has_jit_symbol() -> anyhow::Result<()> { + let mut command = Command::new("node"); + command.args([ + "--perf-basic-prof", + "--interpreted-frames-native-stack", + &fixture("node_alloc.js"), + ]); + let (events, thread_handle) = shared::track_command(command, stack_capture())?; + + assert_perf_map_symbol(allocation_pid(&events), "JS:~allocation "); + + thread_handle.join().unwrap(); + Ok(()) +} diff --git a/src/executor/memory/executor.rs b/src/executor/memory/executor.rs index d54a0d675..e021f482f 100644 --- a/src/executor/memory/executor.rs +++ b/src/executor/memory/executor.rs @@ -181,14 +181,13 @@ impl Executor for MemoryExecutor { debug!("cmd: {cmd:?}"); let runner_fifo = RunnerFifo::new()?; - let integration = Rc::new(RefCell::new(None)); + let fifo_data = Rc::new(RefCell::new(None)); let on_process_started = { - let integration = integration.clone(); + let fifo_data_cell = fifo_data.clone(); |mut child: std::process::Child| async move { - let (marker_result, fifo_data, exit_status) = + let (marker_result, data, exit_status) = Self::handle_fifo(runner_fifo, ipc, &mut child).await?; - *integration.borrow_mut() = fifo_data.integration; - + *fifo_data_cell.borrow_mut() = Some(data); marker_result.save_to(&results_folder).unwrap(); Ok(exit_status) @@ -202,16 +201,22 @@ impl Executor for MemoryExecutor { bail!("failed to execute memory tracker process: {status}"); } - if let Some(integration) = integration.borrow_mut().take() { - let results_folder = execution_context.profile_folder.join("results"); - if let Err(e) = save_module_artifacts( - &execution_context.profile_folder, - &results_folder, - integration, - ) { - // The memory results are complete without them; only offline - // stack attribution is lost. - error!("Failed to save memtrack module artifacts: {e:#}"); + let data = fifo_data.borrow_mut().take(); + if let Some(data) = data { + if let Some(integration) = data.integration { + let results_folder = execution_context.profile_folder.join("results"); + if let Err(e) = save_module_artifacts( + &execution_context.profile_folder, + &results_folder, + integration, + &data.bench_pids, + ) + .await + { + // The memory results are complete without them; only offline + // stack attribution is lost. + error!("Failed to save memtrack module artifacts: {e:#}"); + } } } diff --git a/src/executor/memory/module_artifacts.rs b/src/executor/memory/module_artifacts.rs index 8bd0596c1..b3bee9a9f 100644 --- a/src/executor/memory/module_artifacts.rs +++ b/src/executor/memory/module_artifacts.rs @@ -1,13 +1,15 @@ +use crate::executor::helpers::harvest_perf_maps_for_pids::harvest_perf_maps_for_pids; use crate::executor::shared::module_artifacts::loaded_module::LoadedModule; use crate::executor::shared::module_artifacts::module_symbols::ModuleSymbols; use crate::executor::shared::module_artifacts::save_artifacts::save_artifacts; use crate::executor::shared::module_artifacts::unwind_data::unwind_data_from_elf; +use crate::executor::wall_time::profiler::perf::jit_dump::save_symbols_and_harvest_unwind_data_for_pids; use crate::prelude::*; use libc::pid_t; use runner_shared::artifacts::{ArtifactExt, MemtrackArtifact, MemtrackEventKind}; use runner_shared::metadata::MemtrackMetadata; use runner_shared::unwind_data::ProcessUnwindData; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::ops::Range; use std::os::unix::fs::MetadataExt; use std::path::{Path, PathBuf}; @@ -33,23 +35,33 @@ struct ProcessMapping { timestamp: u64, } -/// Turn the mappings memtrack recorded into the artifacts an offline unwinder -/// needs: the deduplicated `unwind_data`/`symbols.map` files, plus the -/// `memtrack.metadata` referencing them per pid. -/// -/// `results_folder` is where memtrack wrote its artifacts; the keyed files and -/// the metadata land in `profile_folder`, next to walltime's equivalents. -pub fn save_module_artifacts( +/// Save the native modules memtrack mapped, per-process perf maps, and JIT +/// unwind data needed for offline stack attribution. The keyed native and JIT +/// files and their `memtrack.metadata` land in `profile_folder`; memtrack's +/// event streams are read from `results_folder`. +pub async fn save_module_artifacts( profile_folder: &Path, results_folder: &Path, integration: (String, String), + bench_pids: &HashSet, ) -> Result<()> { let mappings = read_mappings(results_folder)?; - if mappings.is_empty() { - debug!("No module mappings recorded, skipping memtrack module artifacts"); + let pids = mappings + .iter() + .map(|mapping| mapping.pid) + .chain(bench_pids.iter().copied()) + .collect::>(); + if pids.is_empty() { + debug!("No processes recorded, skipping memtrack module artifacts"); return Ok(()); } + // Python and Node emit perf maps in /tmp; Python JIT dumps also contain + // unwind data needed to walk through interpreter trampolines. + harvest_perf_maps_for_pids(profile_folder, &pids).await?; + let jit_unwind_data = + save_symbols_and_harvest_unwind_data_for_pids(profile_folder, &pids).await?; + let loaded_modules = loaded_modules_from_mappings(&mappings); debug!( "Extracting artifacts for {} modules from {} mappings", @@ -57,7 +69,7 @@ pub fn save_module_artifacts( mappings.len() ); - let saved = save_artifacts(profile_folder, &loaded_modules, &HashMap::new()); + let saved = save_artifacts(profile_folder, &loaded_modules, &jit_unwind_data); MemtrackMetadata::new(integration, saved.artifacts).save_to(profile_folder) } @@ -218,17 +230,16 @@ fn loaded_modules_from_mappings(mappings: &[ProcessMapping]) -> HashMap/maps` line into the mapping event memtrack records + /// for it. + fn mapping_event_from_maps_line(pid: pid_t, timestamp: u64, line: &str) -> MemtrackEvent { + let fields = line.split_whitespace().collect::>(); + let (start, end) = fields[0].split_once('-').unwrap(); + let (start, end) = ( + u64::from_str_radix(start, 16).unwrap(), + u64::from_str_radix(end, 16).unwrap(), + ); + let (major, minor) = fields[3].split_once(':').unwrap(); + let dev = + u64::from_str_radix(major, 16).unwrap() << 20 | u64::from_str_radix(minor, 16).unwrap(); + + MemtrackEvent { + pid, + tid: pid, + timestamp, + addr: start, + kind: MemtrackEventKind::Mapping { + path: fields[5].to_string(), + dev, + ino: fields[4].parse().unwrap(), + file_offset: u64::from_str_radix(fields[2], 16).unwrap(), + len: end - start, + }, + } + } + + /// A process can map its own binary a second time, as V8 does with its + /// embedded builtins. Each placement has its own load bias, and frames in + /// either copy must resolve. + #[tokio::test] + async fn keeps_every_placement_of_a_module_mapped_twice() { + let build = tempfile::tempdir().unwrap(); + let binary = build.path().join("remapped_text"); + let status = std::process::Command::new("gcc") + .args(["-O0", "-o"]) + .arg(&binary) + .arg("testdata/memory/remapped_text.c") + .status() + .unwrap(); + assert!(status.success(), "failed to compile remapped_text.c"); + + let output = std::process::Command::new(&binary).output().unwrap(); + assert!(output.status.success(), "remapped_text failed"); + let stdout = String::from_utf8(output.stdout).unwrap(); + let (maps_lines, allocate_line) = stdout.trim_end().rsplit_once('\n').unwrap(); + let runtime_addrs = allocate_line + .strip_prefix("allocate ") + .unwrap() + .split(' ') + .map(|addr| u64::from_str_radix(addr, 16).unwrap()) + .collect::>(); + + const PID: pid_t = 4321; + let profile = tempfile::tempdir().unwrap(); + let results = profile.path().join("results"); + std::fs::create_dir_all(&results).unwrap(); + MemtrackArtifact { + events: maps_lines + .lines() + .zip(1..) + .map(|(line, timestamp)| mapping_event_from_maps_line(PID, timestamp, line)) + .collect(), + } + .save_with_pid_to(&results, PID) + .unwrap(); + + save_module_artifacts( + profile.path(), + &results, + ("exec-harness".to_string(), "1.0.0".to_string()), + &HashSet::new(), + ) + .await + .unwrap(); + + let metadata = MemtrackMetadata::from_reader( + std::fs::File::open(profile.path().join("memtrack.metadata")).unwrap(), + ) + .unwrap(); + let artifacts = &metadata.artifacts; + let key = artifacts + .path_key_to_path + .iter() + .find_map(|(key, path)| (path == &binary).then_some(key)) + .unwrap(); + + let symbols = + std::fs::read_to_string(profile.path().join(format!("{key}.symbols.map"))).unwrap(); + let allocate_svma = symbols + .lines() + .find_map(|line| { + let fields = line.split(' ').collect::>(); + (fields[2] == "allocate").then(|| u64::from_str_radix(fields[0], 16).unwrap()) + }) + .unwrap(); + let load_biases = artifacts.mapped_process_module_symbols[&PID] + .iter() + .filter(|mapped| &mapped.perf_map_key == key) + .map(|mapped| mapped.load_bias) + .collect::>(); + let unwind_ranges = artifacts.mapped_process_unwind_data_by_pid[&PID] + .iter() + .filter(|mapped| &mapped.unwind_data_key == key) + .map(|mapped| mapped.inner.avma_range.clone()) + .collect::>(); + + for addr in runtime_addrs { + assert!( + load_biases + .iter() + .any(|bias| addr.wrapping_sub(*bias) == allocate_svma), + "no load bias resolves {addr:#x} to `allocate`, got {load_biases:x?}" + ); + assert!( + unwind_ranges.iter().any(|range| range.contains(&addr)), + "no unwind data covers {addr:#x}, got {unwind_ranges:x?}" + ); + } + } + fn mapping_event(pid: pid_t, timestamp: u64, addr: u64, path: &str) -> MemtrackEvent { MemtrackEvent { pid, diff --git a/src/executor/shared/module_artifacts/loaded_module.rs b/src/executor/shared/module_artifacts/loaded_module.rs index 2c9bd88e6..cd7da7d9c 100644 --- a/src/executor/shared/module_artifacts/loaded_module.rs +++ b/src/executor/shared/module_artifacts/loaded_module.rs @@ -18,13 +18,25 @@ pub struct LoadedModule { pub process_loaded_modules: HashMap, } -#[derive(Default)] +/// Every placement of a module in one process. A process can map the same file +/// more than once at different addresses, and each placement has its own load +/// bias. +#[derive(Default, Clone)] pub struct ProcessLoadedModule { - /// Load bias used to adjust declared elf addresses to their actual runtime addresses - /// The bias is the difference between where the segment *actually* is in memory versus where the ELF file *preferred* it to be - pub symbols_load_bias: Option, - /// Unwind data specific to the process mounting, derived from both load bias and the actual unwind data - pub process_unwind_data: Option, + /// Load biases used to adjust declared elf addresses to their actual runtime addresses, one + /// per distinct placement. A bias is the difference between where the segment *actually* is in + /// memory versus where the ELF file *preferred* it to be + pub symbols_load_biases: Vec, + /// Unwind data of each executable mapping, derived from both load bias and the actual unwind data + pub process_unwind_data: Vec, +} + +impl ProcessLoadedModule { + pub fn add_load_bias(&mut self, load_bias: u64) { + if !self.symbols_load_biases.contains(&load_bias) { + self.symbols_load_biases.push(load_bias); + } + } } impl LoadedModule { diff --git a/src/executor/shared/module_artifacts/save_artifacts.rs b/src/executor/shared/module_artifacts/save_artifacts.rs index 3e8903ed9..386b76734 100644 --- a/src/executor/shared/module_artifacts/save_artifacts.rs +++ b/src/executor/shared/module_artifacts/save_artifacts.rs @@ -108,7 +108,7 @@ fn save_symbols( } let key = &path_to_key[path]; for (&pid, pm) in &loaded_module.process_loaded_modules { - if let Some(load_bias) = pm.symbols_load_bias { + for &load_bias in &pm.symbols_load_biases { mappings_by_pid .entry(pid) .or_default() @@ -158,7 +158,7 @@ fn save_debug_info( continue; }; for (&pid, pm) in &loaded_module.process_loaded_modules { - if let Some(load_bias) = pm.symbols_load_bias { + for &load_bias in &pm.symbols_load_biases { mappings_by_pid .entry(pid) .or_default() @@ -204,7 +204,7 @@ fn save_unwind_data( } let key = &path_to_key[path]; for (&pid, pm) in &loaded_module.process_loaded_modules { - if let Some(ref pud) = pm.process_unwind_data { + for pud in &pm.process_unwind_data { mappings_by_pid .entry(pid) .or_default() @@ -281,7 +281,7 @@ fn collect_ignored_modules( }; for (&pid, pm) in &loaded_module.process_loaded_modules { - if let Some(load_bias) = pm.symbols_load_bias { + for &load_bias in &pm.symbols_load_biases { by_pid.entry(pid).or_default().push(( path_str.to_string(), elf_start + load_bias, diff --git a/src/executor/wall_time/profiler/perf/mod.rs b/src/executor/wall_time/profiler/perf/mod.rs index 7f31921e2..dbc0e464a 100644 --- a/src/executor/wall_time/profiler/perf/mod.rs +++ b/src/executor/wall_time/profiler/perf/mod.rs @@ -30,7 +30,7 @@ use runner_shared::metadata::WalltimeMetadata; use std::path::Path; use std::path::PathBuf; -mod jit_dump; +pub(crate) mod jit_dump; mod parse_perf_file; pub(crate) mod setup; diff --git a/src/executor/wall_time/profiler/perf/parse_perf_file.rs b/src/executor/wall_time/profiler/perf/parse_perf_file.rs index 1d1033b38..a3a8505a7 100644 --- a/src/executor/wall_time/profiler/perf/parse_perf_file.rs +++ b/src/executor/wall_time/profiler/perf/parse_perf_file.rs @@ -1,4 +1,4 @@ -use crate::executor::shared::module_artifacts::loaded_module::{LoadedModule, ProcessLoadedModule}; +use crate::executor::shared::module_artifacts::loaded_module::LoadedModule; use crate::executor::shared::module_artifacts::module_symbols::ModuleSymbols; use crate::executor::shared::module_artifacts::unwind_data::unwind_data_from_elf; use crate::prelude::*; @@ -182,14 +182,7 @@ fn inherit_parent_mappings( use std::collections::hash_map::Entry; for loaded_module in loaded_modules_by_path.values_mut() { - let inherited = - loaded_module - .process_loaded_modules - .get(&ppid) - .map(|p| ProcessLoadedModule { - symbols_load_bias: p.symbols_load_bias, - process_unwind_data: p.process_unwind_data.clone(), - }); + let inherited = loaded_module.process_loaded_modules.get(&ppid).cloned(); let Some(inherited) = inherited else { continue; }; @@ -277,8 +270,7 @@ fn process_mmap2_record( } } - // Store load bias for this process mounting - process_loaded_module.symbols_load_bias = Some(load_bias); + process_loaded_module.add_load_bias(load_bias); // Extract unwind_data match unwind_data_from_elf( @@ -290,7 +282,9 @@ fn process_mmap2_record( ) { Ok((unwind_data, process_unwind_data)) => { loaded_module.unwind_data = Some(unwind_data); - process_loaded_module.process_unwind_data = Some(process_unwind_data); + process_loaded_module + .process_unwind_data + .push(process_unwind_data); } Err(error) => { debug!("Failed to load unwind data for module {record_path_string}: {error}"); @@ -301,14 +295,15 @@ fn process_mmap2_record( #[cfg(test)] mod tests { use super::*; + use crate::executor::shared::module_artifacts::loaded_module::ProcessLoadedModule; fn make_module_with_parent(ppid: pid_t, load_bias: u64) -> LoadedModule { let mut m = LoadedModule::default(); m.process_loaded_modules.insert( ppid, ProcessLoadedModule { - symbols_load_bias: Some(load_bias), - process_unwind_data: None, + symbols_load_biases: vec![load_bias], + process_unwind_data: vec![], }, ); m @@ -326,7 +321,7 @@ mod tests { let m = &modules[&PathBuf::from("/lib/libpython.so")]; let child = m.process_loaded_modules.get(&200).unwrap(); - assert_eq!(child.symbols_load_bias, Some(0xdead)); + assert_eq!(child.symbols_load_biases, vec![0xdead]); } #[test] @@ -337,8 +332,8 @@ mod tests { m.process_loaded_modules.insert( 200, ProcessLoadedModule { - symbols_load_bias: Some(0xcafe), - process_unwind_data: None, + symbols_load_biases: vec![0xcafe], + process_unwind_data: vec![], }, ); modules.insert(PathBuf::from("/lib/libpython.so"), m); @@ -349,7 +344,7 @@ mod tests { .process_loaded_modules .get(&200) .unwrap(); - assert_eq!(child.symbols_load_bias, Some(0xcafe)); + assert_eq!(child.symbols_load_biases, vec![0xcafe]); } #[test] @@ -363,8 +358,8 @@ mod tests { bash.process_loaded_modules.insert( 200, ProcessLoadedModule { - symbols_load_bias: Some(0xaaaaaaaa0000), - process_unwind_data: None, + symbols_load_biases: vec![0xaaaaaaaa0000], + process_unwind_data: vec![], }, ); modules.insert(PathBuf::from("/usr/bin/bash"), bash); @@ -394,8 +389,8 @@ mod tests { .process_loaded_modules .get(&100) .unwrap() - .symbols_load_bias, - Some(0xaaaaaaaa0000) + .symbols_load_biases, + vec![0xaaaaaaaa0000] ); } } diff --git a/testdata/memory/remapped_text.c b/testdata/memory/remapped_text.c new file mode 100644 index 000000000..613b66c27 --- /dev/null +++ b/testdata/memory/remapped_text.c @@ -0,0 +1,90 @@ +// Maps the executable segment holding `allocate` a second time at another +// address, the way V8 remaps its embedded builtins, and allocates through both +// copies. The process then holds two placements of its own binary, each with a +// different load bias. +// +// Prints the executable mappings of the binary, as /proc/self/maps lines, +// followed by `allocate `. +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include + +typedef void *(*alloc_fn)(size_t); +typedef void *(*allocate_fn)(alloc_fn, size_t); + +// Only touches its arguments, so it runs unchanged from either copy. +__attribute__((noinline, optimize("no-optimize-sibling-calls"))) void * +allocate(alloc_fn alloc, size_t size) { + return alloc(size); +} + +int main(void) { + char exe[PATH_MAX]; + ssize_t exe_len = readlink("/proc/self/exe", exe, sizeof(exe) - 1); + if (exe_len < 0) { + return 1; + } + exe[exe_len] = '\0'; + + FILE *maps = fopen("/proc/self/maps", "r"); + if (!maps) { + return 1; + } + + uintptr_t target = (uintptr_t)&allocate; + uintptr_t start = 0, end = 0; + unsigned long long offset = 0; + char line[PATH_MAX + 128]; + while (fgets(line, sizeof(line), maps)) { + uintptr_t s, e; + char perms[5]; + unsigned long long off; + if (sscanf(line, "%lx-%lx %4s %llx", &s, &e, perms, &off) == 4 && + perms[2] == 'x' && s <= target && target < e) { + start = s; + end = e; + offset = off; + break; + } + } + fclose(maps); + if (!start) { + return 1; + } + + int fd = open(exe, O_RDONLY); + if (fd < 0) { + return 1; + } + uint8_t *copy = mmap(NULL, end - start, PROT_READ | PROT_EXEC, MAP_PRIVATE, + fd, (off_t)offset); + close(fd); + if (copy == MAP_FAILED) { + return 1; + } + + allocate_fn remapped = (allocate_fn)(void *)(copy + (target - start)); + free(allocate(malloc, 1111)); + free(remapped(malloc, 2222)); + maps = fopen("/proc/self/maps", "r"); + if (!maps) { + return 1; + } + while (fgets(line, sizeof(line), maps)) { + char perms[5]; + if (sscanf(line, "%*x-%*x %4s", perms) == 1 && perms[2] == 'x' && + strstr(line, exe)) { + fputs(line, stdout); + } + } + fclose(maps); + + printf("allocate %lx %lx\n", target, (uintptr_t)remapped); + return 0; +}