From fb649f96a66b2e718baeb87422f66e95e54b9c41 Mon Sep 17 00:00:00 2001 From: Gustavo Noronha Silva Date: Wed, 10 Jun 2026 10:07:34 -0300 Subject: [PATCH] Add follow-forks support (-f) to trace child processes When a client requests follow-forks, the daemon always subscribes the fork family (clone, clone3, and fork/vfork on x86_64) the same way it already does for execve, watches their successful exits in the dispatch loop, and registers the returned child PID for every interested client: the child goes into the eBPF PID filter, gets its own pidfd lifecycle monitoring, and its events flow into the same output stream. clone/clone3 with CLONE_THREAD are skipped, since threads already share the traced thread group. Children are discovered from the parent's fork exit event, so a child's very first syscalls may be missed; this is documented in the README. Client bookkeeping now copes with one client spanning several PIDs: disconnect cleans up every PID left without clients, and the per-UID registration cap counts distinct clients rather than per-PID entries. The D-Bus trace_pid method gains a follow_forks argument, and the client grows the -f/--follow-forks flag. The integration harness gains a way to pass extra client flags through the UML runner. Co-Authored-By: Claude Fable 5 --- README.md | 7 ++ lupa/src/main.rs | 4 +- pinchy-client/src/lib.rs | 19 ++- pinchy/src/bin/test-helper.rs | 32 +++++ pinchy/src/client.rs | 15 ++- pinchy/src/server.rs | 3 +- pinchy/src/tracing.rs | 222 ++++++++++++++++++++++++++++++---- pinchy/tests/common.rs | 17 ++- pinchy/tests/integration.rs | 29 ++++- uml-kernel/uml-test-runner.sh | 6 +- 10 files changed, 319 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 751e8ba9..91c64e2a 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,13 @@ List the syscall names supported by this build: pinchy --list-syscalls ``` +Follow child processes created by the tracee (like `strace -f`): +```shell +pinchy -f -- sh -c 'ls | wc -l' +``` +The daemon learns about new children from the parent's fork/clone exit, so a +child's very first syscalls may be missed. + Other knobs: - `--format one-line|multi-line`: trace line formatting (default `one-line`) diff --git a/lupa/src/main.rs b/lupa/src/main.rs index af0cdc01..c2c9c71b 100644 --- a/lupa/src/main.rs +++ b/lupa/src/main.rs @@ -51,10 +51,10 @@ fn main() -> Result<()> { let (pid, fd) = tokio::runtime::Runtime::new()?.block_on(async move { let syscalls = vec![SYS_openat, SYS_close, SYS_read, SYS_write]; let (pid, fd) = if let Some(pid) = args.pid { - let fd = pinchy_client::attach(pid, syscalls).await; + let fd = pinchy_client::attach(pid, syscalls, false).await; (pid, fd) } else if let Some(command) = args.command { - let (pid, fd) = pinchy_client::trace_child(command, syscalls).await; + let (pid, fd) = pinchy_client::trace_child(command, syscalls, false).await; (pid as u32, fd) } else { anyhow::bail!("Need one of -p or command") diff --git a/pinchy-client/src/lib.rs b/pinchy-client/src/lib.rs index 06ba7d2e..0e6e31b4 100644 --- a/pinchy-client/src/lib.rs +++ b/pinchy-client/src/lib.rs @@ -7,22 +7,31 @@ use zbus::{Error as ZBusError, fdo, names::WellKnownName, proxy}; #[proxy(interface = "org.pinchy.Service", default_path = "/org/pinchy/Service")] pub trait Pinchy { - fn trace_pid(&self, pid: u32, syscalls: Vec) -> zbus::Result; + fn trace_pid( + &self, + pid: u32, + syscalls: Vec, + follow_forks: bool, + ) -> zbus::Result; } -pub async fn attach(pid: u32, syscalls: Vec) -> OwnedFd { +pub async fn attach(pid: u32, syscalls: Vec, follow_forks: bool) -> OwnedFd { let proxy = match connect_to_server().await { Ok(proxy) => proxy, Err(e) => handle_dbus_error(e), }; - match proxy.trace_pid(pid, syscalls).await { + match proxy.trace_pid(pid, syscalls, follow_forks).await { Ok(fd) => OwnedFd::from(fd), Err(e) => handle_dbus_error(e), } } -pub async fn trace_child(command: Vec, syscalls: Vec) -> (i32, OwnedFd) { +pub async fn trace_child( + command: Vec, + syscalls: Vec, + follow_forks: bool, +) -> (i32, OwnedFd) { let proxy = match connect_to_server().await { Ok(proxy) => proxy, Err(e) => handle_dbus_error(e), @@ -107,7 +116,7 @@ pub async fn trace_child(command: Vec, syscalls: Vec) -> (i32, Ow std::process::exit(1); } } - let fd = match proxy.trace_pid(pid as u32, syscalls).await { + let fd = match proxy.trace_pid(pid as u32, syscalls, follow_forks).await { Ok(fd) => OwnedFd::from(fd), Err(e) => handle_dbus_error(e), }; diff --git a/pinchy/src/bin/test-helper.rs b/pinchy/src/bin/test-helper.rs index a78e2741..d57adec2 100644 --- a/pinchy/src/bin/test-helper.rs +++ b/pinchy/src/bin/test-helper.rs @@ -102,6 +102,7 @@ fn main() -> anyhow::Result<()> { "rt_sigaction_realtime" => rt_sigaction_realtime(), "rt_sigaction_standard" => rt_sigaction_standard(), "fcntl_test" => fcntl_test(), + "fork_test" => fork_test(), "fchdir_test" => fchdir_test(), "network_test" => network_test(), "accept_test" => accept_test(), @@ -1665,6 +1666,37 @@ fn fchdir_test() -> anyhow::Result<()> { Ok(()) } +fn fork_test() -> anyhow::Result<()> { + unsafe { + let pid = libc::fork(); + assert!(pid >= 0, "fork failed"); + + if pid == 0 { + // Child: do a recognizable syscall so a follow-forks trace can + // assert on it, then exit without running any libc cleanup. The + // daemon learns about the child from the parent's clone exit, so + // retry over a few hundred milliseconds to let it catch up. + for _ in 0..5 { + libc::usleep(100_000); + + let fd = libc::openat(libc::AT_FDCWD, c"/dev/null".as_ptr(), libc::O_RDONLY); + + if fd >= 0 { + libc::close(fd); + } + } + + libc::_exit(0); + } + + let mut status = 0; + let ret = libc::waitpid(pid, &mut status, 0); + assert_eq!(ret, pid, "waitpid failed"); + } + + Ok(()) +} + fn fcntl_test() -> anyhow::Result<()> { unsafe { // Open a file to test fcntl on diff --git a/pinchy/src/client.rs b/pinchy/src/client.rs index a6ed9408..8b9e3cd3 100644 --- a/pinchy/src/client.rs +++ b/pinchy/src/client.rs @@ -26,7 +26,12 @@ mod tests; #[proxy(interface = "org.pinchy.Service", default_path = "/org/pinchy/Service")] trait Pinchy { - fn trace_pid(&self, pid: u32, syscalls: Vec) -> zbus::Result; + fn trace_pid( + &self, + pid: u32, + syscalls: Vec, + follow_forks: bool, + ) -> zbus::Result; } const DEFAULT_STDOUT_FLUSH_BYTES: usize = 1; @@ -141,6 +146,10 @@ struct Args { #[arg(long = "list-syscalls")] list_syscalls: bool, + /// Follow forks: also trace child processes created by the tracee + #[arg(short = 'f', long = "follow-forks")] + follow_forks: bool, + /// Write trace output to FILE instead of stderr #[arg(short = 'o', long = "output")] output: Option, @@ -180,7 +189,7 @@ async fn main() -> Result<()> { let style = args.style; if let Some(command) = args.command { - let (pid, fd) = pinchy_client::trace_child(command, syscalls).await; + let (pid, fd) = pinchy_client::trace_child(command, syscalls, args.follow_forks).await; // Read everything there is to read, the server will close the write end // of the pipe @@ -188,7 +197,7 @@ async fn main() -> Result<()> { pinchy_client::cleanup_and_quit(pid); } else if let Some(pid) = args.pid { - let fd = pinchy_client::attach(pid, syscalls).await; + let fd = pinchy_client::attach(pid, syscalls, args.follow_forks).await; relay_to_sink(fd, style, args.output).await?; } else { diff --git a/pinchy/src/server.rs b/pinchy/src/server.rs index 7b52a95f..24455c8e 100644 --- a/pinchy/src/server.rs +++ b/pinchy/src/server.rs @@ -121,6 +121,7 @@ impl PinchyDBus { #[zbus(connection)] conn: &zbus::Connection, pid: u32, syscalls: Vec, + follow_forks: bool, ) -> zbus::fdo::Result> { // The eBPF SYSCALL_FILTER is a 512-bit bitmap indexed by syscall // number; reject anything outside that range before it reaches the @@ -164,7 +165,7 @@ impl PinchyDBus { .dispatch .write() .await - .register_client(pid, writer, syscalls, Some(pidfd), caller_uid) + .register_client(pid, writer, syscalls, Some(pidfd), caller_uid, follow_forks) .await .map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?; diff --git a/pinchy/src/tracing.rs b/pinchy/src/tracing.rs index 92e18bd5..ecb3db96 100644 --- a/pinchy/src/tracing.rs +++ b/pinchy/src/tracing.rs @@ -1,7 +1,7 @@ #![allow(non_snake_case, non_upper_case_globals)] use std::{ collections::{HashMap, HashSet}, - os::fd::{AsRawFd as _, OwnedFd}, + os::fd::{AsRawFd as _, FromRawFd as _, OwnedFd}, sync::{ atomic::{AtomicU64, AtomicUsize, Ordering}, Arc, @@ -14,7 +14,8 @@ use aya::{ Ebpf, }; use pinchy_common::{ - compact_payload_size, syscalls, wire_validation_enabled, WireEventHeader, WIRE_VERSION, + compact_payload_size, syscalls, wire_validation_enabled, Clone3Data, CloneData, + WireEventHeader, WIRE_VERSION, }; use tokio::{ io::{unix::AsyncFd, AsyncWriteExt}, @@ -484,6 +485,7 @@ struct Client { sender: tokio::sync::mpsc::Sender>, syscalls: HashSet, queue_stats: Arc, + follow_forks: bool, } #[derive(Debug)] @@ -540,6 +542,54 @@ fn parse_wire_metadata(event: &[u8], validate_wire: bool) -> Option bool { + if nr == syscalls::SYS_clone || nr == syscalls::SYS_clone3 { + return true; + } + + #[cfg(target_arch = "x86_64")] + if nr == syscalls::SYS_fork || nr == syscalls::SYS_vfork { + return true; + } + + false +} + +// A fork-family exit in a traced process announces a new child. clone and +// clone3 with CLONE_THREAD stay within the traced thread group, so they are +// not followed. +fn fork_child_pid(header: &WireEventHeader, event: &[u8]) -> Option { + if header.return_value <= 0 { + return None; + } + + let payload = &event[std::mem::size_of::()..]; + + let flags = if header.syscall_nr == syscalls::SYS_clone { + if payload.len() < std::mem::size_of::() { + return None; + } + + let data = unsafe { std::ptr::read_unaligned(payload.as_ptr() as *const CloneData) }; + data.flags + } else if header.syscall_nr == syscalls::SYS_clone3 { + if payload.len() < std::mem::size_of::() { + return None; + } + + let data = unsafe { std::ptr::read_unaligned(payload.as_ptr() as *const Clone3Data) }; + data.cl_args.flags + } else { + 0 + }; + + if flags & libc::CLONE_THREAD as u64 != 0 { + return None; + } + + Some(header.return_value as u32) +} + fn parse_client_queue_capacity() -> usize { std::env::var("PINCHY_CLIENT_QUEUE_CAPACITY") .ok() @@ -647,6 +697,7 @@ impl EventDispatch { tokio::spawn(async move { let mut dispatch_batch: Vec<(WireEventHeader, Arc<[u8]>)> = Vec::with_capacity(256); let mut revalidate_pids: Vec = Vec::new(); + let mut fork_children: Vec<(u32, u32)> = Vec::new(); loop { tokio::select! { @@ -662,6 +713,7 @@ impl EventDispatch { let dispatch = event_dispatch.read().await; dispatch_batch.clear(); revalidate_pids.clear(); + fork_children.clear(); while let Some(item) = ring.next() { let event = &*item; @@ -687,6 +739,14 @@ impl EventDispatch { revalidate_pids.push(header.pid); } + if is_fork_syscall(header.syscall_nr) { + if let Some(child) = fork_child_pid(&header, event) { + if !fork_children.contains(&(header.pid, child)) { + fork_children.push((header.pid, child)); + } + } + } + let framed_event = Arc::<[u8]>::from(event); event_stats.framed_event(framed_event.len()); @@ -702,9 +762,15 @@ impl EventDispatch { guard.clear_ready(); drop(dispatch); - if !revalidate_pids.is_empty() { + if !fork_children.is_empty() || !revalidate_pids.is_empty() { let mut dispatch = event_dispatch.write().await; + for (parent, child) in fork_children.drain(..) { + if let Err(e) = dispatch.handle_fork(parent, child).await { + eprintln!("Error following fork of PID {parent}: {e}"); + } + } + for pid in revalidate_pids.drain(..) { if let Err(e) = dispatch.revalidate_pid_authorization(pid).await @@ -825,9 +891,11 @@ impl EventDispatch { mut syscalls: Vec, pidfd: Option, // Pass pidfd from server for monitoring uid: u32, + follow_forks: bool, ) -> anyhow::Result { // Each registration holds a pipe, a writer task and a queue in the - // daemon; bound what a single user can pin down. + // daemon; bound what a single user can pin down. A client following + // forks appears once per traced PID, so count distinct client ids. const MAX_CLIENTS_PER_UID: usize = 64; if uid != 0 { let existing = self @@ -835,7 +903,9 @@ impl EventDispatch { .values() .flatten() .filter(|client| client.uid == uid) - .count(); + .map(|client| client.client_id) + .collect::>() + .len(); if existing >= MAX_CLIENTS_PER_UID { anyhow::bail!("too many concurrent traces for uid {uid}"); @@ -861,6 +931,7 @@ impl EventDispatch { sender: event_tx, syscalls: syscalls.into_iter().collect(), queue_stats: queue_stats.clone(), + follow_forks, }; let is_new_pid = !self.clients_map.contains_key(&pid); @@ -868,18 +939,25 @@ impl EventDispatch { // Add PID to eBPF filter if this is the first client for this PID if is_new_pid { - let mut pid_filter: aya::maps::HashMap<_, u32, u8> = self - .ebpf - .map_mut("PID_FILTER") - .ok_or_else(|| anyhow::anyhow!("PID_FILTER map not found"))? - .try_into() - .map_err(|e| anyhow::anyhow!("Map conversion failed: {e}"))?; - pid_filter - .insert(pid, 0, 0) - .map_err(|e| anyhow::anyhow!("Failed to insert PID: {e}"))?; // Start monitoring this PID if we got a pidfd - if let Some(pidfd) = pidfd { - // Set up async monitoring - self.start_pidfd_monitoring(pid, pidfd).await?; + let setup = async { + self.add_pid_to_filter(pid)?; + + // Start monitoring this PID if we got a pidfd + if let Some(pidfd) = pidfd { + // Set up async monitoring + self.start_pidfd_monitoring(pid, pidfd).await?; + } + + Ok::<(), anyhow::Error>(()) + }; + + if let Err(e) = setup.await { + // Roll back the registration so a failed setup does not + // leave a clientless entry (or a stray filter PID) behind. + self.clients_map.remove(&pid); + let _ = self.remove_pid_from_filter(pid).await; + + return Err(e); } } @@ -901,20 +979,22 @@ impl EventDispatch { } pub async fn remove_client(&mut self, client_id: u64) -> anyhow::Result<()> { - let mut removed_pid = None; + // A client following forks is registered for several PIDs; collect + // every PID left without clients, not just the first. + let mut removed_pids = Vec::new(); self.clients_map.retain(|&pid, clients| { clients.retain(|client| client.client_id != client_id); if clients.is_empty() { - removed_pid = Some(pid); + removed_pids.push(pid); false } else { true } }); - // Remove PID from eBPF filter if this was the last client for this PID - if let Some(pid) = removed_pid { + // Remove PIDs from eBPF filter if this was their last client + for pid in removed_pids { // Clean up any timeout tracking for this PID self.pid_timeouts.remove(&pid); @@ -925,6 +1005,69 @@ impl EventDispatch { Ok(()) } + // A traced process with a follow-forks client created a new child + // process: register the interested clients for the child as well, so its + // events flow into the same output stream. + pub async fn handle_fork(&mut self, parent: u32, child: u32) -> anyhow::Result<()> { + let Some(parent_clients) = self.clients_map.get(&parent) else { + return Ok(()); + }; + + let followers: Vec = parent_clients + .iter() + .filter(|client| { + client.follow_forks + && !self + .clients_map + .get(&child) + .map(|clients| clients.iter().any(|c| c.client_id == client.client_id)) + .unwrap_or(false) + }) + .map(|client| Client { + client_id: client.client_id, + pid: child, + uid: client.uid, + sender: client.sender.clone(), + syscalls: client.syscalls.clone(), + queue_stats: client.queue_stats.clone(), + follow_forks: true, + }) + .collect(); + + if followers.is_empty() { + return Ok(()); + } + + let is_new_pid = !self.clients_map.contains_key(&child); + + if is_new_pid { + // The child may already be gone; in that case no events were + // captured for it (it was never in the filter), so just skip it. + let pidfd = unsafe { libc::syscall(libc::SYS_pidfd_open, child, 0u32) }; + + if pidfd < 0 { + return Ok(()); + } + + let pidfd = unsafe { OwnedFd::from_raw_fd(pidfd as std::os::fd::RawFd) }; + + self.add_pid_to_filter(child)?; + + if let Err(e) = self.start_pidfd_monitoring(child, pidfd).await { + // Without monitoring the child would stay in the filter + // forever; roll back instead of tracing it unsupervised. + let _ = self.remove_pid_from_filter(child).await; + + return Err(e); + } + } + + log::debug!("following fork: parent {parent} -> child {child}"); + self.clients_map.entry(child).or_default().extend(followers); + + Ok(()) + } + // After a successful execve the traced process may have gained // privileges (setuid binary). /proc/ ownership follows the // process's effective credentials (and turns to root when it becomes @@ -1008,6 +1151,28 @@ impl EventDispatch { } } + // Likewise capture the fork family while any client follows forks, + // so the dispatch loop learns about new children. + if self + .clients_map + .values() + .flatten() + .any(|client| client.follow_forks) + { + let fork_syscalls = [ + syscalls::SYS_clone, + syscalls::SYS_clone3, + #[cfg(target_arch = "x86_64")] + syscalls::SYS_fork, + #[cfg(target_arch = "x86_64")] + syscalls::SYS_vfork, + ]; + + for nr in fork_syscalls { + bitmap[(nr / 8) as usize] |= 1 << (nr % 8); + } + } + let mut map: aya::maps::Array<_, u8> = Array::try_from(self.ebpf.map_mut("SYSCALL_FILTER").unwrap()).unwrap(); @@ -1016,6 +1181,21 @@ impl EventDispatch { } } + fn add_pid_to_filter(&mut self, pid: u32) -> anyhow::Result<()> { + let mut pid_filter: aya::maps::HashMap<_, u32, u8> = self + .ebpf + .map_mut("PID_FILTER") + .ok_or_else(|| anyhow::anyhow!("PID_FILTER map not found"))? + .try_into() + .map_err(|e| anyhow::anyhow!("Map conversion failed: {e}"))?; + + pid_filter + .insert(pid, 0, 0) + .map_err(|e| anyhow::anyhow!("Failed to insert PID: {e}"))?; + + Ok(()) + } + async fn remove_pid_from_filter(&mut self, pid: u32) -> anyhow::Result<()> { // Remove PID from eBPF filter let mut pid_filter: aya::maps::HashMap<_, u32, u8> = self diff --git a/pinchy/tests/common.rs b/pinchy/tests/common.rs index 1df729f6..df378588 100644 --- a/pinchy/tests/common.rs +++ b/pinchy/tests/common.rs @@ -100,6 +100,7 @@ fn boot_uml( events: Option<&str>, workload: Option<&str>, test_name: Option<&str>, + extra_args: Option<&str>, ) -> UmlResult { let kpath = kernel_path(); @@ -117,6 +118,7 @@ fn boot_uml( let events_str = events.unwrap_or(""); let workload_str = workload.unwrap_or(""); let test_name_str = test_name.unwrap_or(""); + let extra_args_str = extra_args.unwrap_or(""); let args = vec![ "mem=128M".to_string(), @@ -133,6 +135,7 @@ fn boot_uml( format!("PINCHY_TEST_PROJDIR={}", proj.display()), format!("PINCHY_TEST_MODE={}", mode.as_str()), format!("PINCHY_TEST_NAME={test_name_str}"), + format!("PINCHY_TEST_EXTRA_ARGS={extra_args_str}"), ]; let output = Command::new(&kpath) @@ -243,7 +246,7 @@ impl PinchyTest { let has_result = self.result.lock().unwrap().is_some(); if !has_result { - let result = boot_uml(&self.mode, self.output_dir.path(), None, None, None); + let result = boot_uml(&self.mode, self.output_dir.path(), None, None, None, None); *self.result.lock().unwrap() = Some(result); } @@ -266,7 +269,18 @@ impl PinchyTest { } pub fn run_workload(pinchy: &PinchyTest, events: &[&str], test_name: &str) -> JoinHandle { + run_workload_with_args(pinchy, events, test_name, &[]) +} + +// extra_args are additional pinchy client flags, e.g. &["-f"]. +pub fn run_workload_with_args( + pinchy: &PinchyTest, + events: &[&str], + test_name: &str, + extra_args: &[&str], +) -> JoinHandle { let events_str = events.join(","); + let extra_args_str = extra_args.join(","); let test_name = test_name.to_owned(); let output_dir = pinchy.output_dir.path().to_path_buf(); let result_arc = Arc::clone(&pinchy.result); @@ -279,6 +293,7 @@ pub fn run_workload(pinchy: &PinchyTest, events: &[&str], test_name: &str) -> Jo Some(&events_str), Some(&test_name), Some(&test_name), + Some(&extra_args_str), ); // Take the pinchy output before storing the result diff --git a/pinchy/tests/integration.rs b/pinchy/tests/integration.rs index 7f54e17d..710b4542 100644 --- a/pinchy/tests/integration.rs +++ b/pinchy/tests/integration.rs @@ -4,7 +4,7 @@ mod common; use assert_cmd::assert::Assert; -use common::{run_workload, PinchyTest, TestMode}; +use common::{run_workload, run_workload_with_args, PinchyTest, TestMode}; use indoc::indoc; use predicates::prelude::*; @@ -354,6 +354,33 @@ fn fcntl_syscalls() { .stdout(predicate::str::ends_with("Exiting...\n")); } +#[test] +fn follow_forks() { + let pinchy = PinchyTest::new(); + + // The fork_test workload forks a child that opens /dev/null; only a + // follow-forks trace sees the child's openat. + let handle = run_workload_with_args(&pinchy, &["openat"], "fork_test", &["-f"]); + + let expected_output = escaped_regex(indoc! {r#" + @PID@ openat(dfd: AT_FDCWD, pathname: "/dev/null", flags: 0x0 (O_RDONLY), mode: 0) = @NUMBER@ (fd) + "#}); + + let output = handle.join().unwrap(); + Assert::new(output) + .success() + .stderr(predicate::str::is_match(&expected_output).unwrap()); + + // Server output - has to be at the end, since we kill the server for waiting. + let output = pinchy.wait(); + Assert::new(output) + .success() + .stdout(predicate::str::ends_with( + "Exiting... +", + )); +} + #[test] fn pipe_operations_syscalls() { let pinchy = PinchyTest::new(); diff --git a/uml-kernel/uml-test-runner.sh b/uml-kernel/uml-test-runner.sh index 690e6c72..a797460d 100755 --- a/uml-kernel/uml-test-runner.sh +++ b/uml-kernel/uml-test-runner.sh @@ -38,6 +38,10 @@ for param in $(cat /proc/cmdline); do PINCHY_TEST_NAME=*) PINCHY_TEST_NAME="${param#PINCHY_TEST_NAME=}" ;; + PINCHY_TEST_EXTRA_ARGS=*) + # Comma-separated so the value survives the kernel command line. + PINCHY_TEST_EXTRA_ARGS=$(printf '%s' "${param#PINCHY_TEST_EXTRA_ARGS=}" | tr ',' ' ') + ;; PINCHY_BENCH_LOOPS=*) PINCHY_BENCH_LOOPS="${param#PINCHY_BENCH_LOOPS=}" ;; @@ -217,7 +221,7 @@ run_standard() { build_event_args # Run pinchy client with test-helper workload under setsid - setsid sh -c "$PINCHY $EVENT_ARGS -- $TEST_HELPER $PINCHY_TEST_WORKLOAD \ + setsid sh -c "$PINCHY $EVENT_ARGS $PINCHY_TEST_EXTRA_ARGS -- $TEST_HELPER $PINCHY_TEST_WORKLOAD \ >\"$OUTDIR/pinchy.stdout\" 2>\"$OUTDIR/pinchy.stderr\"; \ echo \$? >\"$OUTDIR/pinchy.exit\""