smb2/client/diagnostics.rs
1//! Diagnostics: an in-process observability surface for a running [`SmbClient`](crate::SmbClient).
2//!
3//! Call [`SmbClient::diagnostics`](crate::SmbClient::diagnostics) to capture
4//! a point-in-time tree of the client's negotiated parameters, credits,
5//! in-flight requests, per-connection counters, and DFS cache state. Call
6//! [`Connection::diagnostics`](crate::client::Connection::diagnostics) for
7//! the per-connection slice.
8//!
9//! ## Consistency model
10//!
11//! Snapshots are **eventually consistent**. Each field is loaded
12//! independently: the available-credits gauge, the in-flight count
13//! (`waiters.len()`), and each counter are sampled at slightly different
14//! moments. Sums of related fields (for example `credits.available +
15//! credits.in_flight`) are **not** invariant — read each field for what it
16//! says about itself, not as a coupled tuple. A consumer that wants
17//! atomicity quiesces operations first.
18//!
19//! ## Snapshot lock order
20//!
21//! The snapshot acquires these locks, one at a time, in this order, never
22//! across an `.await`: `crypto → waiters → dfs_trees → estimated_rtt`.
23//! Each is held only as long as it takes to copy primitives out and
24//! release. `params` is an `OnceLock` (wait-free read). `preauth_hasher`
25//! and `receiver_task` are not touched by the snapshot.
26//!
27//! If you add a field that touches a new lock, **extend** this order, don't
28//! reshuffle it.
29//!
30//! ## Counters survive teardown
31//!
32//! Counters live on `Arc<Inner>`, which outlives the receiver task. A
33//! snapshot taken on a torn-down connection (`disconnected: true`) returns
34//! the final counter values at the moment of death.
35//!
36//! ## Counters carry across a reconnect
37//!
38//! [`SmbClient::reconnect`](crate::SmbClient::reconnect) revives the existing
39//! [`Connection`](crate::client::Connection) on a fresh socket rather than
40//! building a new one, so `Inner` — and every counter on it — survives. The
41//! numbers describe the whole life of the client's link to this server, blips
42//! included, which is also what makes
43//! [`MetricsSnapshot::reconnects_succeeded`] meaningful: a counter reset by the
44//! event it counts would always read zero.
45//!
46//! Client-level counters (the [`ClientMetricsSnapshot`] on
47//! [`Diagnostics::client`]) survive too; `reconnects` counts explicit
48//! [`SmbClient::reconnect`](crate::SmbClient::reconnect) calls, while
49//! `reconnects_succeeded` counts every revival including the automatic ones.
50//!
51//! See `docs/specs/diagnostics-plan.md` for the design rationale.
52
53use std::fmt;
54use std::time::Duration;
55
56use crate::crypto::encryption::Cipher;
57use crate::crypto::signing::SigningAlgorithm;
58use crate::pack::Guid;
59use crate::types::flags::Capabilities;
60use crate::types::{Command, Dialect, SessionId, TreeId};
61
62/// Top-level diagnostics tree, captured by [`SmbClient::diagnostics`](crate::SmbClient::diagnostics).
63#[non_exhaustive]
64#[derive(Debug, Clone)]
65#[cfg_attr(feature = "serde", derive(serde::Serialize))]
66pub struct Diagnostics {
67 /// Client-level configuration and counters.
68 pub client: ClientInfo,
69 /// The primary connection. Its `session` field carries the primary
70 /// session (or `None` until session setup runs).
71 pub primary: ConnectionDiagnostics,
72 /// DFS cross-server connections, each with its own session. Each
73 /// extra entry was authenticated separately.
74 pub extra_connections: Vec<ConnectionDiagnostics>,
75 /// DFS referral cache snapshot (one entry per cached path prefix).
76 pub dfs_cache: Vec<DfsCacheEntry>,
77}
78
79/// Client-level configuration + counters.
80#[non_exhaustive]
81#[derive(Debug, Clone)]
82#[cfg_attr(feature = "serde", derive(serde::Serialize))]
83pub struct ClientInfo {
84 /// The server address the client was constructed with (`host:port`).
85 pub primary_server: String,
86 /// Connection timeout from [`ClientConfig`](crate::ClientConfig).
87 pub timeout: Duration,
88 /// Whether the client was configured for auto-reconnect on loss.
89 pub auto_reconnect: bool,
90 /// Whether DFS resolution is enabled.
91 pub dfs_enabled: bool,
92 /// Client-level counters (survive `reconnect`).
93 pub metrics: ClientMetricsSnapshot,
94}
95
96/// Per-connection snapshot, captured by
97/// [`Connection::diagnostics`](crate::client::Connection::diagnostics) and
98/// included in [`Diagnostics::primary`] / [`Diagnostics::extra_connections`].
99#[non_exhaustive]
100#[derive(Debug, Clone)]
101#[cfg_attr(feature = "serde", derive(serde::Serialize))]
102pub struct ConnectionDiagnostics {
103 /// Server hostname or IP this connection talks to.
104 pub server: String,
105 /// Negotiated parameters, or `None` until `negotiate()` runs.
106 pub negotiated: Option<NegotiatedSummary>,
107 /// Credit gauge + in-flight count + next-MessageId.
108 pub credits: CreditInfo,
109 /// Signing state.
110 pub signing: SigningInfo,
111 /// Encryption state.
112 pub encryption: EncryptionInfo,
113 /// Compression state.
114 pub compression: CompressionInfo,
115 /// RTT measured during `negotiate`, if it ran.
116 pub rtt_estimate: Option<Duration>,
117 /// `true` after the receiver task has torn down (transport error,
118 /// decrypt failure, etc.).
119 pub disconnected: bool,
120 /// Tree IDs that have DFS capability on this connection.
121 pub dfs_trees: Vec<TreeId>,
122 /// Session on this connection, or `None` until session setup runs.
123 pub session: Option<SessionDiagnostics>,
124 /// Per-connection counters.
125 pub metrics: MetricsSnapshot,
126 /// Requests sent and not yet answered, oldest first.
127 ///
128 /// Empty on a healthy idle connection. A long-lived entry here is the
129 /// signature of a hung request; see [`OutstandingRequest`].
130 pub outstanding: Vec<OutstandingRequest>,
131}
132
133/// Snapshot of [`NegotiatedParams`](crate::client::NegotiatedParams) for
134/// the diagnostics tree. Same fields, copied (not borrowed).
135#[non_exhaustive]
136#[derive(Debug, Clone)]
137#[cfg_attr(feature = "serde", derive(serde::Serialize))]
138pub struct NegotiatedSummary {
139 /// Negotiated dialect.
140 pub dialect: Dialect,
141 /// Maximum read size the server supports.
142 pub max_read_size: u32,
143 /// Maximum write size the server supports.
144 pub max_write_size: u32,
145 /// Maximum transact size the server supports.
146 pub max_transact_size: u32,
147 /// Server GUID.
148 pub server_guid: Guid,
149 /// Whether the server requires signing.
150 pub signing_required: bool,
151 /// Server capabilities.
152 ///
153 /// With the `serde` feature on, this serializes as the underlying
154 /// `u32` bits (not a JSON object of named flags).
155 pub capabilities: Capabilities,
156 /// Whether AES-GMAC signing was negotiated (SMB 3.1.1).
157 pub gmac_negotiated: bool,
158 /// The negotiated encryption cipher (SMB 3.x).
159 pub cipher: Option<Cipher>,
160 /// Whether compression was negotiated with the server.
161 pub compression_supported: bool,
162}
163
164/// Credit gauge for the connection.
165///
166/// The fields are sampled independently — `available + in_flight` is **not**
167/// invariant. See the module-level eventual-consistency note.
168#[non_exhaustive]
169#[derive(Debug, Clone, Copy)]
170#[cfg_attr(feature = "serde", derive(serde::Serialize))]
171pub struct CreditInfo {
172 /// Credits on hand: granted by the server and not reserved by a request
173 /// that is still in flight. This is what a new request can draw on, not
174 /// the size of the server's window.
175 pub available: u16,
176 /// Number of `MessageId`s currently waiting for a response (i.e.
177 /// `waiters.len()`).
178 pub in_flight: usize,
179 /// The `MessageId` that will be assigned to the next request.
180 pub next_message_id: u64,
181 /// Frames handed to the writer task and not yet written.
182 ///
183 /// Steadily non-zero while `wire_bytes_sent` stands still means the
184 /// send side is stuck, not the server.
185 pub send_queue_depth: usize,
186}
187
188/// One request that is in flight: registered for a response, and not yet
189/// answered.
190///
191/// Surfaced so a consumer can see WHICH request a connection is waiting on, not
192/// just how many. A connection that keeps serving small requests while one large
193/// write hangs looks healthy by every other measure in this snapshot: credits
194/// recover, counters advance, `disconnected` stays `false`. This is the field
195/// that tells them apart.
196///
197/// **Read [`sent_age`](Self::sent_age) before concluding anything about the
198/// server.** "In flight" starts at registration, which is BEFORE the bytes
199/// reach the transport, so an entry here does not by itself mean the server
200/// was asked and stayed quiet. A 2026-08-01 wedge was read that way for
201/// weeks: hundreds of requests looked unanswered for half an hour while the
202/// truth was that not one byte had left the socket.
203#[non_exhaustive]
204#[derive(Debug, Clone)]
205#[cfg_attr(feature = "serde", derive(serde::Serialize))]
206pub struct OutstandingRequest {
207 /// The SMB2 command that was sent.
208 pub command: Command,
209 /// Its `MessageId`, matching the `dispatch:` log line.
210 pub message_id: u64,
211 /// How long ago the request was registered as in flight.
212 ///
213 /// Measured from registration, so it includes any time spent queued for
214 /// the transport.
215 pub age: Duration,
216 /// How long ago the frame reached the transport, or `None` if it is still
217 /// queued to be sent.
218 ///
219 /// `None` with a large `age` is the signature of a send-side wedge: the
220 /// server cannot answer a question it was never asked. `Some(d)` with a
221 /// large `d` is the genuine "server went quiet" case.
222 pub sent_age: Option<Duration>,
223 /// The `AsyncId` the server assigned in its interim `STATUS_PENDING`, or
224 /// `None` if it has not sent one.
225 ///
226 /// What a
227 /// [`Connection::send_cancel`](crate::client::connection::Connection::send_cancel)
228 /// for this request has to carry: once a request has an `AsyncId`, a
229 /// cancel against its `MessageId` alone matches nothing (MS-SMB2
230 /// § 3.2.4.24).
231 pub async_id: Option<u64>,
232}
233
234/// Signing state.
235#[derive(Debug, Clone, Copy)]
236#[cfg_attr(feature = "serde", derive(serde::Serialize))]
237pub struct SigningInfo {
238 /// `true` when outgoing requests are being signed.
239 pub active: bool,
240 /// Negotiated signing algorithm, or `None` if signing isn't active.
241 pub algorithm: Option<SigningAlgorithm>,
242}
243
244/// Encryption state.
245#[derive(Debug, Clone, Copy)]
246#[cfg_attr(feature = "serde", derive(serde::Serialize))]
247pub struct EncryptionInfo {
248 /// `true` when outgoing requests are being encrypted with
249 /// `TRANSFORM_HEADER`.
250 pub active: bool,
251 /// Negotiated encryption cipher, or `None` if encryption isn't active.
252 pub cipher: Option<Cipher>,
253}
254
255/// Compression state.
256#[derive(Debug, Clone, Copy)]
257#[cfg_attr(feature = "serde", derive(serde::Serialize))]
258pub struct CompressionInfo {
259 /// Whether the client requested compression in `ClientConfig`.
260 pub requested: bool,
261 /// Whether compression was actually negotiated and is active.
262 pub negotiated: bool,
263}
264
265/// Per-connection session snapshot. Each
266/// [`ConnectionDiagnostics`] has its own — DFS extra connections each
267/// authenticate separately, so they each carry a distinct session.
268#[non_exhaustive]
269#[derive(Debug, Clone)]
270#[cfg_attr(feature = "serde", derive(serde::Serialize))]
271pub struct SessionDiagnostics {
272 /// SMB session ID assigned by the server.
273 pub session_id: SessionId,
274 /// `true` when the session requires signing.
275 pub should_sign: bool,
276 /// `true` when the session requires encryption.
277 pub should_encrypt: bool,
278 /// Signing algorithm derived for this session.
279 pub signing_algorithm: SigningAlgorithm,
280}
281
282/// One entry in the DFS referral cache.
283#[non_exhaustive]
284#[derive(Debug, Clone)]
285#[cfg_attr(feature = "serde", derive(serde::Serialize))]
286pub struct DfsCacheEntry {
287 /// The DFS path prefix this entry covers. Lowercased UNC form (the
288 /// internal normalization used for case-insensitive matching).
289 pub path_prefix: String,
290 /// Number of failover targets the server returned.
291 pub target_count: usize,
292 /// Remaining time-to-live. `None` if the entry has already expired
293 /// (cache eviction is lazy: expired entries linger until the next
294 /// `resolve()` for an overlapping prefix).
295 pub expires_in: Option<Duration>,
296}
297
298/// Per-connection counter snapshot, taken atomically at the field level
299/// but not as a whole (fields may skew — see [module docs](self)).
300///
301/// Counters are monotonic across the connection's lifetime. To compute a
302/// rate, take two snapshots and subtract.
303#[non_exhaustive]
304#[derive(Debug, Clone, Copy, Default)]
305#[cfg_attr(feature = "serde", derive(serde::Serialize))]
306pub struct MetricsSnapshot {
307 /// Every `MessageId` allocated for a request. Includes negotiate,
308 /// session-setup, every `execute`, every `execute_with_credits`, every
309 /// `dispatch` (Watcher's pre-arm CHANGE_NOTIFY), and every sub-op of
310 /// every `execute_compound`. Does *not* include CANCEL (see
311 /// [`Self::explicit_cancels_sent`]).
312 pub requests_sent: u64,
313 /// Every successful `execute_compound` call — the chain itself, not
314 /// the per-sub-op count (those tick `requests_sent`).
315 pub compound_requests_sent: u64,
316 /// Bytes handed to `Transport::send` — the wire-layer count, after
317 /// any sign / encrypt / compress on the send side. The byte count a
318 /// packet capture would observe.
319 pub wire_bytes_sent: u64,
320 /// `Connection::send_cancel` invocations. CANCEL is the only SMB op
321 /// today that we send proactively; cancellation-by-drop is invisible
322 /// here (the drop never reaches the wire).
323 pub explicit_cancels_sent: u64,
324
325 /// Sub-frames where the receiver task found the waiter in the map
326 /// and successfully delivered `Ok(frame)` to it. The normal happy
327 /// path.
328 pub responses_routed_ok: u64,
329 /// Sub-frames where the receiver task found the waiter in the map
330 /// and successfully delivered `Err(_)` to it. Today this is the
331 /// union: [`Self::signature_failures`] + [`Self::session_expired_events`].
332 /// Don't sum *those* with this counter — they're a partition of it.
333 pub responses_routed_err: u64,
334 /// Sub-frames where the receiver task found the waiter in the map
335 /// but the caller's `oneshot::Receiver` was already dropped. Typical
336 /// for `tokio::spawn` + `JoinHandle::abort()` patterns where the
337 /// caller's future was cancelled mid-flight. The frame is discarded
338 /// silently; credits already applied.
339 pub responses_late_after_drop: u64,
340 /// Sub-frames where the receiver task did **not** find the waiter
341 /// in the map. The genuine orphan: server sent a frame for a
342 /// `MessageId` we never allocated, or a send-error cleanup raced
343 /// with arrival. Should be near-zero in normal operation.
344 pub responses_stray: u64,
345 /// Bytes received from `Transport::receive` — wire-layer, before
346 /// any decrypt / decompress.
347 pub wire_bytes_received: u64,
348
349 /// Interim STATUS_PENDING sub-frames the receiver kept the waiter
350 /// alive on (CHANGE_NOTIFY long-polls, slow IOCTLs).
351 pub status_pending_loops: u64,
352 /// Sub-frames with `MessageId::UNSOLICITED` (today: oplock breaks;
353 /// the same magic id is reserved for future lease-break and other
354 /// server-initiated notifications). Counted, logged at DEBUG,
355 /// skipped — no waiter to route to.
356 pub unsolicited_notifications_received: u64,
357 /// Sub-frames whose signature verification failed. The error is
358 /// routed to the matching waiter (also ticks
359 /// [`Self::responses_routed_err`]); the connection continues.
360 pub signature_failures: u64,
361 /// Frames the receiver task could not decrypt (auth-tag mismatch,
362 /// missing decryption key, malformed `TransformHeader`). Counted
363 /// once before the connection tears down — the receiver task
364 /// fans `Err(Disconnected)` to every pending waiter and exits.
365 pub decrypt_failures: u64,
366 /// Frames the receiver task could not decompress. Same teardown
367 /// behavior as decrypt failures.
368 pub decompress_failures: u64,
369 /// Frames the receiver task could not parse (compound split,
370 /// sub-frame header parse). Same teardown behavior. Covers both
371 /// the `split_compound` parse-failure branch and the
372 /// `prepare_sub_frame` header-parse branch.
373 pub malformed_frames: u64,
374 /// Sub-frames with `STATUS_NETWORK_SESSION_EXPIRED`. Counted
375 /// per-sub-frame, not per session-event: a compound of N expired
376 /// sub-ops ticks N times. For the event-shaped signal "did we
377 /// reconnect", use [`ClientMetricsSnapshot::reconnects`]. Subset
378 /// of [`Self::responses_routed_err`]; don't sum.
379 pub session_expired_events: u64,
380
381 /// `execute` / `execute_with_credits` / `execute_compound` returned
382 /// an outer `Err` to a caller that polled to completion. Per-call,
383 /// not per-sub-op: an `execute_compound` whose inner `Vec` contains
384 /// errors but whose outer `Result` is `Ok` does **not** tick this.
385 ///
386 /// Caller-drop (the spawn/abort pattern) is captured by
387 /// [`Self::responses_late_after_drop`], not here — a dropped future
388 /// never polls to a return value.
389 pub requests_returned_err: u64,
390
391 /// Sends that had to park because every credit the server granted was
392 /// already in flight. A trickle is normal on a saturated pipeline; a
393 /// flood means the server's window is small relative to the chunk size,
394 /// and throughput is bounded by credits rather than by the network.
395 pub credit_waits: u64,
396 /// Sends that gave up waiting for a grant and returned
397 /// [`Error::CreditStarvation`](crate::Error::CreditStarvation). Subset of
398 /// [`Self::credit_waits`]; don't sum. Non-zero means a server stopped
399 /// answering while its socket stayed open.
400 pub credit_starvations: u64,
401 /// Requests abandoned because the server went silent for longer than
402 /// [`Connection::set_response_timeout`](crate::client::connection::Connection::set_response_timeout).
403 /// The clock restarts on every interim `STATUS_PENDING`, so this counts
404 /// total silence, not slowness.
405 pub response_timeouts: u64,
406 /// Frames the transport refused to write: a send that timed out
407 /// ([`Error::SendTimeout`](crate::Error::SendTimeout)) or errored.
408 ///
409 /// Non-zero means the wedge was on OUR side of the wire — the request
410 /// never reached the server, so nothing about the server follows from it.
411 pub send_failures: u64,
412 /// SMB2 ECHO probes the keepalive put on the wire.
413 ///
414 /// Zero on a healthy busy connection and that is correct, not a bug: the
415 /// keepalive only probes when the server has gone quiet with work
416 /// outstanding, and responses flowing are already proof of life.
417 pub keepalive_probes_sent: u64,
418 /// Probe rounds that asked the server nothing, so they are evidence of
419 /// nothing: no credit was on hand for the ECHO, or the connection was
420 /// already going down.
421 ///
422 /// A steady stream of these means the credit window is fully spent
423 /// whenever the server goes quiet, which leaves the connection without a
424 /// liveness signal — slow-but-alive operations fall back to the plain
425 /// response deadline. Skips are never counted as failures: they say
426 /// nothing about the server.
427 pub keepalive_probes_skipped: u64,
428 /// Probes that reached the wire and were never answered.
429 ///
430 /// Not a failure count in any operational sense — a busy NAS drops probes
431 /// while it writes, and this rising on its own costs a connection nothing
432 /// but the deadline extension it would otherwise have earned. It is worth
433 /// watching next to `response_deadline_extensions`: probes going
434 /// unanswered while slow operations need the extra room is the shape that
435 /// ends in [`Error::Timeout`](crate::Error::Timeout).
436 pub keepalive_failures: u64,
437 /// Requests that went past
438 /// [`Connection::set_response_timeout`](crate::client::connection::Connection::set_response_timeout)
439 /// without being abandoned, because an ECHO had just proven the server
440 /// alive.
441 ///
442 /// Each tick is a slow-but-healthy operation the deadline alone would have
443 /// killed — a large write to a loaded spinning-disk NAS is the usual one.
444 /// A rising count next to a flat `response_timeouts` is the keepalive
445 /// working exactly as intended.
446 pub response_deadline_extensions: u64,
447 /// Long-poll requests (CHANGE_NOTIFY) retired and re-issued because they
448 /// reached
449 /// [`Connection::set_long_poll_refresh`](crate::client::connection::Connection::set_long_poll_refresh).
450 ///
451 /// ❌ Not an error count and not a detection count. Nothing can tell a
452 /// subscription the server has forgotten from a directory nobody has
453 /// touched — both are silence — so the client re-issues on a cycle instead
454 /// of trying, and this ticks every time it does. On a healthy watch it
455 /// climbs at roughly one per interval per watched directory. Zero while a
456 /// watcher has been open for longer than the interval means the refresh is
457 /// turned off, which is what leaves a dropped subscription dead forever.
458 pub long_poll_refreshes: u64,
459
460 /// Dials made trying to bring this connection back, across every revival.
461 pub reconnect_attempts: u64,
462 /// Revivals that ended with a live, authenticated session on a fresh
463 /// socket.
464 ///
465 /// The after-the-fact answer to "was this link quietly flaky?". A transfer
466 /// that finished with a non-zero value here survived something the user
467 /// never saw, which is exactly the outcome the spec worried about hiding:
468 /// the number is always here whether or not anyone subscribed to
469 /// [`ReconnectEvent`](crate::client::connection::ReconnectEvent).
470 ///
471 /// ❌ Unlike every other per-connection counter this one is NOT reset by a
472 /// reconnect — it counts them, and a counter that resets on the event it
473 /// counts would always read zero.
474 pub reconnects_succeeded: u64,
475 /// Revivals that gave up, each surfaced to its caller as
476 /// [`Error::ReconnectFailed`](crate::Error::ReconnectFailed).
477 pub reconnects_failed: u64,
478}
479
480/// Client-level counter snapshot. Lives on [`SmbClient`](crate::SmbClient)
481/// (above the per-connection layer) and survives
482/// [`SmbClient::reconnect`](crate::SmbClient::reconnect).
483#[non_exhaustive]
484#[derive(Debug, Clone, Copy, Default)]
485#[cfg_attr(feature = "serde", derive(serde::Serialize))]
486pub struct ClientMetricsSnapshot {
487 /// `SmbClient::reconnect` invocations. The event-shaped signal "did
488 /// we reconnect" — pair with
489 /// [`MetricsSnapshot::session_expired_events`] if you want both.
490 pub reconnects: u64,
491 /// DFS path resolutions that resulted in a referral IOCTL to the
492 /// server (cache miss).
493 pub dfs_referrals_resolved: u64,
494 /// DFS path resolutions served from the in-process referral cache
495 /// (cache hit).
496 pub dfs_cache_hits: u64,
497}
498
499impl fmt::Display for Diagnostics {
500 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
501 let c = &self.client;
502 writeln!(f, "SMB client → {}", c.primary_server)?;
503 writeln!(
504 f,
505 " reconnects: {} dfs: {} (hits: {}, referrals resolved: {}, cache entries: {})",
506 c.metrics.reconnects,
507 if c.dfs_enabled { "enabled" } else { "disabled" },
508 c.metrics.dfs_cache_hits,
509 c.metrics.dfs_referrals_resolved,
510 self.dfs_cache.len(),
511 )?;
512 writeln!(f)?;
513 writeln!(f, "Primary connection ({})", self.primary.server)?;
514 fmt_connection_body(&self.primary, f)?;
515
516 if !self.extra_connections.is_empty() {
517 writeln!(f)?;
518 writeln!(
519 f,
520 "DFS extra connections: ({})",
521 self.extra_connections.len()
522 )?;
523 for c in &self.extra_connections {
524 writeln!(f)?;
525 writeln!(f, " ↳ {}", c.server)?;
526 fmt_connection_body(c, f)?;
527 }
528 } else {
529 writeln!(f)?;
530 writeln!(f, "DFS extra connections: (0)")?;
531 }
532 Ok(())
533 }
534}
535
536fn fmt_connection_body(c: &ConnectionDiagnostics, f: &mut fmt::Formatter<'_>) -> fmt::Result {
537 let m = &c.metrics;
538 match &c.negotiated {
539 Some(n) => {
540 let rtt = c
541 .rtt_estimate
542 .map(|d| format!("{:.1} ms", d.as_secs_f64() * 1000.0))
543 .unwrap_or_else(|| "—".to_string());
544 writeln!(f, " dialect: {:?} rtt: {}", n.dialect, rtt)?;
545 writeln!(
546 f,
547 " signing: {} encryption: {} compression: {}",
548 fmt_signing(&c.signing),
549 fmt_encryption(&c.encryption),
550 fmt_compression(&c.compression),
551 )?;
552 }
553 None => {
554 writeln!(
555 f,
556 " (pre-negotiate — no dialect / signing / encryption yet)"
557 )?;
558 }
559 }
560 writeln!(
561 f,
562 " credits: {} available · {} in flight · next msg_id {}",
563 c.credits.available, c.credits.in_flight, c.credits.next_message_id
564 )?;
565 writeln!(
566 f,
567 " wire bytes: {} sent · {} received",
568 m.wire_bytes_sent, m.wire_bytes_received
569 )?;
570 writeln!(
571 f,
572 " responses: {} ok · {} wire-err · {} late · {} stray (sent: {}, caller-err: {})",
573 m.responses_routed_ok,
574 m.responses_routed_err,
575 m.responses_late_after_drop,
576 m.responses_stray,
577 m.requests_sent,
578 m.requests_returned_err,
579 )?;
580 writeln!(
581 f,
582 " protocol events: {} status-pending · {} unsolicited · {} compound chains · {} cancels",
583 m.status_pending_loops,
584 m.unsolicited_notifications_received,
585 m.compound_requests_sent,
586 m.explicit_cancels_sent,
587 )?;
588 writeln!(
589 f,
590 " errors: {} signature · {} decrypt · {} decompress · {} malformed · {} session-expired",
591 m.signature_failures,
592 m.decrypt_failures,
593 m.decompress_failures,
594 m.malformed_frames,
595 m.session_expired_events,
596 )?;
597 writeln!(
598 f,
599 " credit waits: {} parked · {} starved · {} response timeouts · {} send failures",
600 m.credit_waits, m.credit_starvations, m.response_timeouts, m.send_failures,
601 )?;
602 writeln!(
603 f,
604 " keepalive: {} probes · {} skipped · {} unanswered · {} deadline extensions",
605 m.keepalive_probes_sent,
606 m.keepalive_probes_skipped,
607 m.keepalive_failures,
608 m.response_deadline_extensions,
609 )?;
610 if m.long_poll_refreshes > 0 {
611 writeln!(f, " long polls: {} refreshed", m.long_poll_refreshes)?;
612 }
613 if m.reconnect_attempts > 0 {
614 writeln!(
615 f,
616 " reconnects: {} succeeded · {} failed · {} dials",
617 m.reconnects_succeeded, m.reconnects_failed, m.reconnect_attempts,
618 )?;
619 }
620 if c.disconnected {
621 writeln!(f, " status: DISCONNECTED")?;
622 }
623 Ok(())
624}
625
626fn fmt_signing(s: &SigningInfo) -> String {
627 match (s.active, s.algorithm) {
628 (true, Some(algo)) => format!("active ({:?})", algo),
629 (true, None) => "active".to_string(),
630 (false, _) => "inactive".to_string(),
631 }
632}
633
634fn fmt_encryption(e: &EncryptionInfo) -> String {
635 match (e.active, e.cipher) {
636 (true, Some(c)) => format!("active ({:?})", c),
637 (true, None) => "active".to_string(),
638 (false, _) => "inactive".to_string(),
639 }
640}
641
642fn fmt_compression(c: &CompressionInfo) -> String {
643 match (c.requested, c.negotiated) {
644 (true, true) => "active".to_string(),
645 (true, false) => "requested, not negotiated".to_string(),
646 (false, true) => "active (not requested)".to_string(),
647 (false, false) => "off".to_string(),
648 }
649}
650
651// ── M3: optional serde derives ───────────────────────────────────────────
652// Each diagnostics type carries `#[cfg_attr(feature = "serde", derive(Serialize))]`
653// directly on its definition (above). `Capabilities` has a manual `Serialize`
654// impl in `types/flags.rs` that emits the underlying u32 bits.
655
656#[cfg(test)]
657mod tests {
658 //! Per-counter unit tests for M1.
659 //!
660 //! Each test exercises one counter against a `MockTransport`, asserting
661 //! it ticks the expected number of times. The disjoint-partition test
662 //! at the bottom checks the four routing-outcome counters sum to the
663 //! total sub-frames the receiver routed.
664
665 use std::sync::Arc;
666 use std::time::Duration;
667
668 use crate::client::connection::Connection;
669 use crate::msg::echo::{EchoRequest, EchoResponse};
670 use crate::msg::header::Header;
671 use crate::pack::Pack;
672 use crate::transport::mock::MockTransport;
673 use crate::types::status::NtStatus;
674 use crate::types::{Command, MessageId};
675
676 /// Build a packed message (header + body) — mirrors `pack_message` in
677 /// `connection.rs`, kept inline to avoid widening that helper's
678 /// visibility just for tests.
679 fn pack(header: &Header, body: &dyn Pack) -> Vec<u8> {
680 let mut cursor = crate::pack::WriteCursor::with_capacity(64 + 16);
681 header.pack(&mut cursor);
682 body.pack(&mut cursor);
683 cursor.into_inner()
684 }
685
686 fn echo_response(msg_id: MessageId, status: NtStatus) -> Vec<u8> {
687 let mut h = Header::new_request(Command::Echo);
688 h.flags.set_response();
689 h.credits = 10;
690 h.message_id = msg_id;
691 h.status = status;
692 pack(&h, &EchoResponse)
693 }
694
695 fn echo_ok(msg_id: MessageId) -> Vec<u8> {
696 echo_response(msg_id, NtStatus::SUCCESS)
697 }
698
699 /// Wait until at least `n` messages have been recorded as sent on
700 /// the mock. Times out after 5 s.
701 async fn wait_for_sent(mock: &MockTransport, n: usize) {
702 let deadline = std::time::Instant::now() + Duration::from_secs(5);
703 while mock.sent_count() < n {
704 if std::time::Instant::now() > deadline {
705 panic!("expected {n} sent messages, got {}", mock.sent_count());
706 }
707 tokio::time::sleep(Duration::from_millis(10)).await;
708 }
709 }
710
711 /// A bare `Connection` over a mock transport, with auto-rewrite ON.
712 /// Mirrors the existing `execute_returns_correct_frame_for_sent_request`
713 /// setup but returns the mock so the test can queue / inspect.
714 fn fresh_conn() -> (Connection, Arc<MockTransport>) {
715 let mock = Arc::new(MockTransport::new());
716 mock.enable_auto_rewrite_msg_id();
717 let conn = Connection::from_transport(
718 Box::new(mock.clone()),
719 Box::new(mock.clone()),
720 "test-server",
721 );
722 // Stage the credit window a real connection would hold after
723 // NEGOTIATE / SESSION_SETUP / TREE_CONNECT; a fresh pool holds the
724 // single pre-NEGOTIATE credit, which no compound can afford.
725 conn.set_credits(512);
726 (conn, mock)
727 }
728
729 #[tokio::test(flavor = "multi_thread")]
730 async fn requests_sent_and_wire_bytes_sent_tick_for_one_execute() {
731 let (conn, mock) = fresh_conn();
732
733 let c = conn.clone();
734 let handle =
735 tokio::spawn(async move { c.execute(Command::Echo, &EchoRequest, None).await });
736
737 wait_for_sent(&mock, 1).await;
738 mock.queue_response(echo_ok(MessageId(0)));
739 handle.await.unwrap().unwrap();
740
741 let m = conn.metrics();
742 assert_eq!(m.requests_sent, 1, "one msg_id allocated → one request");
743 assert!(m.wire_bytes_sent > 0, "send wrote some bytes to the wire");
744 assert!(
745 m.wire_bytes_received > 0,
746 "receive read some bytes from the wire"
747 );
748 assert_eq!(m.responses_routed_ok, 1);
749 assert_eq!(m.responses_routed_err, 0);
750 assert_eq!(m.responses_late_after_drop, 0);
751 assert_eq!(m.responses_stray, 0);
752 assert_eq!(m.requests_returned_err, 0);
753
754 mock.close();
755 }
756
757 #[tokio::test(flavor = "multi_thread")]
758 async fn requests_sent_ticks_per_sub_op_in_compound_and_compound_chain_counted() {
759 use crate::client::connection::CompoundOp;
760
761 let (conn, mock) = fresh_conn();
762
763 let c = conn.clone();
764 let handle = tokio::spawn(async move {
765 let ops = vec![
766 CompoundOp::new(Command::Echo, &EchoRequest, None),
767 CompoundOp::new(Command::Echo, &EchoRequest, None),
768 CompoundOp::new(Command::Echo, &EchoRequest, None),
769 ];
770 c.execute_compound(&ops).await
771 });
772
773 wait_for_sent(&mock, 1).await;
774 // Three sub-frames → three responses. Auto-rewrite handles the
775 // msg_id pairing per sub-frame.
776 mock.queue_response(echo_ok(MessageId(0)));
777 mock.queue_response(echo_ok(MessageId(0)));
778 mock.queue_response(echo_ok(MessageId(0)));
779 handle.await.unwrap().unwrap();
780
781 let m = conn.metrics();
782 assert_eq!(m.requests_sent, 3, "three sub-ops → requests_sent += 3");
783 assert_eq!(m.compound_requests_sent, 1, "one compound chain");
784 assert_eq!(m.responses_routed_ok, 3);
785 assert_eq!(m.requests_returned_err, 0);
786
787 mock.close();
788 }
789
790 #[tokio::test(flavor = "multi_thread")]
791 async fn requests_returned_err_ticks_on_outer_err_to_completed_caller() {
792 let (conn, mock) = fresh_conn();
793
794 // Close before sending → execute returns Err(Disconnected) once the
795 // receiver task's transport-error branch fans to the waiter.
796 let c = conn.clone();
797 let handle =
798 tokio::spawn(async move { c.execute(Command::Echo, &EchoRequest, None).await });
799
800 wait_for_sent(&mock, 1).await;
801 mock.close();
802 let result = handle.await.unwrap();
803 assert!(result.is_err(), "execute should error after close");
804
805 // Receiver-task tear-down may take a beat to propagate; loop briefly.
806 let deadline = std::time::Instant::now() + Duration::from_secs(2);
807 while conn.metrics().requests_returned_err == 0 && std::time::Instant::now() < deadline {
808 tokio::time::sleep(Duration::from_millis(10)).await;
809 }
810
811 assert_eq!(conn.metrics().requests_returned_err, 1);
812 }
813
814 #[tokio::test(flavor = "multi_thread")]
815 async fn responses_late_after_drop_ticks_when_caller_dropped() {
816 let (conn, mock) = fresh_conn();
817
818 let c = conn.clone();
819 let handle =
820 tokio::spawn(async move { c.execute(Command::Echo, &EchoRequest, None).await });
821
822 wait_for_sent(&mock, 1).await;
823 // Drop the caller's future BEFORE the response arrives. The waiter
824 // is still in the map; the oneshot::Receiver gets dropped.
825 handle.abort();
826 let _ = handle.await; // observe the JoinError, don't unwrap
827
828 // Now queue the response. The receiver task finds the waiter,
829 // tries to send, sees the dropped Receiver, bumps
830 // responses_late_after_drop (and NOT responses_stray).
831 mock.queue_response(echo_ok(MessageId(0)));
832
833 // Wait until the counter ticks (the receiver task drives this).
834 let deadline = std::time::Instant::now() + Duration::from_secs(2);
835 while conn.metrics().responses_late_after_drop == 0 && std::time::Instant::now() < deadline
836 {
837 tokio::time::sleep(Duration::from_millis(10)).await;
838 }
839
840 let m = conn.metrics();
841 assert_eq!(m.responses_late_after_drop, 1, "caller-drop should tick");
842 assert_eq!(m.responses_stray, 0, "stray is for unregistered ids only");
843 assert_eq!(m.responses_routed_ok, 0);
844
845 mock.close();
846 }
847
848 #[tokio::test(flavor = "multi_thread")]
849 async fn responses_stray_ticks_for_unregistered_msg_id() {
850 let (conn, mock) = fresh_conn();
851
852 // Don't call execute at all. Queue a response for a msg_id no one
853 // allocated. Auto-rewrite would normally pair with a sent msg_id,
854 // but there is none — so we use the *non*-auto path: build a
855 // response with an explicit non-zero msg_id and drop into the
856 // queue. Auto-rewrite's "keep non-zero, still consume one id"
857 // logic would block on `send_notify` forever. So disable it
858 // first by NOT enabling on a fresh second mock.
859 let _ = (conn, mock); // discarded — we use a non-auto-rewrite mock below
860 let plain_mock = Arc::new(MockTransport::new());
861 let conn = Connection::from_transport(
862 Box::new(plain_mock.clone()),
863 Box::new(plain_mock.clone()),
864 "test-server",
865 );
866
867 plain_mock.queue_response(echo_ok(MessageId(999_999)));
868
869 // Poll the counter — `pending_responses() == 0` only proves the
870 // transport drained, not that the receiver finished processing the
871 // frame and bumped `responses_stray`. The latter is the actual
872 // signal we're testing.
873 let deadline = std::time::Instant::now() + Duration::from_secs(2);
874 while conn.metrics().responses_stray == 0 && std::time::Instant::now() < deadline {
875 tokio::time::sleep(Duration::from_millis(10)).await;
876 }
877
878 let m = conn.metrics();
879 assert_eq!(m.responses_stray, 1);
880 assert_eq!(m.responses_late_after_drop, 0);
881 assert_eq!(m.responses_routed_ok, 0);
882
883 plain_mock.close();
884 }
885
886 #[tokio::test(flavor = "multi_thread")]
887 async fn unsolicited_notifications_received_ticks_for_unsolicited_msg_id() {
888 let mock = Arc::new(MockTransport::new());
889 let conn = Connection::from_transport(
890 Box::new(mock.clone()),
891 Box::new(mock.clone()),
892 "test-server",
893 );
894
895 let mut h = Header::new_request(Command::OplockBreak);
896 h.flags.set_response();
897 h.credits = 0;
898 h.message_id = MessageId::UNSOLICITED;
899 let frame = pack(&h, &EchoResponse); // body shape doesn't matter; it's skipped
900 mock.queue_response(frame);
901
902 // Wait for consumption.
903 let deadline = std::time::Instant::now() + Duration::from_secs(2);
904 while conn.metrics().unsolicited_notifications_received == 0
905 && std::time::Instant::now() < deadline
906 {
907 tokio::time::sleep(Duration::from_millis(10)).await;
908 }
909
910 assert_eq!(conn.metrics().unsolicited_notifications_received, 1);
911 // UNSOLICITED is skipped — it does NOT tick the routing counters.
912 assert_eq!(conn.metrics().responses_routed_ok, 0);
913 assert_eq!(conn.metrics().responses_stray, 0);
914
915 mock.close();
916 }
917
918 #[tokio::test(flavor = "multi_thread")]
919 async fn status_pending_loops_ticks_for_interim_pending_then_final() {
920 // Don't use auto_rewrite — we need TWO responses paired with ONE sent
921 // msg_id. The first execute on a fresh connection always allocates
922 // msg_id=0, so we can hardcode that in both responses.
923 let mock = Arc::new(MockTransport::new());
924 let conn = Connection::from_transport(
925 Box::new(mock.clone()),
926 Box::new(mock.clone()),
927 "test-server",
928 );
929
930 let c = conn.clone();
931 let handle =
932 tokio::spawn(async move { c.execute(Command::Echo, &EchoRequest, None).await });
933
934 wait_for_sent(&mock, 1).await;
935 // Interim STATUS_PENDING with msg_id=0, then final SUCCESS with msg_id=0.
936 mock.queue_response(echo_response(MessageId(0), NtStatus::PENDING));
937 mock.queue_response(echo_response(MessageId(0), NtStatus::SUCCESS));
938
939 handle.await.unwrap().unwrap();
940
941 let m = conn.metrics();
942 assert_eq!(m.status_pending_loops, 1, "one interim PENDING observed");
943 assert_eq!(m.responses_routed_ok, 1, "one final response routed");
944
945 mock.close();
946 }
947
948 #[tokio::test(flavor = "multi_thread")]
949 async fn session_expired_events_ticks_and_also_routes_err() {
950 let mock = Arc::new(MockTransport::new());
951 let conn = Connection::from_transport(
952 Box::new(mock.clone()),
953 Box::new(mock.clone()),
954 "test-server",
955 );
956
957 let c = conn.clone();
958 let handle =
959 tokio::spawn(async move { c.execute(Command::Echo, &EchoRequest, None).await });
960
961 wait_for_sent(&mock, 1).await;
962 mock.queue_response(echo_response(
963 MessageId(0),
964 NtStatus::NETWORK_SESSION_EXPIRED,
965 ));
966
967 let result = handle.await.unwrap();
968 assert!(result.is_err(), "session-expired should surface as Err");
969
970 let m = conn.metrics();
971 assert_eq!(m.session_expired_events, 1);
972 assert_eq!(
973 m.responses_routed_err, 1,
974 "session_expired_events is a subset of responses_routed_err"
975 );
976 assert_eq!(m.responses_routed_ok, 0);
977 assert_eq!(
978 m.requests_returned_err, 1,
979 "caller polled to completion and got Err"
980 );
981
982 mock.close();
983 }
984
985 #[tokio::test(flavor = "multi_thread")]
986 async fn explicit_cancels_sent_ticks_on_send_cancel() {
987 let mock = Arc::new(MockTransport::new());
988 let conn = Connection::from_transport(
989 Box::new(mock.clone()),
990 Box::new(mock.clone()),
991 "test-server",
992 );
993
994 conn.send_cancel(MessageId(42), None).await.unwrap();
995
996 assert_eq!(conn.metrics().explicit_cancels_sent, 1);
997 // CANCEL does NOT allocate a msg_id — it reuses the original.
998 assert_eq!(conn.metrics().requests_sent, 0);
999
1000 mock.close();
1001 }
1002
1003 #[tokio::test(flavor = "multi_thread")]
1004 async fn dispatch_path_is_counted() {
1005 // `dispatch` is the watcher's pre-arm path — funnel-counted via
1006 // allocate_msg_id, same as `execute`.
1007 let (conn, mock) = fresh_conn();
1008
1009 let c = conn.clone();
1010 let handle =
1011 tokio::spawn(async move { c.dispatch(Command::Echo, &EchoRequest, None).await });
1012
1013 wait_for_sent(&mock, 1).await;
1014 let mut rx = handle.await.unwrap().unwrap();
1015 mock.queue_response(echo_ok(MessageId(0)));
1016 // Drive the response so the receiver processes it (and the awaiter
1017 // sees the result).
1018 let _ = rx.recv().await.unwrap();
1019
1020 let m = conn.metrics();
1021 assert_eq!(m.requests_sent, 1, "dispatch funnel-counts via allocate");
1022 assert!(m.wire_bytes_sent > 0);
1023 assert_eq!(m.responses_routed_ok, 1);
1024
1025 mock.close();
1026 }
1027
1028 #[tokio::test(flavor = "multi_thread")]
1029 async fn counters_survive_teardown() {
1030 let (conn, mock) = fresh_conn();
1031
1032 let c = conn.clone();
1033 let handle =
1034 tokio::spawn(async move { c.execute(Command::Echo, &EchoRequest, None).await });
1035
1036 wait_for_sent(&mock, 1).await;
1037 mock.queue_response(echo_ok(MessageId(0)));
1038 handle.await.unwrap().unwrap();
1039
1040 let before = conn.metrics();
1041 assert_eq!(before.responses_routed_ok, 1);
1042
1043 // Tear down.
1044 mock.close();
1045 // Give the receiver task a tick to observe Err and fan.
1046 tokio::time::sleep(Duration::from_millis(50)).await;
1047
1048 // Counters still readable.
1049 let after = conn.metrics();
1050 assert_eq!(after.responses_routed_ok, before.responses_routed_ok);
1051 assert_eq!(after.requests_sent, before.requests_sent);
1052 }
1053
1054 // ── M2 / M3: full Diagnostics tree + Display + serde ──────────────
1055
1056 fn fake_client(conn: Connection, session: crate::client::Session) -> crate::SmbClient {
1057 let cfg = crate::ClientConfig {
1058 addr: conn.server_name().to_string(),
1059 timeout: Duration::from_secs(30),
1060 username: String::new(),
1061 password: String::new(),
1062 domain: String::new(),
1063 auto_reconnect: false,
1064 compression: true,
1065 dfs_enabled: true,
1066 dfs_target_overrides: std::collections::HashMap::new(),
1067 };
1068 crate::SmbClient::from_parts(cfg, conn, session)
1069 }
1070
1071 fn fake_session() -> crate::client::Session {
1072 crate::client::Session {
1073 session_id: crate::types::SessionId(0x1234_5678_9ABC_DEF0),
1074 signing_key: vec![],
1075 encryption_key: None,
1076 decryption_key: None,
1077 signing_algorithm: crate::crypto::signing::SigningAlgorithm::HmacSha256,
1078 should_sign: false,
1079 should_encrypt: false,
1080 }
1081 }
1082
1083 #[tokio::test(flavor = "multi_thread")]
1084 async fn display_contains_key_labels() {
1085 let (conn, mock) = fresh_conn();
1086 let c = conn.clone();
1087 let handle =
1088 tokio::spawn(async move { c.execute(Command::Echo, &EchoRequest, None).await });
1089 wait_for_sent(&mock, 1).await;
1090 mock.queue_response(echo_ok(MessageId(0)));
1091 handle.await.unwrap().unwrap();
1092
1093 let client = fake_client(conn, fake_session());
1094 let d = client.diagnostics();
1095 let text = format!("{}", d);
1096 for label in [
1097 "SMB client",
1098 "test-server",
1099 "credits:",
1100 "wire bytes:",
1101 "responses:",
1102 "protocol events:",
1103 "errors:",
1104 "DFS extra connections",
1105 ] {
1106 assert!(
1107 text.contains(label),
1108 "Display missing {label:?} in:\n{text}"
1109 );
1110 }
1111
1112 mock.close();
1113 }
1114
1115 #[cfg(feature = "serde")]
1116 #[tokio::test(flavor = "multi_thread")]
1117 async fn serde_round_trip_into_json_value() {
1118 let (conn, mock) = fresh_conn();
1119 let c = conn.clone();
1120 let handle =
1121 tokio::spawn(async move { c.execute(Command::Echo, &EchoRequest, None).await });
1122 wait_for_sent(&mock, 1).await;
1123 mock.queue_response(echo_ok(MessageId(0)));
1124 handle.await.unwrap().unwrap();
1125
1126 let client = fake_client(conn, fake_session());
1127 let d = client.diagnostics();
1128
1129 let json = serde_json::to_string(&d).expect("serialize");
1130 let v: serde_json::Value = serde_json::from_str(&json).expect("re-parse");
1131
1132 assert_eq!(v["client"]["primary_server"], "test-server", "json: {json}");
1133 assert_eq!(v["primary"]["server"], "test-server");
1134 assert_eq!(v["primary"]["metrics"]["requests_sent"], 1);
1135 assert_eq!(v["primary"]["metrics"]["responses_routed_ok"], 1);
1136 assert!(v["primary"]["disconnected"].is_boolean());
1137 assert!(v["primary"]["credits"]["available"].is_number());
1138 // SessionId is transparent: bare integer, not `{"0": ...}`.
1139 assert_eq!(
1140 v["primary"]["session"]["session_id"], 0x1234_5678_9ABC_DEF0_u64,
1141 "json: {json}"
1142 );
1143
1144 mock.close();
1145 }
1146
1147 #[tokio::test(flavor = "multi_thread")]
1148 async fn snapshot_releases_all_locks_before_returning() {
1149 // Regression test: the snapshot promises it holds each lock only
1150 // briefly and releases it before returning. Try_lock'ing after
1151 // the snapshot call must succeed.
1152 let (conn, mock) = fresh_conn();
1153 let _d = conn.diagnostics();
1154 // We can't reach `inner` from here without crate access; this test
1155 // lives in-crate so it CAN. The diagnostics module is in
1156 // `client/`, the connection internals are `pub(crate)`-shaped.
1157 // If a future refactor breaks lock ordering, the in-flight test
1158 // above catches it indirectly; this test pins the "no held lock"
1159 // invariant cheaply.
1160 for _ in 0..100 {
1161 let _ = conn.diagnostics();
1162 }
1163 mock.close();
1164 }
1165
1166 #[tokio::test(flavor = "multi_thread")]
1167 async fn routing_partition_is_disjoint_and_complete() {
1168 // 3 sent, 1 normal, 1 caller-drop, 1 stray on top.
1169 let mock = Arc::new(MockTransport::new());
1170 // Plain mode so we can fully control msg_ids.
1171 let conn = Connection::from_transport(
1172 Box::new(mock.clone()),
1173 Box::new(mock.clone()),
1174 "test-server",
1175 );
1176 conn.set_credits(512);
1177
1178 // Op A: send, will succeed.
1179 let c1 = conn.clone();
1180 let h1 = tokio::spawn(async move { c1.execute(Command::Echo, &EchoRequest, None).await });
1181 wait_for_sent(&mock, 1).await; // msg_id 0
1182
1183 // Op B: send, then abort (caller drop).
1184 let c2 = conn.clone();
1185 let h2 = tokio::spawn(async move { c2.execute(Command::Echo, &EchoRequest, None).await });
1186 wait_for_sent(&mock, 2).await; // msg_id 1
1187
1188 // Op A response.
1189 mock.queue_response(echo_ok(MessageId(0)));
1190 h1.await.unwrap().unwrap();
1191
1192 // Drop op B then queue its response.
1193 h2.abort();
1194 let _ = h2.await;
1195 mock.queue_response(echo_ok(MessageId(1)));
1196
1197 // Stray frame for a msg_id no one allocated.
1198 mock.queue_response(echo_ok(MessageId(999_999)));
1199
1200 // Poll the actual signals — `pending_responses() == 0` only proves
1201 // the transport drained, not that the receiver finished bumping the
1202 // counters. `responses_routed_ok` is already 1 (h1.await guarantees
1203 // it after the receiver_loop fix); `late_after_drop` and
1204 // `responses_stray` each tick once when their frame is processed.
1205 let deadline = std::time::Instant::now() + Duration::from_secs(2);
1206 while (conn.metrics().responses_late_after_drop == 0 || conn.metrics().responses_stray == 0)
1207 && std::time::Instant::now() < deadline
1208 {
1209 tokio::time::sleep(Duration::from_millis(10)).await;
1210 }
1211
1212 let m = conn.metrics();
1213 assert_eq!(m.responses_routed_ok, 1);
1214 assert_eq!(m.responses_routed_err, 0);
1215 assert_eq!(m.responses_late_after_drop, 1);
1216 assert_eq!(m.responses_stray, 1);
1217
1218 // Partition: routed_ok + routed_err + late + stray ==
1219 // total sub-frames the receiver dispatched (3 here).
1220 assert_eq!(
1221 m.responses_routed_ok
1222 + m.responses_routed_err
1223 + m.responses_late_after_drop
1224 + m.responses_stray,
1225 3
1226 );
1227
1228 mock.close();
1229 }
1230}