pldm: use run_until to remove timeout-as-success ambiguity in host tests

base_host, inventory_host, unexpected_eid_host, and unexpected_eid_fw_host
all queue one command, run the FD, and check the response, then relied on
run_terminus timing out to signal "done" -- indistinguishable from a real
hang. Add a shared run_fd_step helper (common.rs) that uses run_until to
bound each of these to exactly one step, so every error they can now hit
is real.

firmware_update_host's own multi-step flow (an unbounded number of
RequestFirmwareData/TransferComplete/VerifyComplete/ApplyComplete round
trips per call) doesn't fit a fixed step count and keeps using
run_terminus directly; it already gained a direct run_until test in the
prior commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
diff --git a/services/pldm/tests/base_host.rs b/services/pldm/tests/base_host.rs
index 29e1fc9..fef3347 100644
--- a/services/pldm/tests/base_host.rs
+++ b/services/pldm/tests/base_host.rs
@@ -9,8 +9,8 @@
 
 use mctp::Eid;
 use openprot_mctp_server::Server;
-use openprot_pldm_service::firmware_device::{FirmwareDevice, RunTerminusResult};
-use openprot_pldm_service::{MctpPldmTransport, PldmServiceError};
+use openprot_pldm_service::firmware_device::FirmwareDevice;
+use openprot_pldm_service::MctpPldmTransport;
 use pldm_common::codec::PldmCodec;
 use pldm_common::message::control::{GetPldmVersionRequest, GetTidRequest, SetTidRequest};
 use pldm_common::message::firmware_update::apply_complete::ApplyResult;
@@ -24,7 +24,7 @@
 use pldm_interface::firmware_device::fd_ops::{ComponentOperation, FdOps, FdOpsError};
 
 mod common;
-use common::{transfer, BufferSender, DirectClientWithPump, FD_EID, TIMEOUT_MILLIS, UA_EID};
+use common::{run_fd_step, transfer, BufferSender, DirectClientWithPump, FD_EID, UA_EID};
 
 struct MockFdOps {
     component_accepted: Cell<bool>,
@@ -202,16 +202,10 @@
         )
         .expect("send request_update payload");
 
-    // Runs `FirmwareDevice::run_terminus` until its inbound queue is drained.
-    // `run_terminus` loops until its responder listener has nothing left, at
-    // which point it returns Mctp(TimedOut); that terminating timeout means
-    // "done", not a failure.
-    let mut run_fd_once =
-        || match fd.run_terminus(UA_EID, &mut fd_buf, TIMEOUT_MILLIS, TIMEOUT_MILLIS, &mut ()) {
-            RunTerminusResult::Completed => {}
-            RunTerminusResult::StoppedByError(PldmServiceError::Mctp(e)) if e.is_timeout() => {}
-            RunTerminusResult::StoppedByError(e) => panic!("firmware device failed: {e:?}"),
-        };
+    // Each command below is answered within a single step: the responder
+    // transport's pre-recv pump delivers the queued UA->FD packet before
+    // `run_fd_step` even attempts to receive it.
+    let mut run_fd_once = || run_fd_step(&mut fd, UA_EID, &mut fd_buf, &mut ());
 
     // The responder transport's pre-recv pump delivers the queued UA->FD
     // packets into fd_server *after* its listener is registered. Delivering
diff --git a/services/pldm/tests/common.rs b/services/pldm/tests/common.rs
index 3311668..b52e915 100644
--- a/services/pldm/tests/common.rs
+++ b/services/pldm/tests/common.rs
@@ -10,6 +10,8 @@
 use mctp_lib::Sender;
 use openprot_mctp_api::{Handle, MctpClient, MctpError, RecvMetadata, ResponseCode};
 use openprot_mctp_server::Server;
+use openprot_pldm_service::firmware_device::{FdEventSink, FirmwareDevice, RunTerminusResult};
+use pldm_interface::firmware_device::fd_ops::FdOps;
 
 pub const FD_EID: u8 = 42;
 pub const UA_EID: u8 = 8;
@@ -57,6 +59,47 @@
     }
 }
 
+/// Drives `fd` for exactly one step and panics on any error.
+///
+/// These host tests queue one command, run the FD, and check the response —
+/// never more than one step's worth of work, and never while the FD is in
+/// initiator mode. `run_terminus` used to be driven until it timed out with
+/// nothing left to receive, treating that timeout as "done"; the trouble is
+/// a genuine timeout (something actually hanging) looks identical, so a real
+/// bug there would have passed silently. Bounding the run to one step with
+/// `run_until` removes the ambiguity: every error from here is real.
+// `common.rs` is compiled once per test binary (via `mod common;`), and not
+// every binary uses every helper here — `firmware_update_host.rs`'s
+// multi-step flow needs `run_terminus` directly instead.
+#[allow(dead_code)]
+pub fn run_fd_step<O, Cr, Cq>(
+    fd: &mut FirmwareDevice<'_, O, Cr, Cq>,
+    remote_eid: u8,
+    buf: &mut [u8],
+    sink: &mut impl FdEventSink,
+) where
+    O: FdOps,
+    Cr: MctpClient,
+    Cq: MctpClient,
+{
+    let mut done = false;
+    match fd.run_until(
+        remote_eid,
+        buf,
+        TIMEOUT_MILLIS,
+        TIMEOUT_MILLIS,
+        sink,
+        || {
+            let go = !done;
+            done = true;
+            go
+        },
+    ) {
+        RunTerminusResult::Completed => {}
+        RunTerminusResult::StoppedByError(e) => panic!("firmware device failed: {e:?}"),
+    }
+}
+
 pub struct DirectClientWithPump<'a, S: Sender, const N: usize, F: FnMut()> {
     pub server: &'a RefCell<Server<S, N>>,
     pub pre_recv_pump: RefCell<F>,
diff --git a/services/pldm/tests/inventory_host.rs b/services/pldm/tests/inventory_host.rs
index 0e35333..42ad067 100644
--- a/services/pldm/tests/inventory_host.rs
+++ b/services/pldm/tests/inventory_host.rs
@@ -10,8 +10,8 @@
 
 use mctp::Eid;
 use openprot_mctp_server::Server;
-use openprot_pldm_service::firmware_device::{FirmwareDevice, RunTerminusResult};
-use openprot_pldm_service::{MctpPldmTransport, PldmServiceError};
+use openprot_pldm_service::firmware_device::FirmwareDevice;
+use openprot_pldm_service::MctpPldmTransport;
 use pldm_common::codec::PldmCodec;
 use pldm_common::message::firmware_update::apply_complete::ApplyResult;
 use pldm_common::message::firmware_update::get_fw_params::{
@@ -33,7 +33,7 @@
 use pldm_interface::firmware_device::fd_ops::{ComponentOperation, FdOps, FdOpsError};
 
 mod common;
-use common::{transfer, BufferSender, DirectClientWithPump, FD_EID, TIMEOUT_MILLIS, UA_EID};
+use common::{run_fd_step, transfer, BufferSender, DirectClientWithPump, FD_EID, UA_EID};
 
 struct InventoryFdOps {
     descriptors: [Descriptor; 2],
@@ -222,14 +222,7 @@
             .send(Some(handle), 0x01, None, None, false, request)
             .expect("send inventory request");
 
-        match fd.run_terminus(UA_EID, &mut fd_buf, TIMEOUT_MILLIS, TIMEOUT_MILLIS, &mut ()) {
-            RunTerminusResult::Completed => {}
-            RunTerminusResult::StoppedByError(PldmServiceError::Mctp(error))
-                if error.is_timeout() => {}
-            RunTerminusResult::StoppedByError(error) => {
-                panic!("firmware device failed: {error:?}")
-            }
-        }
+        run_fd_step(&mut fd, UA_EID, &mut fd_buf, &mut ());
 
         transfer(&fd_to_ua_packets, &mut ua_server.borrow_mut());
         fd_to_ua_packets.borrow_mut().clear();
diff --git a/services/pldm/tests/unexpected_eid_fw_host.rs b/services/pldm/tests/unexpected_eid_fw_host.rs
index 57b81c9..0341572 100644
--- a/services/pldm/tests/unexpected_eid_fw_host.rs
+++ b/services/pldm/tests/unexpected_eid_fw_host.rs
@@ -9,8 +9,8 @@
 
 use mctp::Eid;
 use openprot_mctp_server::Server;
-use openprot_pldm_service::firmware_device::{FirmwareDevice, RunTerminusResult};
-use openprot_pldm_service::{MctpPldmTransport, PldmServiceError};
+use openprot_pldm_service::firmware_device::FirmwareDevice;
+use openprot_pldm_service::MctpPldmTransport;
 use pldm_common::codec::PldmCodec;
 use pldm_common::message::firmware_update::apply_complete::ApplyResult;
 use pldm_common::message::firmware_update::get_fw_params::FirmwareParameters;
@@ -31,7 +31,7 @@
 use pldm_interface::firmware_device::fd_ops::{ComponentOperation, FdOps, FdOpsError};
 
 mod common;
-use common::{transfer, BufferSender, DirectClientWithPump, FD_EID, TIMEOUT_MILLIS, UA_EID};
+use common::{run_fd_step, transfer, BufferSender, DirectClientWithPump, FD_EID, UA_EID};
 
 const PLDM_MSG_TYPE: u8 = 0x01;
 /// Total firmware image size (bytes) advertised in `UpdateComponent`.
@@ -222,17 +222,10 @@
     );
     let mut fd_buf = [0u8; 1024];
 
-    // Runs `FirmwareDevice::run_terminus` until its inbound queue is drained.
-    // `run_terminus` loops until its responder listener has nothing left, at
-    // which point it returns Mctp(TimedOut); that terminating timeout means
-    // "done", not a failure. `UA_EID` is the only EID `run_terminus` is told
-    // to serve, so commands from `ATTACKER_EID` must be ignored below.
-    let mut run_fd_once =
-        || match fd.run_terminus(UA_EID, &mut fd_buf, TIMEOUT_MILLIS, TIMEOUT_MILLIS, &mut ()) {
-            RunTerminusResult::Completed => {}
-            RunTerminusResult::StoppedByError(PldmServiceError::Mctp(e)) if e.is_timeout() => {}
-            RunTerminusResult::StoppedByError(e) => panic!("firmware device failed: {e:?}"),
-        };
+    // Each command below is answered within a single step. `UA_EID` is the
+    // only EID `run_fd_step` is told to serve, so commands from
+    // `ATTACKER_EID` must be ignored below.
+    let mut run_fd_once = || run_fd_step(&mut fd, UA_EID, &mut fd_buf, &mut ());
 
     let mut buf = [0u8; 1024];
     // ---- Attacker (EID 99) sends RequestUpdate; the FD must ignore it and FD states must not move----
diff --git a/services/pldm/tests/unexpected_eid_host.rs b/services/pldm/tests/unexpected_eid_host.rs
index c074f25..96179b0 100644
--- a/services/pldm/tests/unexpected_eid_host.rs
+++ b/services/pldm/tests/unexpected_eid_host.rs
@@ -9,8 +9,8 @@
 
 use mctp::Eid;
 use openprot_mctp_server::Server;
-use openprot_pldm_service::firmware_device::{FirmwareDevice, RunTerminusResult};
-use openprot_pldm_service::{MctpPldmTransport, PldmServiceError};
+use openprot_pldm_service::firmware_device::FirmwareDevice;
+use openprot_pldm_service::MctpPldmTransport;
 use pldm_common::codec::PldmCodec;
 use pldm_common::message::control::{GetTidRequest, SetTidRequest};
 use pldm_common::message::firmware_update::apply_complete::ApplyResult;
@@ -24,7 +24,7 @@
 use pldm_interface::firmware_device::fd_ops::{ComponentOperation, FdOps, FdOpsError};
 
 mod common;
-use common::{transfer, BufferSender, DirectClientWithPump, FD_EID, TIMEOUT_MILLIS, UA_EID};
+use common::{run_fd_step, transfer, BufferSender, DirectClientWithPump, FD_EID, UA_EID};
 
 struct MockFdOps {
     component_accepted: Cell<bool>,
@@ -196,17 +196,10 @@
     );
     let mut fd_buf = [0u8; 1024];
 
-    // Runs `FirmwareDevice::run_terminus` until its inbound queue is drained.
-    // `run_terminus` loops until its responder listener has nothing left, at
-    // which point it returns Mctp(TimedOut); that terminating timeout means
-    // "done", not a failure. `UA_EID` is the only EID `run_terminus` is told
-    // to serve, so commands from `ATTACKER_EID` must be ignored below.
-    let mut run_fd_once =
-        || match fd.run_terminus(UA_EID, &mut fd_buf, TIMEOUT_MILLIS, TIMEOUT_MILLIS, &mut ()) {
-            RunTerminusResult::Completed => {}
-            RunTerminusResult::StoppedByError(PldmServiceError::Mctp(e)) if e.is_timeout() => {}
-            RunTerminusResult::StoppedByError(e) => panic!("firmware device failed: {e:?}"),
-        };
+    // Each command below is answered within a single step. `UA_EID` is the
+    // only EID `run_fd_step` is told to serve, so commands from
+    // `ATTACKER_EID` must be ignored below.
+    let mut run_fd_once = || run_fd_step(&mut fd, UA_EID, &mut fd_buf, &mut ());
 
     let mut buf = [0u8; 1024];
     // ---- Attacker (EID 99) sends SetTid(0x99); the FD must ignore it ----