mctp: Add I2C MCTP throughput test

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
diff --git a/target/ast10x0/tests/mctp/throughput/BUILD.bazel b/target/ast10x0/tests/mctp/throughput/BUILD.bazel
new file mode 100644
index 0000000..e1b8070
--- /dev/null
+++ b/target/ast10x0/tests/mctp/throughput/BUILD.bazel
@@ -0,0 +1,191 @@
+# Licensed under the Apache-2.0 license
+# SPDX-License-Identifier: Apache-2.0
+
+load("@pigweed//pw_kernel/tooling:rust_app.bzl", "rust_app")
+load("@pigweed//pw_kernel/tooling:system_image.bzl", "system_image")
+load("@pigweed//pw_kernel/tooling:target_codegen.bzl", "target_codegen")
+load("@pigweed//pw_kernel/tooling:target_linker_script.bzl", "target_linker_script")
+load("@rules_rust//rust:defs.bzl", "rust_binary")
+load("//target/ast10x0:defs.bzl", "TARGET_COMPATIBLE_WITH", "system_image_test")
+
+# MCTP-over-I2C throughput between two physical AST1060 EVBs (Bus 2 wired
+# bus-to-bus), using the echo apps. Card A runs a passive echo responder; card B
+# (the "requester" image) runs the requester that times round trips.
+#
+# On each card the I2C driver and the MCTP server live in ONE process
+# (`mctp_i2c_server`), so MCTP packets reach the bus without an I2C IPC hop.
+# The echo app is a separate process talking to that server over IPC.
+
+filegroup(
+    name = "system_config",
+    srcs = ["system.json5"],
+    visibility = ["//visibility:public"],
+)
+
+filegroup(
+    name = "requester_system_config",
+    srcs = ["requester_system.json5"],
+    visibility = ["//visibility:public"],
+)
+
+target_codegen(
+    name = "codegen",
+    arch = "@pigweed//pw_kernel/arch/arm_cortex_m:arch_arm_cortex_m",
+    system_config = ":system_config",
+    target_compatible_with = TARGET_COMPATIBLE_WITH,
+)
+
+target_linker_script(
+    name = "linker_script",
+    system_config = ":system_config",
+    tags = ["kernel"],
+    target_compatible_with = TARGET_COMPATIBLE_WITH,
+    template = "//target/ast10x0:linker_script_template",
+)
+
+rust_binary(
+    name = "target",
+    srcs = ["target.rs"],
+    edition = "2024",
+    tags = ["kernel"],
+    target_compatible_with = TARGET_COMPATIBLE_WITH,
+    deps = [
+        ":codegen",
+        ":linker_script",
+        "//target/ast10x0:entry",
+        "//target/ast10x0/board:ast10x0_board",
+        "//target/ast10x0/peripherals",
+        "@ast1060_pac",
+        "@pigweed//pw_kernel/arch/arm_cortex_m:arch_arm_cortex_m",
+        "@pigweed//pw_kernel/kernel",
+        "@pigweed//pw_kernel/subsys/console:console_backend",
+        "@pigweed//pw_kernel/target:target_common",
+        "@pigweed//pw_kernel/userspace",
+    ],
+)
+
+SERVER_DEPS = [
+    "//services/i2c/api:i2c_api",
+    "//services/mctp/api:mctp_api",
+    "//services/mctp/server:mctp_server_lib",
+    "//services/mctp/transport-i2c:mctp_transport_i2c",
+    "//target/ast10x0/backend/i2c:i2c_backend_ast10x0",
+    "//target/ast10x0/peripherals",
+    "@pigweed//pw_kernel/syscall:syscall_user",
+    "@pigweed//pw_kernel/userspace",
+    "@pigweed//pw_log/rust:pw_log",
+    "@pigweed//pw_status/rust:pw_status",
+    "@rust_crates//:embedded-hal",
+    "@rust_crates//:mctp",
+    "@rust_crates//:mctp-lib",
+]
+
+rust_app(
+    name = "mctp_i2c_server",
+    srcs = [
+        "server_common.rs",
+        "server_main.rs",
+    ],
+    codegen_crate_name = "app_mctp_i2c_server",
+    crate_root = "server_main.rs",
+    edition = "2024",
+    system_config = ":system_config",
+    tags = ["kernel"],
+    target_compatible_with = TARGET_COMPATIBLE_WITH,
+    deps = SERVER_DEPS,
+)
+
+rust_app(
+    name = "mctp_i2c_server_peer",
+    srcs = [
+        "server_common.rs",
+        "server_main_peer.rs",
+    ],
+    codegen_crate_name = "app_mctp_i2c_server_peer",
+    crate_root = "server_main_peer.rs",
+    edition = "2024",
+    system_config = ":requester_system_config",
+    tags = ["kernel"],
+    target_compatible_with = TARGET_COMPATIBLE_WITH,
+    deps = SERVER_DEPS,
+)
+
+rust_app(
+    name = "mctp_echo_client",
+    srcs = ["echo_responder_main.rs"],
+    codegen_crate_name = "app_mctp_echo_client",
+    edition = "2024",
+    system_config = ":system_config",
+    tags = ["kernel"],
+    target_compatible_with = TARGET_COMPATIBLE_WITH,
+    deps = [
+        "//services/mctp/api:mctp_api",
+        "//services/mctp/client-ipc:mctp_client_ipc",
+        "//services/mctp/echo:mctp_echo",
+        "@pigweed//pw_kernel/userspace",
+        "@pigweed//pw_log/rust:pw_log",
+    ],
+)
+
+rust_app(
+    name = "mctp_echo_client_peer",
+    srcs = ["throughput_main.rs"],
+    codegen_crate_name = "app_mctp_echo_client_peer",
+    edition = "2024",
+    system_config = ":requester_system_config",
+    tags = ["kernel"],
+    target_compatible_with = TARGET_COMPATIBLE_WITH,
+    deps = [
+        "//services/mctp/api:mctp_api",
+        "//services/mctp/client-ipc:mctp_client_ipc",
+        "//services/mctp/echo:mctp_echo",
+        "@pigweed//pw_kernel/userspace",
+        "@pigweed//pw_log/rust:pw_log",
+        "@pigweed//pw_status/rust:pw_status",
+    ],
+)
+
+system_image(
+    name = "throughput_responder_image",
+    apps = [
+        ":mctp_i2c_server",
+        ":mctp_echo_client",
+    ],
+    kernel = ":target",
+    platform = "//target/ast10x0",
+    system_config = ":system_config",
+    tags = ["kernel"],
+    target_compatible_with = TARGET_COMPATIBLE_WITH,
+    visibility = ["//visibility:public"],
+)
+
+system_image(
+    name = "throughput_requester_image",
+    apps = [
+        ":mctp_i2c_server_peer",
+        ":mctp_echo_client_peer",
+    ],
+    kernel = ":target",
+    platform = "//target/ast10x0",
+    system_config = ":requester_system_config",
+    tags = ["kernel"],
+    target_compatible_with = TARGET_COMPATIBLE_WITH,
+    visibility = ["//visibility:public"],
+)
+
+# `slave_image` is flashed first so the responder is listening before the
+# requester boots. Passes when the requester completes every round trip.
+system_image_test(
+    name = "mctp_throughput_test",
+    timeout = "long",
+    image = ":throughput_requester_image",
+    slave_image = ":throughput_responder_image",
+    tags = [
+        "hardware",
+        "manual",
+    ],
+    target_compatible_with = select({
+        "//target/ast10x0:qemu_enabled": ["@platforms//:incompatible"],
+        "//conditions:default": [],
+    }),
+)
diff --git a/target/ast10x0/tests/mctp/throughput/echo_responder_main.rs b/target/ast10x0/tests/mctp/throughput/echo_responder_main.rs
new file mode 100644
index 0000000..1157a62
--- /dev/null
+++ b/target/ast10x0/tests/mctp/throughput/echo_responder_main.rs
@@ -0,0 +1,50 @@
+// Licensed under the Apache-2.0 license
+// SPDX-License-Identifier: Apache-2.0
+
+//! Echo responder for the MCTP-over-I2C throughput test (card A, EID 8).
+//!
+//! Passive: listens for echo messages and reflects each payload back to the
+//! sender. It never originates traffic, so the requester's measurements are
+//! not perturbed by responder-initiated sends.
+
+#![no_main]
+#![no_std]
+
+use app_mctp_echo_client::handle;
+use openprot_mctp_api::wire::MAX_PAYLOAD_SIZE;
+use openprot_mctp_api::Stack;
+use openprot_mctp_client_ipc::IpcMctpClient;
+use openprot_mctp_echo::{echo_once, prepare_listener_with_eid_and_timeout};
+use userspace::{entry, syscall};
+
+const ECHO_EID: u8 = 8;
+// 0 = block until a request arrives.
+const LISTEN_TIMEOUT_MS: u32 = 0;
+
+#[entry]
+fn entry() {
+    let stack = Stack::new(IpcMctpClient::new(handle::MCTP));
+    let mut listener =
+        match prepare_listener_with_eid_and_timeout(&stack, ECHO_EID, LISTEN_TIMEOUT_MS) {
+            Ok(listener) => listener,
+            Err(e) => {
+                pw_log::error!("echo responder setup failed: code={}", e.code as u32);
+                syscall::process_exit(1);
+            }
+        };
+
+    pw_log::info!("echo responder ready, EID {}", ECHO_EID as u32);
+    let mut buf = [0u8; MAX_PAYLOAD_SIZE];
+    loop {
+        if let Err(e) = echo_once(&mut listener, &mut buf) {
+            if !e.is_timeout() {
+                pw_log::error!("echo responder failed: code={}", e.code as u32);
+            }
+        }
+    }
+}
+
+#[panic_handler]
+fn panic(_info: &core::panic::PanicInfo) -> ! {
+    loop {}
+}
diff --git a/target/ast10x0/tests/mctp/throughput/requester_system.json5 b/target/ast10x0/tests/mctp/throughput/requester_system.json5
new file mode 100644
index 0000000..10c8b84
--- /dev/null
+++ b/target/ast10x0/tests/mctp/throughput/requester_system.json5
@@ -0,0 +1,88 @@
+// Licensed under the Apache-2.0 license
+// SPDX-License-Identifier: Apache-2.0
+
+// AST10x0 MCTP throughput test (requester, card B)
+//
+// The I2C driver and MCTP server share one process (mctp_i2c_server_peer); the
+// echo app talks to it over a single MCTP IPC channel.
+{
+    arch: {
+        type: "armv7m",
+        vector_table_start_address: 0x00000000,
+        vector_table_size_bytes: 1792,
+    },
+    kernel: {
+        flash_start_address: 0x00000700,
+        flash_size_bytes: 129280,
+        ram_start_address: 0x00060000,
+        ram_size_bytes: 131072,
+    },
+    apps: [
+        {
+            name: "mctp_i2c_server_peer",
+            flash_size_bytes: 131072,
+            processes: [
+                {
+                    name: "mctp_i2c_server_peer_process",
+                    ram_size_bytes: 131072,
+                    objects: [
+                        {
+                            name: "wg",
+                            type: "wait_group",
+                        },
+                        {
+                            name: "mctp",
+                            type: "channel_handler",
+                        },
+                        {
+                            name: "i2c2_irq",
+                            type: "interrupt",
+                            irqs: [
+                                {
+                                    name: "i2c2",
+                                    number: 112,
+                                },
+                            ],
+                        },
+                        {
+                            type: "thread",
+                            name: "mctp_i2c_server_peer_thread",
+                            kernel_stack_size_bytes: 4096,
+                        },
+                    ],
+                    memory_mappings: [
+                        {
+                            name: "i2c_regs",
+                            type: "device",
+                            start_address: 0x7e7b0000,
+                            size_bytes: 0x4000,
+                        },
+                    ],
+                },
+            ],
+        },
+        {
+            name: "mctp_echo_client_peer",
+            flash_size_bytes: 16384,
+            processes: [
+                {
+                    name: "mctp_echo_client_peer_process",
+                    ram_size_bytes: 32768,
+                    objects: [
+                        {
+                            name: "mctp",
+                            type: "channel_initiator",
+                            handler_process: "mctp_i2c_server_peer_process",
+                            handler_object_name: "mctp",
+                        },
+                        {
+                            type: "thread",
+                            name: "mctp_echo_client_peer_thread",
+                            kernel_stack_size_bytes: 2048,
+                        },
+                    ],
+                },
+            ],
+        },
+    ],
+}
diff --git a/target/ast10x0/tests/mctp/throughput/server_common.rs b/target/ast10x0/tests/mctp/throughput/server_common.rs
new file mode 100644
index 0000000..6e6a1d3
--- /dev/null
+++ b/target/ast10x0/tests/mctp/throughput/server_common.rs
@@ -0,0 +1,259 @@
+// Licensed under the Apache-2.0 license
+// SPDX-License-Identifier: Apache-2.0
+
+// Shared body of the combined I2C + MCTP server, `include!`d by `server_main.rs`
+// and `server_main_peer.rs`; each defines `handle`, `signals`, the EIDs and the
+// I2C addresses before including it.
+//
+// The I2C driver for Bus 2 lives in this process, so the MCTP stack drives it
+// directly (no I2C IPC hop): outbound packets go through `SharedBus`, inbound
+// packets are drained from the slave RX buffer on the I2C IRQ.
+
+use core::cell::RefCell;
+
+use ast10x0_peripherals::create_pins;
+use ast10x0_peripherals::i2c::{ClockConfig, I2cConfig, I2cSpeed, I2cXferMode};
+use embedded_hal::i2c::{ErrorType, I2c, Operation, SevenBitAddress};
+use i2c_api::seam::{I2cIsrEvent, I2cSlaveBuffer, I2cSlaveCore, I2cSlaveEvent};
+use openprot_mctp_api::wire::{
+    self, MctpOp, MctpRequestHeader, MctpResponseHeader, MAX_PAYLOAD_SIZE, MAX_REQUEST_SIZE,
+    MAX_RESPONSE_SIZE,
+};
+use openprot_mctp_api::{Handle, ResponseCode};
+use openprot_mctp_server::dispatch::{self, DispatchOutcome};
+use openprot_mctp_transport_i2c::{I2cSender, MctpI2cReceiver};
+use pw_status::{Error, Result};
+use userspace::entry;
+use userspace::syscall::{self, Signals};
+use userspace::time::{Clock, Duration, Instant, SystemClock};
+
+const SLAVE_CFG: I2cConfig = I2cConfig {
+    speed: I2cSpeed::Standard,
+    xfer_mode: I2cXferMode::DmaMode,
+    multi_master: false,
+    smbus_timeout: false,
+    smbus_alert: false,
+    clock_config: ClockConfig::ast1060_default(),
+};
+
+/// Lets the MCTP sender (master writes) and the IRQ handler (slave reads)
+/// share the one bus driver. Single-threaded, so `RefCell` never contends.
+struct SharedBus<'a, B>(&'a RefCell<B>);
+
+impl<B: ErrorType> ErrorType for SharedBus<'_, B> {
+    type Error = B::Error;
+}
+
+impl<B: I2c<SevenBitAddress>> I2c<SevenBitAddress> for SharedBus<'_, B> {
+    fn transaction(
+        &mut self,
+        address: SevenBitAddress,
+        operations: &mut [Operation<'_>],
+    ) -> core::result::Result<(), Self::Error> {
+        self.0.borrow_mut().transaction(address, operations)
+    }
+}
+
+fn respond_error(code: ResponseCode, response_buf: &mut [u8]) -> Result<()> {
+    response_buf[..MctpResponseHeader::SIZE]
+        .copy_from_slice(&MctpResponseHeader::error(code).to_bytes());
+    syscall::channel_respond(handle::MCTP, &response_buf[..MctpResponseHeader::SIZE])
+}
+
+fn respond_recv(
+    meta: &openprot_mctp_api::RecvMetadata,
+    recv_buf: &[u8],
+    response_buf: &mut [u8],
+) -> Result<()> {
+    let len = wire::encode_recv_response(
+        response_buf,
+        meta.msg_type,
+        meta.msg_ic,
+        meta.remote_eid,
+        meta.msg_tag,
+        &recv_buf[..meta.payload_size],
+    )
+    .unwrap_or_else(|_| {
+        wire::encode_error_response(response_buf, ResponseCode::InternalError).unwrap_or(0)
+    });
+    syscall::channel_respond(handle::MCTP, &response_buf[..len])
+}
+
+fn server_loop() -> Result<()> {
+    pw_log::info!("MCTP+I2C server starting");
+
+    // The kernel routed Bus 2's pins at the SCU before starting us; userspace has no SCU grant, so
+    // we bind the already-routed pins rather than re-muxing them.
+    // SAFETY: sole pin creation site in this binary, at boot; the pins! table is this chip's true pin map.
+    let pins = unsafe { create_pins() };
+    let (scl, sda) = (pins.scu418_0, pins.scu418_1);
+    let (Some(master_dma_buf), Some(slave_dma_buf)) = (
+        i2c_backend::non_cached_buf!(4096),
+        i2c_backend::non_cached_buf!(512),
+    ) else {
+        pw_log::error!("i2c DMA buffers already taken");
+        return Err(Error::Internal);
+    };
+    let Ok(mut driver) =
+        i2c_backend::open_bus_dma(scl, sda, &SLAVE_CFG, master_dma_buf, slave_dma_buf)
+    else {
+        pw_log::error!("i2c bus open failed");
+        return Err(Error::Internal);
+    };
+    if driver.configure_slave_address(OWN_I2C_ADDR).is_err() || driver.enable_slave_mode().is_err()
+    {
+        pw_log::error!("i2c slave setup failed");
+        return Err(Error::Internal);
+    }
+    let bus = RefCell::new(driver);
+
+    let sender = I2cSender::new(SharedBus(&bus), OWN_I2C_ADDR, REMOTE_I2C_ADDR);
+    let i2c_receiver = MctpI2cReceiver::new(OWN_I2C_ADDR);
+    let mut server = openprot_mctp_server::Server::<_, 16>::new(mctp::Eid(OWN_EID), 0, sender);
+
+    let mut request_buf = [0u8; MAX_REQUEST_SIZE];
+    let mut response_buf = [0u8; MAX_RESPONSE_SIZE];
+    let mut recv_buf = [0u8; MAX_PAYLOAD_SIZE];
+    let mut i2c_rx_buf = [0u8; MAX_PAYLOAD_SIZE];
+
+    // A blocking recv whose IPC reply is deferred until a packet arrives or
+    // its deadline passes.
+    struct PendingRecv {
+        handle: Handle,
+        deadline: Instant,
+    }
+    let mut pending_recv: Option<PendingRecv> = None;
+
+    // user_data=0 → MCTP client channel READABLE, user_data=1 → I2C IRQ.
+    syscall::wait_group_add(handle::WG, handle::MCTP, Signals::READABLE, 0usize)?;
+    syscall::wait_group_add(handle::WG, handle::I2C2_IRQ, signals::I2C2, 1usize)?;
+
+    loop {
+        let wait_deadline = pending_recv
+            .as_ref()
+            .map_or(Instant::MAX, |pending| pending.deadline);
+        let ev = match syscall::object_wait(
+            handle::WG,
+            Signals::READABLE | signals::I2C2,
+            wait_deadline,
+        ) {
+            Ok(ev) => ev,
+            Err(Error::DeadlineExceeded) => {
+                if pending_recv.take().is_some() {
+                    let _ = respond_error(ResponseCode::TimedOut, &mut response_buf);
+                    let _ = syscall::wait_group_add(
+                        handle::WG,
+                        handle::MCTP,
+                        Signals::READABLE,
+                        0usize,
+                    );
+                }
+                continue;
+            }
+            Err(err) => return Err(err),
+        };
+
+        if ev.user_data == 1 {
+            // Slave IRQ: drain a received packet, ack, feed the router.
+            let event = bus.borrow_mut().try_next_slave_event();
+            if let Ok(Some((I2cIsrEvent::SlaveWrRecvd, _))) = event {
+                match bus.borrow_mut().read_slave_buffer(&mut i2c_rx_buf) {
+                    Ok(n) if n > 0 => match i2c_receiver.decode(&i2c_rx_buf[..n]) {
+                        Ok((pkt, _)) => {
+                            let _ = server.inbound(pkt);
+                        }
+                        Err(_) => pw_log::error!("i2c frame decode failed"),
+                    },
+                    Ok(_) => {}
+                    Err(_) => pw_log::error!("read_slave_buffer failed"),
+                }
+            }
+            if syscall::interrupt_ack(handle::I2C2_IRQ, ev.pending_signals & signals::I2C2)
+                .is_err()
+            {
+                pw_log::error!("interrupt_ack failed");
+            }
+            // Satisfy any deferred blocking recv now that a packet was processed.
+            if let Some(pending) = pending_recv.as_ref() {
+                if let Some(meta) = server.try_recv(pending.handle, &mut recv_buf) {
+                    respond_recv(&meta, &recv_buf, &mut response_buf)?;
+                    pending_recv = None;
+                    syscall::wait_group_add(handle::WG, handle::MCTP, Signals::READABLE, 0usize)?;
+                }
+            }
+            continue;
+        }
+
+        // IPC from a client — non-blocking, the WaitGroup fired on READABLE.
+        let len = syscall::channel_read(handle::MCTP, 0, &mut request_buf)?;
+        if pending_recv.is_some() {
+            // A blocking recv is in flight; reject so READABLE clears.
+            respond_error(ResponseCode::InternalError, &mut response_buf)?;
+            continue;
+        }
+        if len < MctpRequestHeader::SIZE {
+            respond_error(ResponseCode::BadArgument, &mut response_buf)?;
+            continue;
+        }
+
+        let header = MctpRequestHeader::from_bytes(&request_buf[..len]);
+        if header
+            .as_ref()
+            .and_then(|h| h.operation())
+            .is_some_and(|op| matches!(op, MctpOp::Recv))
+        {
+            let recv_handle = Handle(header.map_or(0, |h| h.handle));
+            let payload = wire::get_request_payload(&request_buf[..len]);
+            if payload.len() < 4 {
+                respond_error(ResponseCode::BadArgument, &mut response_buf)?;
+                continue;
+            }
+            let timeout_millis = u32::from_le_bytes([payload[0], payload[1], payload[2], payload[3]]);
+            match server.try_recv(recv_handle, &mut recv_buf) {
+                Some(meta) => respond_recv(&meta, &recv_buf, &mut response_buf)?,
+                None => {
+                    let deadline = if timeout_millis == 0 {
+                        Instant::MAX
+                    } else {
+                        SystemClock::now()
+                            .checked_add_duration(Duration::from_millis(timeout_millis as u64))
+                            .unwrap_or(Instant::MAX)
+                    };
+                    pending_recv = Some(PendingRecv {
+                        handle: recv_handle,
+                        deadline,
+                    });
+                    // Remove MCTP from the WaitGroup so READABLE on the open
+                    // transaction can't re-fire it.
+                    let _ = syscall::wait_group_remove(handle::WG, handle::MCTP);
+                }
+            }
+        } else {
+            let response_len = match dispatch::dispatch_mctp_op(
+                &request_buf[..len],
+                &mut response_buf,
+                &mut server,
+                &mut recv_buf,
+                0,
+            ) {
+                DispatchOutcome::Reply(n) => n,
+                DispatchOutcome::Pending { .. } => unreachable!("Recv handled above"),
+            };
+            syscall::channel_respond(handle::MCTP, &response_buf[..response_len])?;
+        }
+    }
+}
+
+#[entry]
+fn entry() {
+    if let Err(e) = server_loop() {
+        pw_log::error!("mctp_i2c_server exiting with error");
+        let _ = syscall::process_exit(e as u32);
+    }
+    loop {}
+}
+
+#[panic_handler]
+fn panic(_info: &core::panic::PanicInfo) -> ! {
+    loop {}
+}
diff --git a/target/ast10x0/tests/mctp/throughput/server_main.rs b/target/ast10x0/tests/mctp/throughput/server_main.rs
new file mode 100644
index 0000000..53439da
--- /dev/null
+++ b/target/ast10x0/tests/mctp/throughput/server_main.rs
@@ -0,0 +1,15 @@
+// Licensed under the Apache-2.0 license
+// SPDX-License-Identifier: Apache-2.0
+
+//! Combined I2C + MCTP server for the throughput test (responder, card A).
+
+#![no_main]
+#![no_std]
+
+use app_mctp_i2c_server::{handle, signals};
+
+const OWN_EID: u8 = 8;
+const OWN_I2C_ADDR: u8 = 0x10;
+const REMOTE_I2C_ADDR: u8 = 0x42;
+
+include!("server_common.rs");
diff --git a/target/ast10x0/tests/mctp/throughput/server_main_peer.rs b/target/ast10x0/tests/mctp/throughput/server_main_peer.rs
new file mode 100644
index 0000000..c54afbc
--- /dev/null
+++ b/target/ast10x0/tests/mctp/throughput/server_main_peer.rs
@@ -0,0 +1,15 @@
+// Licensed under the Apache-2.0 license
+// SPDX-License-Identifier: Apache-2.0
+
+//! Combined I2C + MCTP server for the throughput test (requester, card B).
+
+#![no_main]
+#![no_std]
+
+use app_mctp_i2c_server_peer::{handle, signals};
+
+const OWN_EID: u8 = 9;
+const OWN_I2C_ADDR: u8 = 0x42;
+const REMOTE_I2C_ADDR: u8 = 0x10;
+
+include!("server_common.rs");
diff --git a/target/ast10x0/tests/mctp/throughput/system.json5 b/target/ast10x0/tests/mctp/throughput/system.json5
new file mode 100644
index 0000000..e7937e9
--- /dev/null
+++ b/target/ast10x0/tests/mctp/throughput/system.json5
@@ -0,0 +1,88 @@
+// Licensed under the Apache-2.0 license
+// SPDX-License-Identifier: Apache-2.0
+
+// AST10x0 MCTP throughput test (responder, card A)
+//
+// The I2C driver and MCTP server share one process (mctp_i2c_server); the
+// echo app talks to it over a single MCTP IPC channel.
+{
+    arch: {
+        type: "armv7m",
+        vector_table_start_address: 0x00000000,
+        vector_table_size_bytes: 1792,
+    },
+    kernel: {
+        flash_start_address: 0x00000700,
+        flash_size_bytes: 129280,
+        ram_start_address: 0x00060000,
+        ram_size_bytes: 131072,
+    },
+    apps: [
+        {
+            name: "mctp_i2c_server",
+            flash_size_bytes: 131072,
+            processes: [
+                {
+                    name: "mctp_i2c_server_process",
+                    ram_size_bytes: 131072,
+                    objects: [
+                        {
+                            name: "wg",
+                            type: "wait_group",
+                        },
+                        {
+                            name: "mctp",
+                            type: "channel_handler",
+                        },
+                        {
+                            name: "i2c2_irq",
+                            type: "interrupt",
+                            irqs: [
+                                {
+                                    name: "i2c2",
+                                    number: 112,
+                                },
+                            ],
+                        },
+                        {
+                            type: "thread",
+                            name: "mctp_i2c_server_thread",
+                            kernel_stack_size_bytes: 4096,
+                        },
+                    ],
+                    memory_mappings: [
+                        {
+                            name: "i2c_regs",
+                            type: "device",
+                            start_address: 0x7e7b0000,
+                            size_bytes: 0x4000,
+                        },
+                    ],
+                },
+            ],
+        },
+        {
+            name: "mctp_echo_client",
+            flash_size_bytes: 16384,
+            processes: [
+                {
+                    name: "mctp_echo_client_process",
+                    ram_size_bytes: 32768,
+                    objects: [
+                        {
+                            name: "mctp",
+                            type: "channel_initiator",
+                            handler_process: "mctp_i2c_server_process",
+                            handler_object_name: "mctp",
+                        },
+                        {
+                            type: "thread",
+                            name: "mctp_echo_client_thread",
+                            kernel_stack_size_bytes: 2048,
+                        },
+                    ],
+                },
+            ],
+        },
+    ],
+}
diff --git a/target/ast10x0/tests/mctp/throughput/target.rs b/target/ast10x0/tests/mctp/throughput/target.rs
new file mode 100644
index 0000000..c543aff
--- /dev/null
+++ b/target/ast10x0/tests/mctp/throughput/target.rs
@@ -0,0 +1,49 @@
+// Licensed under the Apache-2.0 license
+// SPDX-License-Identifier: Apache-2.0
+
+#![no_std]
+#![no_main]
+
+use ast10x0_board::{Ast10x0Board, Ast10x0BoardDescriptor};
+use ast10x0_peripherals::scu::pinctrl;
+use console_backend::console_backend_write_all;
+use entry as _;
+use target_common::{declare_target, TargetInterface};
+
+pub struct Target;
+
+static PINCTRL_GROUPS: [&[ast10x0_peripherals::scu::PinctrlPin]; 1] = [pinctrl::PINCTRL_I2C2];
+
+impl TargetInterface for Target {
+    const NAME: &'static str = "AST10x0 MCTP Throughput";
+
+    fn main() -> ! {
+        // SAFETY: kernel main() runs once with exclusive hardware ownership.
+        if unsafe {
+            Ast10x0Board::new(Ast10x0BoardDescriptor {
+                pinctrl_groups: &PINCTRL_GROUPS,
+            })
+            .init()
+        }
+        .is_err()
+        {
+            loop {}
+        }
+
+        codegen::start();
+        loop {}
+    }
+
+    fn shutdown(code: u32) -> ! {
+        let sentinel: &[u8] = if code == 0 {
+            b"TEST_RESULT:PASS\n"
+        } else {
+            b"TEST_RESULT:FAIL\n"
+        };
+        let _ = console_backend_write_all(sentinel);
+        #[expect(clippy::empty_loop)]
+        loop {}
+    }
+}
+
+declare_target!(Target);
diff --git a/target/ast10x0/tests/mctp/throughput/throughput_main.rs b/target/ast10x0/tests/mctp/throughput/throughput_main.rs
new file mode 100644
index 0000000..447cd74
--- /dev/null
+++ b/target/ast10x0/tests/mctp/throughput/throughput_main.rs
@@ -0,0 +1,107 @@
+// Licensed under the Apache-2.0 license
+// SPDX-License-Identifier: Apache-2.0
+
+//! MCTP-over-I2C throughput requester (card B, EID 9).
+//!
+//! Sends `ITERS` echo requests per payload size to the responder at EID 8,
+//! verifies each echoed payload, and logs round-trip rate and goodput. Ends
+//! the test with PASS only if every round trip completed with matching data.
+//!
+//! Goodput counts payload bytes in both directions (request + echo).
+
+#![no_main]
+#![no_std]
+
+use app_mctp_echo_client_peer::handle;
+use openprot_mctp_api::wire::MAX_PAYLOAD_SIZE;
+use openprot_mctp_api::{MctpReqChannel, Stack};
+use openprot_mctp_client_ipc::IpcMctpClient;
+use openprot_mctp_echo::ECHO_MSG_TYPE;
+use pw_status::Error;
+use userspace::time::{Clock, SystemClock};
+use userspace::{entry, syscall};
+
+const OWN_EID: u8 = 9;
+const RESPONDER_EID: u8 = 8;
+const REQ_TIMEOUT_MS: u32 = 1000;
+const WARMUP_TIMEOUT_MS: u32 = 10_000;
+const ITERS: u32 = 100;
+const PAYLOAD_SIZES: [usize; 6] = [1, 32, 64, 128, 255, 1023];
+
+fn now_micros() -> u64 {
+    SystemClock::now().ticks() * 1_000_000 / SystemClock::TICKS_PER_SEC
+}
+
+/// One echo round trip; `true` if the response matched the request.
+fn round_trip(stack: &Stack<IpcMctpClient>, tx: &[u8], rx: &mut [u8], timeout: u32) -> bool {
+    let Ok(mut req) = stack.req(RESPONDER_EID, timeout) else {
+        return false;
+    };
+    if req.send(ECHO_MSG_TYPE, tx).is_err() {
+        return false;
+    }
+    matches!(req.recv(rx), Ok((_, echoed)) if echoed == tx)
+}
+
+fn fail() -> ! {
+    let _ = syscall::debug_shutdown(Err(Error::Internal));
+    loop {}
+}
+
+#[entry]
+fn entry() {
+    let stack = Stack::new(IpcMctpClient::new(handle::MCTP));
+    if let Err(e) = stack.set_eid(OWN_EID) {
+        pw_log::error!("set_eid failed: code={}", e.code as u32);
+        fail();
+    }
+
+    let mut tx = [0u8; MAX_PAYLOAD_SIZE];
+    let mut rx = [0u8; MAX_PAYLOAD_SIZE];
+    for (i, b) in tx.iter_mut().enumerate() {
+        *b = i as u8;
+    }
+
+    // The responder may boot after us; retry until the first echo succeeds.
+    pw_log::info!("waiting for responder at EID {}", RESPONDER_EID as u32);
+    while !round_trip(&stack, &tx[..1], &mut rx, WARMUP_TIMEOUT_MS) {}
+
+    let mut total_errors = 0u32;
+    for &size in PAYLOAD_SIZES.iter() {
+        let mut errors = 0u32;
+        let start = now_micros();
+        for _ in 0..ITERS {
+            if !round_trip(&stack, &tx[..size], &mut rx, REQ_TIMEOUT_MS) {
+                errors += 1;
+            }
+        }
+        let elapsed_us = now_micros().saturating_sub(start).max(1);
+        let ok = (ITERS - errors) as u64;
+        let rt_per_sec_x100 = ok * 100_000_000 / elapsed_us;
+        let bytes_per_sec = ok * 2 * size as u64 * 1_000_000 / elapsed_us;
+        pw_log::info!(
+            "THROUGHPUT size={} ok={} err={} elapsed_us={} rt_per_sec_x100={} bytes_per_sec={}",
+            size as u32,
+            ok as u32,
+            errors as u32,
+            elapsed_us as u32,
+            rt_per_sec_x100 as u32,
+            bytes_per_sec as u32
+        );
+        total_errors += errors;
+    }
+
+    if total_errors == 0 {
+        pw_log::info!("throughput test complete");
+        let _ = syscall::debug_shutdown(Ok(()));
+    } else {
+        pw_log::error!("throughput test: {} failed round trips", total_errors as u32);
+        fail();
+    }
+    loop {}
+}
+
+#[panic_handler]
+fn panic(_info: &core::panic::PanicInfo) -> ! {
+    loop {}
+}