Skip to main content

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}