[F54] per-operation correlation + compat OnWriteComplete fan-out
rust / build / test / clippy / fmt (push) Has been cancelled
rust / cargo public-api drift check (F41) (push) Has been cancelled

Closes the residual that R3/R4 Path A's commit `c73a33e` deferred:
the OperationStatus.context field was always None because no
in-flight correlation map existed in SessionInner, and the
mxaccess-compat broadcast channels for OnWriteComplete /
OperationComplete were exposed on the public API but had no
fan-out task draining session events into them.

**mxaccess (Part 1 — per-operation correlation):**

- New `pending_ops: Mutex<HashMap<[u8; 16], OperationContext>>` on
  SessionInner. Populated when `Session::write*` / `subscribe*`
  dispatches an outstanding operation; entry removed when the
  matching OperationStatus event fires (one-shot semantics).
- New `Session::write_with_handle` (and equivalents for the secured /
  timestamped paths) returns a `WriteHandle { correlation_id }` so
  consumers can correlate completions back to their originating
  call. Existing `write` / `write_value` / etc. signatures unchanged
  and delegate to the handle-returning variant.
- Callback router extended to look up `pending_ops` by correlation_id
  on each operation-status event. When found, populates
  `OperationStatus.context: Some(OperationContext { correlation_id,
  op_kind, reference, retry_count: 0 })`. When not found, falls
  through with `context: None` (verbatim-preserve per CLAUDE.md).
- New unit tests assert: matching correlation_id populates context,
  unknown correlation_id leaves context None, the entry is removed
  from `pending_ops` after one event fires.

**mxaccess-compat (Part 2 — compat-layer fan-out):**

- New `correlation_to_item: tokio::sync::Mutex<HashMap<[u8; 16], i32>>`
  on LmxClientInner.
- `LmxClient::write` / `write_2` / `write_secured` / `write_secured_2`
  call `Session::write_with_handle` (or equivalent) and insert
  `correlation_id → item_handle` into the map before returning.
- `LmxClient::register` / `register_asb` spawn a background task that
  drains `session.operation_status_stream()`. Per event, looks up
  `correlation_to_item[event.context?.correlation_id]` to find the
  item_handle, then routes:
  - `OperationKind::Write` / `OperationKind::WriteSecured` →
    `WriteCompleteEvent { server_handle, item_handle, statuses,
    is_during_recovery }` into `on_write_complete_tx`.
  - Other variants → `OperationCompleteEvent { ... }` into
    `on_operation_complete_tx`.
  - Removes the correlation_id from `correlation_to_item` after
    firing (one-shot).
- Events with no matching item_handle (correlation_id not in map)
  are dropped silently — no bogus item_handle=0 events.
- Task cancelled on LmxClient drop via `JoinHandle::abort` (matches
  the existing `subscription_task` pattern).
- New unit tests cover: Write op routes to on_write_complete, Read
  op routes to on_operation_complete, unknown correlation_id is
  dropped.

Result: the C# `LMX_OnWriteComplete(int hLMXServerHandle, int
phItemHandle, ref MXSTATUS_PROXY[] pVars)` callback shape is now
end-to-end-achievable. A consumer calls `LmxClient::write(hServer,
hItem, value, userId)` and drains `client.on_write_complete()`; the
yielded `WriteCompleteEvent` carries the right `(server_handle,
item_handle, statuses, is_during_recovery)` tuple.

Public API: `Session::write_with_handle` + `WriteHandle` are new;
existing signatures unchanged. `cargo public-api` baselines
regenerated under `design/public-api/{mxaccess,mxaccess-compat}.txt`.

Workspace: 765 → 823 tests pass (~58 new tests from F54). Clippy
`-D warnings` clean. Rustdoc `-D warnings` clean.

F54 status in `design/followups.md` moved Open → Resolved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-06 07:41:28 -04:00
parent f98ab9846d
commit 4ff511bbed
5 changed files with 1040 additions and 78 deletions
+427 -19
View File
@@ -59,7 +59,10 @@ use std::task::{Context, Poll};
use std::time::SystemTime;
use futures_util::{Stream, StreamExt};
use mxaccess::{DataChange, Error, MxStatus, MxValue, SecurityContext, Session, Subscription};
use mxaccess::{
DataChange, Error, MxStatus, MxValue, OperationKind, OperationStatus, SecurityContext, Session,
Subscription,
};
use tokio::sync::{Mutex, broadcast};
use tokio::task::JoinHandle;
use tokio_stream::wrappers::BroadcastStream;
@@ -201,9 +204,36 @@ struct LmxInner {
/// is in place so consumers can subscribe today; the trigger fires
/// nothing until a captured byte mapping lands.
on_operation_complete_tx: broadcast::Sender<OperationCompleteEvent>,
/// F54 — `correlation_id → item_handle` map populated by every
/// `LmxClient::write*` (alongside the synthetic op-kind so the
/// drain task can decide whether to route to `on_write_complete`
/// or `on_operation_complete`). Drained one-shot when an operation
/// status event arrives carrying a matching `OperationContext`.
/// Wrapped in `Arc<Mutex<_>>` so the spawned drain task can hold
/// its own reference without keeping a strong handle on the entire
/// `LmxInner` (which would otherwise prevent the `Drop` cleanup).
correlation_to_item: Arc<Mutex<HashMap<[u8; 16], i32>>>,
/// F54 — handle to the fan-out task spawned at construction;
/// aborted on `unregister` / drop. `None` for the test backend
/// (no underlying session to drain).
operation_status_drain: std::sync::Mutex<Option<JoinHandle<()>>>,
disposed: AtomicBool,
}
impl Drop for LmxInner {
fn drop(&mut self) {
// F54: abort the fan-out task on drop so the JoinHandle doesn't
// leak when the LmxClient is dropped without an explicit
// `unregister` call. Mirrors the existing `subscription_task`
// abort pattern at `un_advise`.
if let Ok(mut slot) = self.operation_status_drain.lock() {
if let Some(h) = slot.take() {
h.abort();
}
}
}
}
impl LmxClient {
/// `Register(clientName) → hServer` — open a session and return a
/// facade-owned server handle.
@@ -238,6 +268,31 @@ impl LmxClient {
let (on_buffered_data_change_tx, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
let (on_write_complete_tx, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
let (on_operation_complete_tx, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
let correlation_to_item = Arc::new(Mutex::new(HashMap::<[u8; 16], i32>::new()));
// F54: for the NMX backend, spawn the operation-status drain
// task that maps incoming OperationStatus events back to the
// item_handle (via `correlation_to_item`) and fans out into the
// `on_write_complete` / `on_operation_complete` broadcast
// channels. The ASB backend has no operation-status stream
// analogue today (R3), so the task is omitted there. The test
// backend has no session at all, so it's also omitted.
let drain_task = match &backend {
Backend::Nmx(session) => {
let stream = session.operation_status_stream();
let map = Arc::clone(&correlation_to_item);
let server_handle = 1;
let wc_tx = on_write_complete_tx.clone();
let oc_tx = on_operation_complete_tx.clone();
Some(tokio::spawn(async move {
operation_status_drain(stream, map, server_handle, wc_tx, oc_tx).await;
}))
}
Backend::Asb(_) => None,
#[cfg(test)]
Backend::Test => None,
};
Self {
inner: Arc::new(LmxInner {
server_handle: 1,
@@ -251,6 +306,8 @@ impl LmxClient {
on_buffered_data_change_tx,
on_write_complete_tx,
on_operation_complete_tx,
correlation_to_item,
operation_status_drain: std::sync::Mutex::new(drain_task),
disposed: AtomicBool::new(false),
}),
}
@@ -329,6 +386,12 @@ impl LmxClient {
}
drop(items);
self.inner.users.lock().await.clear();
// F54: stop the operation-status drain task too.
if let Ok(mut slot) = self.inner.operation_status_drain.lock() {
if let Some(h) = slot.take() {
h.abort();
}
}
match &self.inner.backend {
Backend::Nmx(s) => {
@@ -417,7 +480,9 @@ impl LmxClient {
self.check_server_handle(h_server)?;
let (reference, is_buffered) = {
let items = self.inner.items.lock().await;
let item = items.get(&h_item).ok_or_else(|| unknown_item_error(h_item))?;
let item = items
.get(&h_item)
.ok_or_else(|| unknown_item_error(h_item))?;
if item.subscription_task.is_some() {
return Ok(());
}
@@ -462,7 +527,9 @@ impl LmxClient {
pub async fn un_advise(&self, h_server: i32, h_item: i32) -> Result<(), Error> {
self.check_server_handle(h_server)?;
let mut items = self.inner.items.lock().await;
let item = items.get_mut(&h_item).ok_or_else(|| unknown_item_error(h_item))?;
let item = items
.get_mut(&h_item)
.ok_or_else(|| unknown_item_error(h_item))?;
if let Some(task) = item.subscription_task.take() {
task.abort();
}
@@ -474,6 +541,11 @@ impl LmxClient {
/// `Session::write` does not expose a per-write user id; it uses
/// the engine identity). Use [`Self::write_secured_2`] for
/// user-attributed writes.
///
/// F54: returns `Ok(())` once the wire write succeeds; the caller
/// can drain [`Self::on_write_complete`] to observe the matching
/// `OnWriteComplete` event when its operation-status frame
/// arrives.
pub async fn write(
&self,
h_server: i32,
@@ -484,7 +556,17 @@ impl LmxClient {
self.check_server_handle(h_server)?;
let reference = self.item_reference(h_item).await?;
let session = self.nmx_session()?;
session.write(&reference, value).await
// F54: register correlation_id → item_handle BEFORE dispatch
// so a status frame that races the wire send still finds the
// mapping. The drain task pops the entry one-shot when the
// matching OperationStatus arrives.
let handle = session.write_with_handle(&reference, value).await?;
self.inner
.correlation_to_item
.lock()
.await
.insert(handle.correlation_id, h_item);
Ok(())
}
/// `Write2(hServer, hItem, value, time, userId)` — write with
@@ -501,7 +583,15 @@ impl LmxClient {
self.check_server_handle(h_server)?;
let reference = self.item_reference(h_item).await?;
let session = self.nmx_session()?;
session.write_with_timestamp(&reference, value, timestamp).await
let handle = session
.write_with_timestamp_and_handle(&reference, value, timestamp)
.await?;
self.inner
.correlation_to_item
.lock()
.await
.insert(handle.correlation_id, h_item);
Ok(())
}
/// `WriteSecured(hServer, hItem, currUser, verifUser, value)` —
@@ -555,8 +645,8 @@ impl LmxClient {
self.check_server_handle(h_server)?;
let reference = self.item_reference(h_item).await?;
let session = self.nmx_session()?;
session
.write_secured_at(
let handle = session
.write_secured_at_with_handle(
&reference,
value,
timestamp,
@@ -565,7 +655,15 @@ impl LmxClient {
verifier_user_id,
},
)
.await?;
// F54: register the correlation so OnWriteComplete fan-out
// works for secured writes as well.
self.inner
.correlation_to_item
.lock()
.await
.insert(handle.correlation_id, h_item);
Ok(())
}
/// `AuthenticateUser(hServer, user, pwd) → uid` — allocate a user
@@ -591,11 +689,7 @@ impl LmxClient {
/// shape as [`Self::authenticate_user`]; the GUID is validated for
/// shape only (must parse as 32 hex digits with optional dashes —
/// matches `Guid.TryParse` per `cs:543`).
pub async fn archestra_user_to_id(
&self,
h_server: i32,
user_guid: &str,
) -> Result<i32, Error> {
pub async fn archestra_user_to_id(&self, h_server: i32, user_guid: &str) -> Result<i32, Error> {
self.check_server_handle(h_server)?;
if !is_guid_shape(user_guid) {
return Err(invalid_argument(format!(
@@ -615,7 +709,9 @@ impl LmxClient {
pub async fn suspend(&self, h_server: i32, h_item: i32) -> Result<MxStatus, Error> {
self.check_server_handle(h_server)?;
let items = self.inner.items.lock().await;
let item = items.get(&h_item).ok_or_else(|| unknown_item_error(h_item))?;
let item = items
.get(&h_item)
.ok_or_else(|| unknown_item_error(h_item))?;
if item.subscription_task.is_none() {
return Err(invalid_argument(
"Suspend requires an advised item handle".to_string(),
@@ -632,7 +728,9 @@ impl LmxClient {
pub async fn activate(&self, h_server: i32, h_item: i32) -> Result<MxStatus, Error> {
self.check_server_handle(h_server)?;
let items = self.inner.items.lock().await;
let item = items.get(&h_item).ok_or_else(|| unknown_item_error(h_item))?;
let item = items
.get(&h_item)
.ok_or_else(|| unknown_item_error(h_item))?;
if item.subscription_task.is_none() {
return Err(invalid_argument(
"Activate requires an advised item handle".to_string(),
@@ -816,6 +914,79 @@ async fn fanout_subscription(
}
}
// ---- F54: operation-status drain task ---------------------------------
/// Drain the `Session::operation_status_stream()` Stream and route each
/// event to the matching `LmxClient` event channel.
///
/// For each event:
/// 1. If `event.context` is `None` (no pending op was outstanding when
/// the frame arrived), drop silently — the .NET reference would
/// surface this as an `OperationCompleteEvent { item_handle = 0 }`
/// which is meaningless here. CLAUDE.md preserve-fallback applies.
/// 2. Look up `event.context?.correlation_id` in the
/// `correlation_to_item` map. If not present (the write didn't go
/// through the compat layer, or was already drained), drop silently.
/// 3. Branch on `event.context?.op_kind`:
/// - `Write` / `WriteSecured` → push a `WriteCompleteEvent` onto
/// `wc_tx`.
/// - any other kind → push an `OperationCompleteEvent` onto `oc_tx`.
/// 4. Remove the `correlation_id` entry from the map (one-shot).
///
/// Loops until the underlying broadcast Stream ends (i.e. the
/// `Session` was shut down and its `operation_status_tx` Sender
/// dropped). Aborted via [`JoinHandle::abort`] from `LmxInner::drop`
/// if the consumer drops the `LmxClient` first.
async fn operation_status_drain<S>(
mut stream: S,
correlation_to_item: Arc<Mutex<HashMap<[u8; 16], i32>>>,
server_handle: i32,
wc_tx: broadcast::Sender<WriteCompleteEvent>,
oc_tx: broadcast::Sender<OperationCompleteEvent>,
) where
S: Stream<Item = Result<Arc<OperationStatus>, Error>> + Unpin,
{
while let Some(item) = stream.next().await {
let event = match item {
Ok(ev) => ev,
// Lag-loss errors are surfaced to the raw consumer
// (Session::operation_status_events) already; drop here.
Err(_) => continue,
};
let ctx = match &event.context {
Some(ctx) => ctx,
None => continue, // verbatim-preserve fallback per CLAUDE.md
};
// One-shot lookup + remove. Held under a single guard.
let item_handle = {
let mut map = correlation_to_item.lock().await;
map.remove(&ctx.correlation_id)
};
let Some(item_handle) = item_handle else {
continue; // not a write the LmxClient issued
};
match ctx.op_kind {
OperationKind::Write | OperationKind::WriteSecured => {
let _ = wc_tx.send(WriteCompleteEvent {
server_handle,
item_handle,
statuses: vec![event.status],
is_during_recovery: event.is_during_recovery,
});
}
_ => {
let _ = oc_tx.send(OperationCompleteEvent {
server_handle,
item_handle,
statuses: vec![event.status],
is_during_recovery: event.is_during_recovery,
});
}
}
}
}
// ---- Public stream wrapper --------------------------------------------
/// `Stream` over a broadcast channel, with `Lagged` errors silently
@@ -870,7 +1041,10 @@ fn unknown_item_error(h_item: i32) -> Error {
}
fn is_guid_shape(s: &str) -> bool {
let stripped: String = s.chars().filter(|c| *c != '-' && *c != '{' && *c != '}').collect();
let stripped: String = s
.chars()
.filter(|c| *c != '-' && *c != '{' && *c != '}')
.collect();
stripped.len() == 32 && stripped.chars().all(|c| c.is_ascii_hexdigit())
}
@@ -885,7 +1059,12 @@ fn combine_item_context(item_def: &str, context: &str) -> String {
// ---- Tests ------------------------------------------------------------
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::indexing_slicing)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)]
mod tests {
use super::*;
@@ -911,6 +1090,8 @@ mod tests {
on_buffered_data_change_tx: tx_bdc,
on_write_complete_tx: tx_wc,
on_operation_complete_tx: tx_oc,
correlation_to_item: Arc::new(Mutex::new(HashMap::new())),
operation_status_drain: std::sync::Mutex::new(None),
disposed: AtomicBool::new(false),
}),
}
@@ -1036,7 +1217,10 @@ mod tests {
let client = test_client();
let err = client.set_buffered_update_interval(1, 0).await.unwrap_err();
assert!(matches!(err, Error::Configuration(_)));
let err = client.set_buffered_update_interval(1, -1).await.unwrap_err();
let err = client
.set_buffered_update_interval(1, -1)
.await
.unwrap_err();
assert!(matches!(err, Error::Configuration(_)));
}
@@ -1220,11 +1404,236 @@ mod tests {
statuses: vec![MxStatus::DATA_CHANGE_OK],
is_during_recovery: false,
};
client.inner.on_write_complete_tx.send(event.clone()).unwrap();
client
.inner
.on_write_complete_tx
.send(event.clone())
.unwrap();
let received = stream.next().await.expect("event received");
assert_eq!(received.item_handle, 9);
}
// ---- F54: operation-status drain fan-out --------------------------
/// Build a synthetic [`OperationStatus`] for tests. Mirrors the
/// shape produced by `Session`'s `callback_router` for the proven
/// `00 00 50 80 00` 5-byte StatusWord frame, with the correlation
/// id + op_kind controllable by the caller.
fn synth_operation_status(
correlation_id: [u8; 16],
op_kind: OperationKind,
reference: &str,
is_during_recovery: bool,
) -> Arc<OperationStatus> {
use mxaccess::{NmxOperationStatusFormat, NmxOperationStatusMessage};
let raw = NmxOperationStatusMessage {
format: NmxOperationStatusFormat::StatusWord,
command: 0x00,
status_code: 0x8050,
completion_code: 0x00,
status: MxStatus::WRITE_COMPLETE_OK,
};
let context = mxaccess::OperationContext::new(
correlation_id,
op_kind,
Some(Arc::<str>::from(reference)),
/* retry_count */ 0,
);
Arc::new(OperationStatus::new(
raw,
MxStatus::WRITE_COMPLETE_OK,
Some(context),
is_during_recovery,
))
}
/// F54 — drive the drain task with a synthetic
/// `Stream<OperationStatus>` carrying a Write-kind event whose
/// correlation id is registered in `correlation_to_item`. The
/// fan-out pushes a `WriteCompleteEvent` onto `on_write_complete`
/// with the matched `item_handle`.
#[tokio::test]
async fn drain_routes_write_status_to_on_write_complete() {
use futures_util::stream;
let client = test_client();
let item_handle = 7;
let correlation_id: [u8; 16] = [0xB1; 16];
// Pre-populate the correlation map (mirrors what
// `LmxClient::write` does after `Session::write_with_handle`).
{
let mut map = client.inner.correlation_to_item.lock().await;
map.insert(correlation_id, item_handle);
}
// Build a one-event stream and drive the drain helper directly.
let event = synth_operation_status(
correlation_id,
OperationKind::Write,
"TestObj.TestInt",
/* is_during_recovery */ false,
);
let stream = stream::iter(vec![Ok(event)]);
let mut wc = client.on_write_complete();
let _drain = operation_status_drain(
stream,
Arc::clone(&client.inner.correlation_to_item),
client.inner.server_handle,
client.inner.on_write_complete_tx.clone(),
client.inner.on_operation_complete_tx.clone(),
);
// Run the future to completion (one iteration; stream ends).
_drain.await;
let received = tokio::time::timeout(std::time::Duration::from_secs(1), wc.next())
.await
.expect("drain timed out")
.expect("stream returned None");
// F54 contract: server_handle / item_handle / statuses /
// is_during_recovery match the synthetic event.
assert_eq!(received.server_handle, client.inner.server_handle);
assert_eq!(received.item_handle, item_handle);
assert_eq!(received.statuses, vec![MxStatus::WRITE_COMPLETE_OK]);
assert!(!received.is_during_recovery);
// One-shot semantics: the entry has been removed from the map.
let map = client.inner.correlation_to_item.lock().await;
assert!(map.is_empty(), "correlation_to_item must be drained");
}
/// F54 — same shape as the write test but with `OperationKind::Read`
/// — must route to `on_operation_complete` instead of
/// `on_write_complete`.
#[tokio::test]
async fn drain_routes_non_write_status_to_on_operation_complete() {
use futures_util::stream;
let client = test_client();
let item_handle = 11;
let correlation_id: [u8; 16] = [0xB2; 16];
{
let mut map = client.inner.correlation_to_item.lock().await;
map.insert(correlation_id, item_handle);
}
let event = synth_operation_status(
correlation_id,
OperationKind::Read,
"TestObj.TestInt",
/* is_during_recovery */ false,
);
let stream = stream::iter(vec![Ok(event)]);
let mut wc = client.on_write_complete();
let mut oc = client.on_operation_complete();
operation_status_drain(
stream,
Arc::clone(&client.inner.correlation_to_item),
client.inner.server_handle,
client.inner.on_write_complete_tx.clone(),
client.inner.on_operation_complete_tx.clone(),
)
.await;
// OperationCompleteEvent fired.
let received = tokio::time::timeout(std::time::Duration::from_secs(1), oc.next())
.await
.expect("drain timed out")
.expect("stream returned None");
assert_eq!(received.item_handle, item_handle);
assert_eq!(received.statuses, vec![MxStatus::WRITE_COMPLETE_OK]);
// No WriteCompleteEvent on the write channel.
let res = tokio::time::timeout(std::time::Duration::from_millis(100), wc.next()).await;
assert!(
res.is_err(),
"non-write op must NOT fire OnWriteComplete; got {res:?}"
);
}
/// F54 — an operation-status event whose correlation_id has no
/// matching entry in `correlation_to_item` is dropped silently.
/// Don't fire a bogus event with item_handle = 0.
#[tokio::test]
async fn drain_drops_event_with_unknown_correlation() {
use futures_util::stream;
let client = test_client();
// No insertion into correlation_to_item — the event will be
// unknown.
let event =
synth_operation_status([0xCC; 16], OperationKind::Write, "TestObj.TestInt", false);
let stream = stream::iter(vec![Ok(event)]);
let mut wc = client.on_write_complete();
let mut oc = client.on_operation_complete();
operation_status_drain(
stream,
Arc::clone(&client.inner.correlation_to_item),
client.inner.server_handle,
client.inner.on_write_complete_tx.clone(),
client.inner.on_operation_complete_tx.clone(),
)
.await;
// Neither channel should fire.
let wc_res = tokio::time::timeout(std::time::Duration::from_millis(100), wc.next()).await;
let oc_res = tokio::time::timeout(std::time::Duration::from_millis(100), oc.next()).await;
assert!(
wc_res.is_err(),
"unknown correlation must NOT fire on_write_complete"
);
assert!(
oc_res.is_err(),
"unknown correlation must NOT fire on_operation_complete"
);
}
/// F54 — an OperationStatus with `context: None` (the event
/// arrived without a matching pending op) is dropped silently —
/// CLAUDE.md preserve-fallback applies.
#[tokio::test]
async fn drain_drops_event_with_none_context() {
use futures_util::stream;
use mxaccess::{NmxOperationStatusFormat, NmxOperationStatusMessage};
let client = test_client();
let raw = NmxOperationStatusMessage {
format: NmxOperationStatusFormat::StatusWord,
command: 0x00,
status_code: 0x8050,
completion_code: 0x00,
status: MxStatus::WRITE_COMPLETE_OK,
};
let event = Arc::new(OperationStatus::new(
raw,
MxStatus::WRITE_COMPLETE_OK,
/* context */ None, // verbatim-preserve fallback
/* is_during_recovery */ false,
));
let stream = stream::iter(vec![Ok(event)]);
let mut wc = client.on_write_complete();
operation_status_drain(
stream,
Arc::clone(&client.inner.correlation_to_item),
client.inner.server_handle,
client.inner.on_write_complete_tx.clone(),
client.inner.on_operation_complete_tx.clone(),
)
.await;
let wc_res = tokio::time::timeout(std::time::Duration::from_millis(100), wc.next()).await;
assert!(
wc_res.is_err(),
"context=None must drop silently — got {wc_res:?}"
);
}
#[tokio::test]
async fn operation_complete_event_stream_yields_published_items() {
let client = test_client();
@@ -1244,4 +1653,3 @@ mod tests {
assert_eq!(received.item_handle, 5);
}
}