Skip to main content

spvirit_codec/
spvirit_state.rs

1//! PVA Connection State Tracker
2//!
3//! Tracks channel mappings (CID ↔ SID ↔ PV name) and operation states
4//! to enable full decoding of MONITOR packets.
5
6use std::collections::HashMap;
7use std::collections::HashSet;
8use std::collections::VecDeque;
9use std::net::{IpAddr, SocketAddr};
10use std::time::{Duration, Instant};
11use tracing::debug;
12
13use crate::spvd_decode::StructureDesc;
14
15/// Configuration for the PVA state tracker
16#[derive(Debug, Clone)]
17pub struct PvaStateConfig {
18    /// Maximum number of channels to track (default: 40000)
19    pub max_channels: usize,
20    /// Time-to-live for channel entries (default: 5 minutes)
21    pub channel_ttl: Duration,
22    /// Maximum number of operations to track per connection
23    pub max_operations: usize,
24    /// Maximum update timestamps kept per connection for rate calculation (default: 10000)
25    pub max_update_rate: usize,
26    /// Ceiling on tracked state in bytes, as reported by [`PvaStateTracker::memory_estimate`].
27    ///
28    /// When the estimate exceeds this, `cleanup_expired` sheds state in
29    /// priority order -- debug affordances first, decoded introspection last
30    /// -- so that a long-running passive observer accumulates coverage
31    /// without growing without bound. `0` disables the ceiling.
32    pub max_memory_bytes: usize,
33    /// Hard cap on SEARCH name-cache entries, enforced independently of the
34    /// memory ceiling so the caches cannot dominate a small budget.
35    pub max_search_cache_entries: usize,
36}
37
38impl Default for PvaStateConfig {
39    fn default() -> Self {
40        Self {
41            max_channels: 40_000,
42            channel_ttl: Duration::from_secs(5 * 60), // 5 minutes
43            max_operations: 10_000,
44            max_update_rate: 10_000,
45            max_memory_bytes: DEFAULT_MAX_MEMORY_BYTES,
46            max_search_cache_entries: DEFAULT_MAX_SEARCH_CACHE_ENTRIES,
47        }
48    }
49}
50
51/// 1 GiB. Chosen to be generous enough that a busy facility never sheds
52/// introspection in practice, while still bounding a multi-day passive run.
53pub const DEFAULT_MAX_MEMORY_BYTES: usize = 1024 * 1024 * 1024;
54/// Enough for a large facility's PV set several times over.
55pub const DEFAULT_MAX_SEARCH_CACHE_ENTRIES: usize = 200_000;
56
57impl PvaStateConfig {
58    pub fn new(max_channels: usize, ttl_secs: u64) -> Self {
59        Self {
60            max_channels,
61            channel_ttl: Duration::from_secs(ttl_secs),
62            max_operations: 10_000,
63            max_update_rate: 10_000,
64            max_memory_bytes: DEFAULT_MAX_MEMORY_BYTES,
65            max_search_cache_entries: DEFAULT_MAX_SEARCH_CACHE_ENTRIES,
66        }
67    }
68
69    pub fn with_max_update_rate(mut self, max_update_rate: usize) -> Self {
70        self.max_update_rate = max_update_rate;
71        self
72    }
73
74    /// Set the memory ceiling. `0` disables shedding entirely.
75    pub fn with_max_memory_bytes(mut self, max_memory_bytes: usize) -> Self {
76        self.max_memory_bytes = max_memory_bytes;
77        self
78    }
79
80    /// Set the per-connection operation cap.
81    pub fn with_max_operations(mut self, max_operations: usize) -> Self {
82        self.max_operations = max_operations;
83        self
84    }
85}
86
87/// Heap bytes held by a `VecDeque<String>` ring, contents included.
88fn ring_bytes(ring: &VecDeque<String>) -> usize {
89    ring.capacity() * std::mem::size_of::<String>()
90        + ring.iter().map(String::capacity).sum::<usize>()
91}
92
93/// Heap bytes held by a `VecDeque<Instant>`.
94fn instants_bytes(times: &VecDeque<Instant>) -> usize {
95    times.capacity() * std::mem::size_of::<Instant>()
96}
97
98/// Approximate bytes a hashbrown table occupies for `n` slots of `(K, V)`.
99/// One control byte per slot on top of the key/value pair.
100fn table_bytes<K, V>(capacity: usize) -> usize {
101    capacity * (std::mem::size_of::<K>() + std::mem::size_of::<V>() + 1)
102}
103
104/// Unique key for a TCP connection (canonical - order independent)
105#[derive(Debug, Clone, Hash, PartialEq, Eq)]
106pub struct ConnectionKey {
107    /// Lower address (lexicographically sorted for consistency)
108    pub addr_a: SocketAddr,
109    /// Higher address
110    pub addr_b: SocketAddr,
111}
112
113impl ConnectionKey {
114    /// Create a canonical connection key (order independent)
115    pub fn new(addr1: SocketAddr, addr2: SocketAddr) -> Self {
116        // Always store in sorted order for consistent hashing
117        if addr1 <= addr2 {
118            Self {
119                addr_a: addr1,
120                addr_b: addr2,
121            }
122        } else {
123            Self {
124                addr_a: addr2,
125                addr_b: addr1,
126            }
127        }
128    }
129
130    /// Create from IP strings and ports (convenience method)
131    /// Order of arguments doesn't matter - will be canonicalized
132    pub fn from_parts(ip1: &str, port1: u16, ip2: &str, port2: u16) -> Option<Self> {
133        let addr1: SocketAddr = format!("{}:{}", ip1, port1).parse().ok()?;
134        let addr2: SocketAddr = format!("{}:{}", ip2, port2).parse().ok()?;
135        Some(Self::new(addr1, addr2))
136    }
137}
138
139/// Information about a channel (PV)
140#[derive(Debug, Clone)]
141pub struct ChannelInfo {
142    /// PV name
143    pub pv_name: String,
144    /// Client Channel ID
145    pub cid: u32,
146    /// Server Channel ID (assigned by server in CREATE_CHANNEL response)
147    pub sid: Option<u32>,
148    /// When this channel was created/last accessed
149    pub last_seen: Instant,
150    /// Whether we saw the full CREATE_CHANNEL exchange
151    pub fully_established: bool,
152    pub update_times: VecDeque<Instant>,
153    pub recent_messages: VecDeque<String>,
154}
155
156impl ChannelInfo {
157    pub fn new_pending(cid: u32, pv_name: String) -> Self {
158        Self {
159            pv_name,
160            cid,
161            sid: None,
162            last_seen: Instant::now(),
163            fully_established: false,
164            update_times: VecDeque::new(),
165            recent_messages: VecDeque::new(),
166        }
167    }
168
169    pub fn touch(&mut self) {
170        self.last_seen = Instant::now();
171    }
172
173    pub fn is_expired(&self, ttl: Duration) -> bool {
174        self.last_seen.elapsed() > ttl
175    }
176
177    /// Bytes this channel owns on the heap, excluding the struct itself
178    /// (its container accounts for that).
179    pub fn heap_bytes(&self) -> usize {
180        self.pv_name.capacity()
181            + instants_bytes(&self.update_times)
182            + ring_bytes(&self.recent_messages)
183    }
184}
185
186/// State for an active operation (GET/PUT/MONITOR etc.)
187#[derive(Debug, Clone)]
188pub struct OperationState {
189    /// Server channel ID this operation is on
190    pub sid: u32,
191    /// Operation ID
192    pub ioid: u32,
193    /// Command type (10=GET, 11=PUT, 13=MONITOR, etc.)
194    pub command: u8,
195    /// PV name (resolved from channel state)
196    pub pv_name: Option<String>,
197    /// Field description from INIT response (parsed introspection)
198    pub field_desc: Option<StructureDesc>,
199    /// Heap bytes held by `field_desc`, cached at assignment.
200    ///
201    /// The introspection tree is the one large term in the memory estimate
202    /// whose measurement is recursive, so it is walked once when the INIT
203    /// response lands rather than on every accounting pass.
204    field_desc_bytes: usize,
205    /// Whether INIT phase completed
206    pub initialized: bool,
207    /// Last activity
208    pub last_seen: Instant,
209    pub update_times: VecDeque<Instant>,
210    pub recent_messages: VecDeque<String>,
211}
212
213impl OperationState {
214    pub fn new(sid: u32, ioid: u32, command: u8, pv_name: Option<String>) -> Self {
215        Self {
216            sid,
217            ioid,
218            command,
219            pv_name,
220            field_desc: None,
221            field_desc_bytes: 0,
222            initialized: false,
223            last_seen: Instant::now(),
224            update_times: VecDeque::new(),
225            recent_messages: VecDeque::new(),
226        }
227    }
228
229    pub fn touch(&mut self) {
230        self.last_seen = Instant::now();
231    }
232
233    pub fn is_expired(&self, ttl: Duration) -> bool {
234        self.last_seen.elapsed() > ttl
235    }
236
237    /// Attach introspection and cache its measured size.
238    pub fn set_field_desc(&mut self, field_desc: Option<StructureDesc>) {
239        self.field_desc_bytes = field_desc.as_ref().map_or(0, |d| d.heap_size());
240        self.field_desc = field_desc;
241    }
242
243    /// Heap bytes held by this operation's introspection, if any.
244    pub fn field_desc_bytes(&self) -> usize {
245        self.field_desc_bytes
246    }
247
248    /// True when this operation was auto-created from mid-stream traffic and
249    /// carries nothing the decoder can use: no completed INIT, no
250    /// introspection. Shed first under memory pressure.
251    pub fn is_placeholder(&self) -> bool {
252        !self.initialized && self.field_desc.is_none()
253    }
254
255    /// Bytes this operation owns on the heap, excluding the struct itself.
256    pub fn heap_bytes(&self) -> usize {
257        self.pv_name.as_ref().map_or(0, |s| s.capacity())
258            + self.field_desc_bytes
259            + instants_bytes(&self.update_times)
260            + ring_bytes(&self.recent_messages)
261    }
262}
263
264/// Per-connection state
265#[derive(Debug)]
266pub struct ConnectionState {
267    /// Channels indexed by Client ID
268    pub channels_by_cid: HashMap<u32, ChannelInfo>,
269    /// Server ID → Client ID mapping
270    pub sid_to_cid: HashMap<u32, u32>,
271    /// Operations indexed by IOID
272    pub operations: HashMap<u32, OperationState>,
273    /// Byte order for this connection (true = big endian)
274    pub is_be: bool,
275    /// Last activity on this connection
276    pub last_seen: Instant,
277    pub update_times: VecDeque<Instant>,
278    pub recent_messages: VecDeque<String>,
279}
280
281impl ConnectionState {
282    pub fn new() -> Self {
283        Self {
284            channels_by_cid: HashMap::new(),
285            sid_to_cid: HashMap::new(),
286            operations: HashMap::new(),
287            is_be: false, // Default to little endian
288            last_seen: Instant::now(),
289            update_times: VecDeque::new(),
290            recent_messages: VecDeque::new(),
291        }
292    }
293
294    pub fn touch(&mut self) {
295        self.last_seen = Instant::now();
296    }
297
298    /// Get channel info by Server ID
299    pub fn get_channel_by_sid(&self, sid: u32) -> Option<&ChannelInfo> {
300        self.sid_to_cid
301            .get(&sid)
302            .and_then(|cid| self.channels_by_cid.get(cid))
303    }
304
305    /// Get mutable channel info by Server ID
306    pub fn get_channel_by_sid_mut(&mut self, sid: u32) -> Option<&mut ChannelInfo> {
307        if let Some(&cid) = self.sid_to_cid.get(&sid) {
308            self.channels_by_cid.get_mut(&cid)
309        } else {
310            None
311        }
312    }
313
314    /// Get PV name for a Server ID
315    pub fn get_pv_name_by_sid(&self, sid: u32) -> Option<&str> {
316        self.get_channel_by_sid(sid).map(|ch| ch.pv_name.as_str())
317    }
318
319    /// Get PV name for an operation IOID
320    pub fn get_pv_name_by_ioid(&self, ioid: u32) -> Option<&str> {
321        self.operations
322            .get(&ioid)
323            .and_then(|op| op.pv_name.as_deref())
324    }
325
326    /// Bytes this connection owns on the heap, excluding the struct itself:
327    /// its three tables, everything inside them, and its own rings.
328    pub fn heap_bytes(&self) -> usize {
329        table_bytes::<u32, ChannelInfo>(self.channels_by_cid.capacity())
330            + table_bytes::<u32, OperationState>(self.operations.capacity())
331            + table_bytes::<u32, u32>(self.sid_to_cid.capacity())
332            + self
333                .channels_by_cid
334                .values()
335                .map(ChannelInfo::heap_bytes)
336                .sum::<usize>()
337            + self
338                .operations
339                .values()
340                .map(OperationState::heap_bytes)
341                .sum::<usize>()
342            + instants_bytes(&self.update_times)
343            + ring_bytes(&self.recent_messages)
344    }
345}
346
347impl Default for ConnectionState {
348    fn default() -> Self {
349        Self::new()
350    }
351}
352
353/// Global PVA state tracker across all connections
354#[derive(Debug)]
355pub struct PvaStateTracker {
356    /// Configuration
357    config: PvaStateConfig,
358    /// Per-connection state
359    connections: HashMap<ConnectionKey, ConnectionState>,
360    /// Total channel count across all connections (for limit enforcement)
361    total_channels: usize,
362    /// Statistics
363    pub stats: PvaStateStats,
364    /// (client_ip, CID) → PV name cache from SEARCH messages
365    /// Scoped by client IP to prevent CID collisions across different clients
366    search_cache: HashMap<(IpAddr, u32), String>,
367    /// Flat CID → PV name fallback (last-writer-wins, used when client IP is unknown)
368    search_cache_flat: HashMap<u32, String>,
369}
370
371/// Statistics for monitoring
372#[derive(Debug, Default, Clone)]
373pub struct PvaStateStats {
374    pub channels_created: u64,
375    pub channels_destroyed: u64,
376    pub channels_expired: u64,
377    pub channels_evicted: u64,
378    pub operations_created: u64,
379    pub operations_completed: u64,
380    pub create_channel_requests: u64,
381    pub create_channel_responses: u64,
382    pub search_responses_resolved: u64,
383    pub search_cache_entries: u64,
384    pub search_retroactive_resolves: u64,
385    /// PVA messages with is_server=false (sent by client)
386    pub client_messages: u64,
387    /// PVA messages with is_server=true (sent by server)
388    pub server_messages: u64,
389    /// Most recent value of [`PvaStateTracker::memory_estimate`], refreshed
390    /// by `cleanup_expired` so exporters need not re-walk the state.
391    pub memory_bytes: u64,
392    /// Operations aged out on their own `last_seen`, independently of any
393    /// channel. This is what reclaims mid-stream placeholders.
394    pub operations_expired: u64,
395    /// Placeholder operations discarded to stay inside the memory ceiling.
396    pub shed_placeholder_ops: u64,
397    /// Debug message rings cleared to stay inside the memory ceiling.
398    pub shed_message_rings: u64,
399    /// SEARCH cache entries discarded, by cap or by memory ceiling.
400    pub shed_search_entries: u64,
401    /// Channels evicted specifically to stay inside the memory ceiling.
402    /// These carried introspection, so this rising means coverage is being lost.
403    pub shed_channels: u64,
404}
405
406#[derive(Debug, Clone)]
407pub struct ConnectionSnapshot {
408    pub addr_a: SocketAddr,
409    pub addr_b: SocketAddr,
410    pub channel_count: usize,
411    pub operation_count: usize,
412    pub last_seen: Duration,
413    pub pv_names: Vec<String>,
414    pub updates_per_sec: f64,
415    pub recent_messages: Vec<String>,
416    pub mid_stream: bool,
417    pub is_beacon: bool,
418    pub is_broadcast: bool,
419}
420
421#[derive(Debug, Clone)]
422pub struct ChannelSnapshot {
423    pub addr_a: SocketAddr,
424    pub addr_b: SocketAddr,
425    pub cid: u32,
426    pub sid: Option<u32>,
427    pub pv_name: String,
428    pub last_seen: Duration,
429    pub updates_per_sec: f64,
430    pub recent_messages: Vec<String>,
431    pub mid_stream: bool,
432    pub is_beacon: bool,
433    pub is_broadcast: bool,
434}
435
436impl PvaStateTracker {
437    fn is_broadcast_addr(addr: &SocketAddr) -> bool {
438        match addr.ip() {
439            std::net::IpAddr::V4(v4) => {
440                if v4.is_broadcast() {
441                    return true;
442                }
443                v4.octets()[3] == 255
444            }
445            std::net::IpAddr::V6(v6) => {
446                // IPv6 has no broadcast; treat multicast as equivalent for PVA
447                v6.is_multicast()
448            }
449        }
450    }
451    pub fn new(config: PvaStateConfig) -> Self {
452        Self {
453            config,
454            connections: HashMap::new(),
455            total_channels: 0,
456            stats: PvaStateStats::default(),
457            search_cache: HashMap::new(),
458            search_cache_flat: HashMap::new(),
459        }
460    }
461
462    pub fn with_defaults() -> Self {
463        Self::new(PvaStateConfig::default())
464    }
465
466    /// Get or create connection state
467    fn get_or_create_connection(&mut self, key: &ConnectionKey) -> &mut ConnectionState {
468        if !self.connections.contains_key(key) {
469            self.connections.insert(key.clone(), ConnectionState::new());
470        }
471        self.connections.get_mut(key).unwrap()
472    }
473
474    /// Get connection state (read-only)
475    pub fn get_connection(&self, key: &ConnectionKey) -> Option<&ConnectionState> {
476        self.connections.get(key)
477    }
478
479    /// Get PV name by SID for a connection
480    pub fn get_pv_name_by_sid(&self, conn_key: &ConnectionKey, sid: u32) -> Option<String> {
481        self.connections
482            .get(conn_key)
483            .and_then(|conn| conn.get_pv_name_by_sid(sid))
484            .map(|s| s.to_string())
485    }
486
487    /// Handle CREATE_CHANNEL request (client → server)
488    /// Called when we see cmd=7 from client with CID and PV name
489    pub fn on_create_channel_request(
490        &mut self,
491        conn_key: &ConnectionKey,
492        cid: u32,
493        pv_name: String,
494    ) {
495        self.stats.create_channel_requests += 1;
496
497        // Also cache in search_cache so it's available as fallback
498        // Extract client IP from connection key (client is the one sending the request)
499        let client_ip = conn_key.addr_a.ip(); // either side works as flat fallback
500        self.search_cache.insert((client_ip, cid), pv_name.clone());
501        self.search_cache_flat.insert(cid, pv_name.clone());
502
503        // Check channel limit
504        if self.total_channels >= self.config.max_channels {
505            self.evict_oldest_channels(100); // Evict 100 oldest
506        }
507
508        let conn = self.get_or_create_connection(conn_key);
509        conn.touch();
510
511        // Only add if not already present
512        if !conn.channels_by_cid.contains_key(&cid) {
513            conn.channels_by_cid
514                .insert(cid, ChannelInfo::new_pending(cid, pv_name));
515            self.total_channels += 1;
516            self.stats.channels_created += 1;
517            debug!("CREATE_CHANNEL request: cid={}", cid);
518        }
519    }
520
521    /// Handle CREATE_CHANNEL response (server → client)
522    /// Called when we see cmd=7 from server with CID and SID
523    pub fn on_create_channel_response(&mut self, conn_key: &ConnectionKey, cid: u32, sid: u32) {
524        self.stats.create_channel_responses += 1;
525
526        // Look up search cache BEFORE borrowing self mutably via get_or_create_connection
527        // Try scoped cache first (both sides of the connection key), then flat fallback
528        let cached_pv_name = self
529            .search_cache
530            .get(&(conn_key.addr_a.ip(), cid))
531            .or_else(|| self.search_cache.get(&(conn_key.addr_b.ip(), cid)))
532            .or_else(|| self.search_cache_flat.get(&cid))
533            .cloned();
534
535        let conn = self.get_or_create_connection(conn_key);
536        conn.touch();
537
538        if let Some(channel) = conn.channels_by_cid.get_mut(&cid) {
539            channel.sid = Some(sid);
540            channel.fully_established = true;
541            channel.touch();
542            conn.sid_to_cid.insert(sid, cid);
543            debug!(
544                "CREATE_CHANNEL response: cid={}, sid={}, pv={}",
545                cid, sid, channel.pv_name
546            );
547        } else {
548            // We missed the request - try search cache first, then create placeholder
549            let pv_name = cached_pv_name.unwrap_or_else(|| format!("<unknown:cid={}>", cid));
550            let is_resolved = !pv_name.starts_with("<unknown");
551            debug!(
552                "CREATE_CHANNEL response without request: cid={}, sid={}, resolved={}",
553                cid, sid, is_resolved
554            );
555            let mut channel = ChannelInfo::new_pending(cid, pv_name);
556            channel.sid = Some(sid);
557            channel.fully_established = is_resolved;
558            conn.channels_by_cid.insert(cid, channel);
559            conn.sid_to_cid.insert(sid, cid);
560            self.total_channels += 1;
561        }
562    }
563
564    /// Handle DESTROY_CHANNEL (cmd=8)
565    pub fn on_destroy_channel(&mut self, conn_key: &ConnectionKey, cid: u32, sid: u32) {
566        if let Some(conn) = self.connections.get_mut(conn_key) {
567            conn.touch();
568
569            // Remove by CID
570            if conn.channels_by_cid.remove(&cid).is_some() {
571                self.total_channels = self.total_channels.saturating_sub(1);
572                self.stats.channels_destroyed += 1;
573            }
574
575            // Remove SID mapping
576            conn.sid_to_cid.remove(&sid);
577
578            // Remove any operations on this channel
579            conn.operations.retain(|_, op| op.sid != sid);
580
581            debug!("DESTROY_CHANNEL: cid={}, sid={}", cid, sid);
582        }
583    }
584
585    /// Handle operation INIT request (client → server)
586    /// subcmd & 0x08 indicates INIT
587    pub fn on_op_init_request(
588        &mut self,
589        conn_key: &ConnectionKey,
590        sid: u32,
591        ioid: u32,
592        command: u8,
593    ) {
594        let max_ops = self.config.max_operations;
595        let conn = self.get_or_create_connection(conn_key);
596        conn.touch();
597
598        let pv_name = conn.get_pv_name_by_sid(sid).map(|s| s.to_string());
599
600        if conn.operations.len() < max_ops {
601            conn.operations
602                .insert(ioid, OperationState::new(sid, ioid, command, pv_name));
603            self.stats.operations_created += 1;
604            debug!(
605                "Operation INIT: sid={}, ioid={}, cmd={}",
606                sid, ioid, command
607            );
608        }
609    }
610
611    /// Handle operation INIT response (server → client)
612    /// Contains type introspection data
613    pub fn on_op_init_response(
614        &mut self,
615        conn_key: &ConnectionKey,
616        ioid: u32,
617        field_desc: Option<StructureDesc>,
618    ) {
619        if let Some(conn) = self.connections.get_mut(conn_key) {
620            conn.touch();
621
622            if let Some(op) = conn.operations.get_mut(&ioid) {
623                op.set_field_desc(field_desc);
624                op.initialized = true;
625                op.touch();
626                debug!("Operation INIT response: ioid={}", ioid);
627            }
628        }
629    }
630
631    /// Handle operation DESTROY (subcmd & 0x10)
632    pub fn on_op_destroy(&mut self, conn_key: &ConnectionKey, ioid: u32) {
633        if let Some(conn) = self.connections.get_mut(conn_key) {
634            if conn.operations.remove(&ioid).is_some() {
635                self.stats.operations_completed += 1;
636            }
637        }
638    }
639
640    /// Touch connection, operation, and channel activity for any op message (data updates, etc.)
641    /// If the IOID is unknown (mid-stream join), auto-creates a placeholder operation
642    /// so the connection appears on the Connections page.
643    pub fn on_op_activity(&mut self, conn_key: &ConnectionKey, sid: u32, ioid: u32, command: u8) {
644        let max_update_rate = self.config.max_update_rate;
645        let max_ops = self.config.max_operations;
646        let mut created_placeholder = false;
647
648        let conn = self.get_or_create_connection(conn_key);
649        conn.touch();
650
651        Self::record_update(&mut conn.update_times, max_update_rate);
652
653        let mut channel_sid = if sid != 0 { Some(sid) } else { None };
654        if let Some(op) = conn.operations.get_mut(&ioid) {
655            op.touch();
656            Self::record_update(&mut op.update_times, max_update_rate);
657            if channel_sid.is_none() {
658                channel_sid = Some(op.sid);
659            }
660        } else if conn.operations.len() < max_ops {
661            // Mid-stream: we missed the INIT exchange, create a placeholder operation
662            // so this connection/channel is visible on the Connections page.
663            let pv_name = if sid != 0 {
664                conn.get_pv_name_by_sid(sid).map(|s| s.to_string())
665            } else if conn.channels_by_cid.len() == 1 && conn.operations.is_empty() {
666                // Server Op messages have sid=0; only use single-channel fallback
667                // when this is the very first operation (no other ops yet).
668                // If there are already other operations, this is likely a
669                // multiplexed connection and the fallback would be wrong.
670                conn.channels_by_cid
671                    .values()
672                    .next()
673                    .map(|ch| ch.pv_name.clone())
674                    .filter(|n| !n.starts_with("<unknown"))
675            } else {
676                None
677            };
678            conn.operations
679                .insert(ioid, OperationState::new(sid, ioid, command, pv_name));
680            created_placeholder = true;
681        }
682
683        if let Some(sid_val) = channel_sid {
684            if let Some(channel) = conn.get_channel_by_sid_mut(sid_val) {
685                channel.touch();
686                Self::record_update(&mut channel.update_times, max_update_rate);
687            }
688        }
689
690        // Deferred stat update — can't touch self.stats while conn borrows self
691        if created_placeholder {
692            self.stats.operations_created += 1;
693            debug!(
694                "Auto-created placeholder operation for mid-stream traffic: sid={}, ioid={}, cmd={}",
695                sid, ioid, command
696            );
697        }
698    }
699
700    /// Cache PV name mappings from SEARCH messages (CID → PV name)
701    /// These serve as fallback when the client's CREATE_CHANNEL request is missed.
702    /// Also retroactively resolves any existing `<unknown:cid=N>` channels and
703    /// placeholder operations that match the CIDs in this SEARCH.
704    /// `source_ip` is the IP of the client that sent the SEARCH request.
705    pub fn on_search(&mut self, pv_requests: &[(u32, String)], source_ip: Option<IpAddr>) {
706        // Build a lookup map for this batch
707        let cid_to_pv: HashMap<u32, String> = pv_requests.iter().cloned().collect();
708
709        for (cid, pv_name) in pv_requests {
710            if let Some(ip) = source_ip {
711                self.search_cache.insert((ip, *cid), pv_name.clone());
712            }
713            // Always populate flat fallback
714            self.search_cache_flat.insert(*cid, pv_name.clone());
715        }
716
717        // Retroactively resolve existing unknown channels and operations.
718        // Walk all connections and fix any <unknown:cid=N> entries whose CID
719        // matches a CID from this SEARCH request.
720        let mut retroactive_count: u64 = 0;
721        for conn in self.connections.values_mut() {
722            for (cid, channel) in conn.channels_by_cid.iter_mut() {
723                if channel.pv_name.starts_with("<unknown") {
724                    if let Some(pv_name) = cid_to_pv.get(cid) {
725                        debug!(
726                            "Retroactive PV resolve from SEARCH: cid={} {} -> {}",
727                            cid, channel.pv_name, pv_name
728                        );
729                        channel.pv_name = pv_name.clone();
730                        channel.fully_established = true;
731                        retroactive_count += 1;
732                    }
733                }
734            }
735
736            // Also update placeholder operations that have pv_name=None
737            // or stale <unknown...> names, and whose SID maps to a resolved channel
738            for op in conn.operations.values_mut() {
739                let needs_update = match &op.pv_name {
740                    None => true,
741                    Some(name) => name.starts_with("<unknown"),
742                };
743                if needs_update && op.sid != 0 {
744                    if let Some(&cid) = conn.sid_to_cid.get(&op.sid) {
745                        if let Some(pv_name) = cid_to_pv.get(&cid) {
746                            op.pv_name = Some(pv_name.clone());
747                        }
748                    }
749                }
750            }
751        }
752        if retroactive_count > 0 {
753            self.stats.search_retroactive_resolves += retroactive_count;
754            debug!(
755                "Retroactively resolved {} unknown channels from SEARCH cache",
756                retroactive_count
757            );
758        }
759
760        // Update search cache size stat
761        self.stats.search_cache_entries = self.search_cache_flat.len() as u64;
762
763        // Cap cache sizes to prevent unbounded growth
764        while self.search_cache.len() > 50_000 {
765            if let Some(key) = self.search_cache.keys().next().cloned() {
766                self.search_cache.remove(&key);
767            }
768        }
769        while self.search_cache_flat.len() > 50_000 {
770            if let Some(key) = self.search_cache_flat.keys().next().cloned() {
771                self.search_cache_flat.remove(&key);
772            }
773        }
774    }
775
776    /// Resolve PV names from SEARCH_RESPONSE CIDs using the search cache.
777    /// Returns a list of (CID, resolved_pv_name) pairs for all CIDs that could be resolved.
778    /// `source_ip` is optionally the IP of the server that sent the response;
779    /// we try scoped lookups using peer IPs, then fall back to flat cache.
780    pub fn resolve_search_cids(
781        &mut self,
782        cids: &[u32],
783        peer_ip: Option<IpAddr>,
784    ) -> Vec<(u32, String)> {
785        let mut resolved = Vec::new();
786        for &cid in cids {
787            // Try scoped cache with peer IP (the client that originally searched),
788            // then fall back to flat cache
789            let pv_name = peer_ip
790                .and_then(|ip| self.search_cache.get(&(ip, cid)))
791                .or_else(|| self.search_cache_flat.get(&cid))
792                .cloned();
793            if let Some(name) = pv_name {
794                resolved.push((cid, name));
795                self.stats.search_responses_resolved += 1;
796            }
797        }
798        resolved
799    }
800
801    /// Count a PVA message direction (for messages not routed through on_message)
802    pub fn count_direction(&mut self, is_server: bool) {
803        if is_server {
804            self.stats.server_messages += 1;
805        } else {
806            self.stats.client_messages += 1;
807        }
808    }
809
810    pub fn on_message(
811        &mut self,
812        conn_key: &ConnectionKey,
813        sid: u32,
814        ioid: u32,
815        request_type: &str,
816        message: String,
817        is_server: bool,
818    ) {
819        let conn = self.get_or_create_connection(conn_key);
820        conn.touch();
821        let dir = if is_server { "S>" } else { "C>" };
822        let full_message = format!("{} {} {}", dir, request_type, message);
823        Self::push_message(&mut conn.recent_messages, full_message.clone());
824
825        let mut channel_sid = if sid != 0 { Some(sid) } else { None };
826        if let Some(op) = conn.operations.get_mut(&ioid) {
827            Self::push_message(&mut op.recent_messages, full_message.clone());
828            if channel_sid.is_none() {
829                channel_sid = Some(op.sid);
830            }
831        }
832        if let Some(sid_val) = channel_sid {
833            if let Some(channel) = conn.get_channel_by_sid_mut(sid_val) {
834                Self::push_message(&mut channel.recent_messages, full_message);
835            }
836        }
837    }
838
839    fn record_update(times: &mut VecDeque<Instant>, max_update_rate: usize) {
840        let now = Instant::now();
841        times.push_back(now);
842        Self::trim_times(times, now);
843        while times.len() > max_update_rate {
844            times.pop_front();
845        }
846    }
847
848    fn trim_times(times: &mut VecDeque<Instant>, now: Instant) {
849        while let Some(front) = times.front() {
850            if now.duration_since(*front) > Duration::from_secs(1) {
851                times.pop_front();
852            } else {
853                break;
854            }
855        }
856    }
857
858    fn updates_per_sec(times: &VecDeque<Instant>) -> f64 {
859        times.len() as f64
860    }
861
862    fn push_message(messages: &mut VecDeque<String>, message: String) {
863        messages.push_back(message);
864        while messages.len() > 30 {
865            messages.pop_front();
866        }
867    }
868
869    /// Resolve PV name for a MONITOR/GET/PUT packet
870    pub fn resolve_pv_name(&self, conn_key: &ConnectionKey, sid: u32, ioid: u32) -> Option<String> {
871        let conn = self.connections.get(conn_key)?;
872
873        // First try by IOID (operation state) - works for server responses
874        if let Some(op) = conn.operations.get(&ioid) {
875            if let Some(ref name) = op.pv_name {
876                if !name.starts_with("<unknown") {
877                    return Some(name.clone());
878                }
879            }
880        }
881
882        // Fall back to SID lookup - works for client requests
883        if sid != 0 {
884            if let Some(name) = conn.get_pv_name_by_sid(sid) {
885                return Some(name.to_string());
886            }
887        }
888
889        // Last resort: if there's exactly one channel AND at most one operation,
890        // use that channel's PV name. This handles simple single-PV connections
891        // where the server Op message has sid_or_cid=0.
892        //
893        // IMPORTANT: Do NOT use this fallback when there are multiple operations,
894        // because PVA multiplexes many channels over one TCP connection (e.g.
895        // Phoebus). If we only captured one CREATE_CHANNEL but there are many
896        // ops, the other ops likely belong to different PVs that were established
897        // before our capture started.
898        if conn.channels_by_cid.len() == 1 && conn.operations.len() <= 1 {
899            if let Some(ch) = conn.channels_by_cid.values().next() {
900                if !ch.pv_name.starts_with("<unknown") {
901                    return Some(ch.pv_name.clone());
902                }
903            }
904        }
905
906        None
907    }
908
909    /// Get the number of active tracked channels
910    pub fn active_channel_count(&self) -> usize {
911        self.total_channels
912    }
913
914    /// Get the number of active tracked connections
915    pub fn active_connection_count(&self) -> usize {
916        self.connections.len()
917    }
918
919    /// Check if a connection is mid-stream (incomplete channel state)
920    pub fn is_connection_mid_stream(&self, conn_key: &ConnectionKey) -> bool {
921        self.connections
922            .get(conn_key)
923            .map(|conn| {
924                // Operations exist but no channels tracked → definitely mid-stream
925                if conn.channels_by_cid.is_empty() && !conn.operations.is_empty() {
926                    return true;
927                }
928                // Any channel not fully established → mid-stream
929                conn.channels_by_cid
930                    .values()
931                    .any(|ch| !ch.fully_established)
932            })
933            .unwrap_or(false)
934    }
935
936    /// Get operation state for decoding values
937    pub fn get_operation(&self, conn_key: &ConnectionKey, ioid: u32) -> Option<&OperationState> {
938        self.connections
939            .get(conn_key)
940            .and_then(|conn| conn.operations.get(&ioid))
941    }
942
943    /// Evict oldest channels when at capacity
944    /// Evict the `count` least-recently-seen channels. Returns how many went.
945    ///
946    /// Removing a channel must also remove the operations riding on it. When
947    /// it did not, those operations were orphaned permanently -- nothing else
948    /// reclaims them, and a connection is only dropped once it holds neither
949    /// channels nor operations, so every leaked operation also pinned its
950    /// connection for the life of the process.
951    fn evict_oldest_channels(&mut self, count: usize) -> usize {
952        let mut oldest: Vec<(ConnectionKey, u32, Instant)> = Vec::new();
953
954        for (conn_key, conn) in &self.connections {
955            for (cid, channel) in &conn.channels_by_cid {
956                oldest.push((conn_key.clone(), *cid, channel.last_seen));
957            }
958        }
959
960        // Sort by last_seen (oldest first)
961        oldest.sort_by_key(|(_, _, t)| *t);
962
963        // Remove oldest
964        let mut evicted = 0;
965        for (conn_key, cid, _) in oldest.into_iter().take(count) {
966            if let Some(conn) = self.connections.get_mut(&conn_key) {
967                if let Some(channel) = conn.channels_by_cid.remove(&cid) {
968                    if let Some(sid) = channel.sid {
969                        conn.sid_to_cid.remove(&sid);
970                        conn.operations.retain(|_, op| op.sid != sid);
971                    }
972                    self.total_channels = self.total_channels.saturating_sub(1);
973                    self.stats.channels_evicted += 1;
974                    evicted += 1;
975                }
976            }
977        }
978        evicted
979    }
980
981    /// Periodic cleanup of expired entries
982    pub fn cleanup_expired(&mut self) {
983        let ttl = self.config.channel_ttl;
984        let mut expired_count = 0;
985
986        for conn in self.connections.values_mut() {
987            let expired_cids: Vec<u32> = conn
988                .channels_by_cid
989                .iter()
990                .filter(|(_, ch)| ch.is_expired(ttl))
991                .map(|(cid, _)| *cid)
992                .collect();
993
994            for cid in expired_cids {
995                if let Some(channel) = conn.channels_by_cid.remove(&cid) {
996                    if let Some(sid) = channel.sid {
997                        conn.sid_to_cid.remove(&sid);
998                        conn.operations.retain(|_, op| op.sid != sid);
999                    }
1000                    expired_count += 1;
1001                }
1002            }
1003        }
1004
1005        // Age operations out on their own `last_seen`, independently of any
1006        // channel. Channel-driven cleanup alone never reclaims a mid-stream
1007        // placeholder: it is created with sid=0, so it matches no expiring
1008        // channel's sid, and because a connection is retained while it holds
1009        // any operation, each such placeholder pinned its connection for the
1010        // life of the process. An operation carrying live traffic is touched
1011        // on every update, so it is never caught here.
1012        let mut expired_ops: u64 = 0;
1013        for conn in self.connections.values_mut() {
1014            let before = conn.operations.len();
1015            conn.operations.retain(|_, op| !op.is_expired(ttl));
1016            expired_ops += (before - conn.operations.len()) as u64;
1017        }
1018        if expired_ops > 0 {
1019            self.stats.operations_expired += expired_ops;
1020            debug!("Cleaned up {} expired operations", expired_ops);
1021        }
1022
1023        if expired_count > 0 {
1024            self.total_channels = self.total_channels.saturating_sub(expired_count);
1025            self.stats.channels_expired += expired_count as u64;
1026            debug!("Cleaned up {} expired channels", expired_count);
1027        }
1028
1029        // Remove empty connections
1030        self.connections
1031            .retain(|_, conn| !conn.channels_by_cid.is_empty() || !conn.operations.is_empty());
1032
1033        self.enforce_search_cache_cap();
1034        self.shed_to_budget();
1035        self.stats.memory_bytes = self.memory_estimate() as u64;
1036    }
1037
1038    /// Approximate bytes of live tracked state.
1039    ///
1040    /// Every term is measured on each call except introspection, which is
1041    /// walked once when the INIT response lands and cached on the operation
1042    /// -- it is the only recursive term, and the only one worth caching. A
1043    /// full pass at the channel ceiling costs single-digit milliseconds, and
1044    /// it runs once per second from `cleanup_expired`.
1045    pub fn memory_estimate(&self) -> usize {
1046        let conns = table_bytes::<ConnectionKey, ConnectionState>(self.connections.capacity())
1047            + self
1048                .connections
1049                .values()
1050                .map(ConnectionState::heap_bytes)
1051                .sum::<usize>();
1052        let search = table_bytes::<(IpAddr, u32), String>(self.search_cache.capacity())
1053            + self
1054                .search_cache
1055                .values()
1056                .map(String::capacity)
1057                .sum::<usize>()
1058            + table_bytes::<u32, String>(self.search_cache_flat.capacity())
1059            + self
1060                .search_cache_flat
1061                .values()
1062                .map(String::capacity)
1063                .sum::<usize>();
1064        conns + search
1065    }
1066
1067    /// Bring tracked state back inside `max_memory_bytes`, discarding in
1068    /// ascending order of how much the decoder needs it.
1069    ///
1070    /// The ordering is the whole point. `field_desc` is expensive to acquire:
1071    /// it arrives only on a MONITOR INIT response, which a passive observer
1072    /// sees only when it happens to witness a channel being created. Debug
1073    /// affordances refill in seconds; introspection may not return for days.
1074    /// So message rings go before caches, and caches before channels.
1075    fn shed_to_budget(&mut self) {
1076        let budget = self.config.max_memory_bytes;
1077        if budget == 0 {
1078            return;
1079        }
1080        let mut est = self.memory_estimate();
1081        if est <= budget {
1082            return;
1083        }
1084
1085        // Tier 1: mid-stream placeholders -- no INIT seen, no introspection.
1086        if self.shed_placeholder_ops(est - budget) > 0 {
1087            self.shrink_containers();
1088            est = self.memory_estimate();
1089            if est <= budget {
1090                return;
1091            }
1092        }
1093
1094        // Tier 2: debug message rings. Pure UI affordance; dropping them
1095        // leaves every field_desc intact.
1096        if self.shed_message_rings(est - budget) > 0 {
1097            est = self.memory_estimate();
1098            if est <= budget {
1099                return;
1100            }
1101        }
1102
1103        // Tier 3: SEARCH name caches. Future SEARCH traffic repopulates them.
1104        if self.shed_search_caches() > 0 {
1105            est = self.memory_estimate();
1106            if est <= budget {
1107                return;
1108            }
1109        }
1110
1111        // Tier 4: oldest channels, introspection and all. Reaching here means
1112        // the budget cannot hold the live channel set, so coverage is being
1113        // lost -- `shed_channels` is the metric that says so.
1114        let mut guard = 0;
1115        while est > budget && self.total_channels > 0 && guard < 16 {
1116            let per_channel = (est / self.total_channels.max(1)).max(1);
1117            let batch = (((est - budget) / per_channel) + 1).clamp(100, self.total_channels);
1118            let evicted = self.evict_oldest_channels(batch);
1119            if evicted == 0 {
1120                break;
1121            }
1122            self.stats.shed_channels += evicted as u64;
1123            self.shrink_containers();
1124            est = self.memory_estimate();
1125            guard += 1;
1126        }
1127    }
1128
1129    /// Hand back the table capacity that removals left behind.
1130    ///
1131    /// A `HashMap` never shrinks on `remove`, so shedding entries releases
1132    /// their contents but not the slots that held them. Skipping this would
1133    /// leave both the estimate and the real allocation nearly where they
1134    /// started -- the tiers would report progress they had not made, and the
1135    /// loop would keep evicting live channels chasing a figure that could not
1136    /// come down. Only reached while over budget, so the rehash is not on any
1137    /// steady-state path.
1138    fn shrink_containers(&mut self) {
1139        for conn in self.connections.values_mut() {
1140            conn.operations.shrink_to_fit();
1141            conn.channels_by_cid.shrink_to_fit();
1142            conn.sid_to_cid.shrink_to_fit();
1143        }
1144        self.connections.shrink_to_fit();
1145    }
1146
1147    /// Discard placeholder operations, least recently seen first, until
1148    /// `target` bytes are freed. Returns the bytes actually freed.
1149    fn shed_placeholder_ops(&mut self, target: usize) -> usize {
1150        let mut cands: Vec<(ConnectionKey, u32, Instant, usize)> = Vec::new();
1151        for (ck, conn) in &self.connections {
1152            for (ioid, op) in &conn.operations {
1153                if op.is_placeholder() {
1154                    cands.push((
1155                        ck.clone(),
1156                        *ioid,
1157                        op.last_seen,
1158                        op.heap_bytes() + std::mem::size_of::<OperationState>(),
1159                    ));
1160                }
1161            }
1162        }
1163        cands.sort_by_key(|(_, _, t, _)| *t);
1164
1165        let mut freed = 0;
1166        for (ck, ioid, _, bytes) in cands {
1167            if freed >= target {
1168                break;
1169            }
1170            let removed = self
1171                .connections
1172                .get_mut(&ck)
1173                .and_then(|conn| conn.operations.remove(&ioid));
1174            if removed.is_some() {
1175                freed += bytes;
1176                self.stats.shed_placeholder_ops += 1;
1177            }
1178        }
1179        freed
1180    }
1181
1182    /// Release `recent_messages` rings, least recently seen first, until
1183    /// `target` bytes are freed. Returns the bytes actually freed.
1184    fn shed_message_rings(&mut self, target: usize) -> usize {
1185        let mut cands: Vec<(ConnectionKey, u32, Instant, usize)> = Vec::new();
1186        for (ck, conn) in &self.connections {
1187            for (ioid, op) in &conn.operations {
1188                let bytes = ring_bytes(&op.recent_messages);
1189                if bytes > 0 {
1190                    cands.push((ck.clone(), *ioid, op.last_seen, bytes));
1191                }
1192            }
1193        }
1194        cands.sort_by_key(|(_, _, t, _)| *t);
1195
1196        let mut freed = 0;
1197        for (ck, ioid, _, bytes) in cands {
1198            if freed >= target {
1199                break;
1200            }
1201            if let Some(op) = self
1202                .connections
1203                .get_mut(&ck)
1204                .and_then(|conn| conn.operations.get_mut(&ioid))
1205            {
1206                // Assign a fresh ring rather than `clear()`, which keeps the
1207                // allocation and would free nothing.
1208                op.recent_messages = VecDeque::new();
1209                freed += bytes;
1210                self.stats.shed_message_rings += 1;
1211            }
1212        }
1213        if freed >= target {
1214            return freed;
1215        }
1216
1217        for conn in self.connections.values_mut() {
1218            if freed >= target {
1219                break;
1220            }
1221            let bytes = ring_bytes(&conn.recent_messages);
1222            if bytes > 0 {
1223                conn.recent_messages = VecDeque::new();
1224                freed += bytes;
1225                self.stats.shed_message_rings += 1;
1226            }
1227        }
1228        freed
1229    }
1230
1231    /// Drop both SEARCH name caches wholesale. Returns the bytes freed.
1232    ///
1233    /// They are unordered best-effort lookups with no natural eviction order,
1234    /// and losing one costs at most a name that a later SEARCH supplies
1235    /// again, so a partial eviction would buy nothing over a clean reset.
1236    fn shed_search_caches(&mut self) -> usize {
1237        let freed = table_bytes::<(IpAddr, u32), String>(self.search_cache.capacity())
1238            + self
1239                .search_cache
1240                .values()
1241                .map(String::capacity)
1242                .sum::<usize>()
1243            + table_bytes::<u32, String>(self.search_cache_flat.capacity())
1244            + self
1245                .search_cache_flat
1246                .values()
1247                .map(String::capacity)
1248                .sum::<usize>();
1249        let dropped = (self.search_cache.len() + self.search_cache_flat.len()) as u64;
1250        self.search_cache = HashMap::new();
1251        self.search_cache_flat = HashMap::new();
1252        self.stats.shed_search_entries += dropped;
1253        freed
1254    }
1255
1256    /// Keep the SEARCH caches inside their entry cap, independently of the
1257    /// memory ceiling so they cannot dominate a small budget.
1258    fn enforce_search_cache_cap(&mut self) {
1259        let cap = self.config.max_search_cache_entries;
1260        if cap == 0 {
1261            return;
1262        }
1263        if self.search_cache.len() > cap || self.search_cache_flat.len() > cap {
1264            self.shed_search_caches();
1265        }
1266    }
1267
1268    /// Get summary statistics
1269    pub fn summary(&self) -> String {
1270        format!(
1271            "PVA State: {} connections, {} channels (created={}, destroyed={}, expired={}, evicted={})",
1272            self.connections.len(),
1273            self.total_channels,
1274            self.stats.channels_created,
1275            self.stats.channels_destroyed,
1276            self.stats.channels_expired,
1277            self.stats.channels_evicted,
1278        )
1279    }
1280
1281    /// Get current channel count
1282    pub fn channel_count(&self) -> usize {
1283        self.total_channels
1284    }
1285
1286    /// Get current connection count
1287    pub fn connection_count(&self) -> usize {
1288        self.connections.len()
1289    }
1290
1291    pub fn connection_snapshots(&self) -> Vec<ConnectionSnapshot> {
1292        let mut snapshots = Vec::new();
1293        let now = Instant::now();
1294        for (conn_key, conn) in &self.connections {
1295            let mut update_times = conn.update_times.clone();
1296            Self::trim_times(&mut update_times, now);
1297            let mut pv_names: Vec<String> = conn
1298                .channels_by_cid
1299                .values()
1300                .map(|ch| ch.pv_name.clone())
1301                .collect();
1302            pv_names.sort();
1303            pv_names.truncate(8);
1304            let mut messages: Vec<String> = conn.recent_messages.iter().cloned().collect();
1305            if messages.len() > 20 {
1306                messages = messages.split_off(messages.len() - 20);
1307            }
1308            let is_beacon = messages.iter().any(|m| m.starts_with("BEACON "));
1309            let is_broadcast = Self::is_broadcast_addr(&conn_key.addr_a)
1310                || Self::is_broadcast_addr(&conn_key.addr_b);
1311            let mut mid_stream = false;
1312            if conn.channels_by_cid.is_empty() && !conn.operations.is_empty() {
1313                mid_stream = true;
1314            }
1315            if conn
1316                .channels_by_cid
1317                .values()
1318                .any(|ch| !ch.fully_established || ch.pv_name.starts_with("<unknown"))
1319            {
1320                mid_stream = true;
1321            }
1322
1323            snapshots.push(ConnectionSnapshot {
1324                addr_a: conn_key.addr_a,
1325                addr_b: conn_key.addr_b,
1326                channel_count: conn.channels_by_cid.len(),
1327                operation_count: conn.operations.len(),
1328                last_seen: conn.last_seen.elapsed(),
1329                pv_names,
1330                updates_per_sec: Self::updates_per_sec(&update_times),
1331                recent_messages: messages,
1332                mid_stream,
1333                is_beacon,
1334                is_broadcast,
1335            });
1336        }
1337        snapshots
1338    }
1339
1340    pub fn channel_snapshots(&self) -> Vec<ChannelSnapshot> {
1341        let mut snapshots = Vec::new();
1342        let now = Instant::now();
1343        for (conn_key, conn) in &self.connections {
1344            for channel in conn.channels_by_cid.values() {
1345                let mut update_times = channel.update_times.clone();
1346                Self::trim_times(&mut update_times, now);
1347                let mut messages: Vec<String> = channel.recent_messages.iter().cloned().collect();
1348                if messages.len() > 20 {
1349                    messages = messages.split_off(messages.len() - 20);
1350                }
1351                let is_beacon = messages.iter().any(|m| m.starts_with("BEACON "));
1352                let is_broadcast = Self::is_broadcast_addr(&conn_key.addr_a)
1353                    || Self::is_broadcast_addr(&conn_key.addr_b);
1354                snapshots.push(ChannelSnapshot {
1355                    addr_a: conn_key.addr_a,
1356                    addr_b: conn_key.addr_b,
1357                    cid: channel.cid,
1358                    sid: channel.sid,
1359                    pv_name: channel.pv_name.clone(),
1360                    last_seen: channel.last_seen.elapsed(),
1361                    updates_per_sec: Self::updates_per_sec(&update_times),
1362                    recent_messages: messages,
1363                    mid_stream: !channel.fully_established
1364                        || channel.pv_name.starts_with("<unknown"),
1365                    is_beacon,
1366                    is_broadcast,
1367                });
1368            }
1369
1370            // Avoid emitting duplicate fallback rows when multiple operations
1371            // reference the same unresolved SID/PV on one connection.
1372            let mut seen_virtual = HashSet::new();
1373            for op in conn.operations.values() {
1374                if conn.get_channel_by_sid(op.sid).is_none() {
1375                    let mut update_times = op.update_times.clone();
1376                    Self::trim_times(&mut update_times, now);
1377                    let mut messages: Vec<String> = op.recent_messages.iter().cloned().collect();
1378                    if messages.len() > 20 {
1379                        messages = messages.split_off(messages.len() - 20);
1380                    }
1381                    let is_beacon = messages.iter().any(|m| m.starts_with("BEACON "));
1382                    let is_broadcast = Self::is_broadcast_addr(&conn_key.addr_a)
1383                        || Self::is_broadcast_addr(&conn_key.addr_b);
1384                    let pv_name = op
1385                        .pv_name
1386                        .clone()
1387                        .unwrap_or_else(|| format!("<unknown:sid={}>", op.sid));
1388                    if !seen_virtual.insert((op.sid, pv_name.clone())) {
1389                        continue;
1390                    }
1391                    snapshots.push(ChannelSnapshot {
1392                        addr_a: conn_key.addr_a,
1393                        addr_b: conn_key.addr_b,
1394                        cid: 0,
1395                        sid: Some(op.sid),
1396                        pv_name,
1397                        last_seen: op.last_seen.elapsed(),
1398                        updates_per_sec: Self::updates_per_sec(&update_times),
1399                        recent_messages: messages,
1400                        mid_stream: true,
1401                        is_beacon,
1402                        is_broadcast,
1403                    });
1404                }
1405            }
1406        }
1407        snapshots
1408    }
1409}
1410
1411#[cfg(test)]
1412mod tests {
1413    use super::*;
1414    use crate::{FieldDesc, FieldType};
1415
1416    fn test_conn_key() -> ConnectionKey {
1417        ConnectionKey::from_parts("192.168.1.1", 12345, "192.168.1.2", 5075).unwrap()
1418    }
1419
1420    /// An NTScalar-shaped description with `nested` sub-fields under
1421    /// `timeStamp`, so the recursive term of `heap_size` is exercised.
1422    fn desc_with_fields(nested: usize) -> StructureDesc {
1423        let mut inner = StructureDesc::new();
1424        for i in 0..nested {
1425            inner.fields.push(FieldDesc {
1426                name: format!("nested_field_name_{i}"),
1427                field_type: FieldType::String,
1428            });
1429        }
1430        let mut outer = StructureDesc::new();
1431        outer.struct_id = Some("epics:nt/NTScalar:1.0".to_string());
1432        outer.fields.push(FieldDesc {
1433            name: "value".to_string(),
1434            field_type: FieldType::String,
1435        });
1436        outer.fields.push(FieldDesc {
1437            name: "timeStamp".to_string(),
1438            field_type: FieldType::Structure(inner),
1439        });
1440        outer
1441    }
1442
1443    #[test]
1444    fn test_heap_size_walks_nested_fields() {
1445        let flat = desc_with_fields(0);
1446        let nested = desc_with_fields(20);
1447
1448        assert!(
1449            flat.heap_size() > 0,
1450            "struct_id and two field names are heap"
1451        );
1452        // Each nested field contributes at least its own name; a walk that
1453        // stopped at the top level would miss all twenty.
1454        let names_only = 20 * "nested_field_name_0".len();
1455        assert!(
1456            nested.heap_size() > flat.heap_size() + names_only,
1457            "nested={} flat={}",
1458            nested.heap_size(),
1459            flat.heap_size()
1460        );
1461    }
1462
1463    #[test]
1464    fn test_expired_placeholder_operations_are_reclaimed() {
1465        let mut tracker = PvaStateTracker::new(PvaStateConfig::new(100, 0));
1466        let key = test_conn_key();
1467
1468        // Mid-stream server MONITOR update: sid=0, unseen ioid. This mints a
1469        // placeholder operation and no channel at all, so channel-driven
1470        // cleanup alone has nothing to hang the reclamation on.
1471        tracker.on_op_activity(&key, 0, 42, 13);
1472        assert_eq!(tracker.connection_count(), 1);
1473
1474        tracker.cleanup_expired();
1475
1476        assert_eq!(tracker.stats.operations_expired, 1);
1477        assert_eq!(
1478            tracker.connection_count(),
1479            0,
1480            "connection must drop once its last operation ages out"
1481        );
1482    }
1483
1484    #[test]
1485    fn test_active_operation_is_not_aged_out() {
1486        let mut tracker = PvaStateTracker::new(PvaStateConfig::new(100, 3600));
1487        let key = test_conn_key();
1488
1489        tracker.on_op_activity(&key, 0, 42, 13);
1490        tracker.cleanup_expired();
1491
1492        assert_eq!(tracker.stats.operations_expired, 0);
1493        assert_eq!(tracker.connection_count(), 1);
1494    }
1495
1496    #[test]
1497    fn test_eviction_removes_operations_riding_the_channel() {
1498        let mut tracker = PvaStateTracker::new(PvaStateConfig::new(1, 3600));
1499        let key = test_conn_key();
1500
1501        tracker.on_create_channel_request(&key, 1, "PV:ONE".to_string());
1502        tracker.on_create_channel_response(&key, 1, 100);
1503        tracker.on_op_init_request(&key, 100, 7, 13);
1504        assert_eq!(tracker.connections[&key].operations.len(), 1);
1505
1506        // A second channel puts us at the ceiling and evicts the first.
1507        tracker.on_create_channel_request(&key, 2, "PV:TWO".to_string());
1508
1509        assert!(tracker.stats.channels_evicted >= 1);
1510        assert!(
1511            tracker.connections[&key].operations.is_empty(),
1512            "an operation whose channel was evicted is unreachable; nothing \
1513             else reclaims it, and it pins its connection forever"
1514        );
1515    }
1516
1517    #[test]
1518    fn test_shedding_drops_placeholders_before_introspection() {
1519        let mut tracker = PvaStateTracker::new(PvaStateConfig::new(10_000, 3600));
1520        let key = test_conn_key();
1521
1522        // One fully-witnessed operation carrying introspection...
1523        tracker.on_create_channel_request(&key, 1, "PV:KEEP".to_string());
1524        tracker.on_create_channel_response(&key, 1, 100);
1525        tracker.on_op_init_request(&key, 100, 1, 13);
1526        tracker.on_op_init_response(&key, 1, Some(desc_with_fields(30)));
1527
1528        // ...and a crowd of mid-stream placeholders carrying nothing.
1529        for ioid in 100..400 {
1530            tracker.on_op_activity(&key, 0, ioid, 13);
1531        }
1532
1533        tracker.config.max_memory_bytes = tracker.memory_estimate() / 2;
1534        tracker.cleanup_expired();
1535
1536        assert!(tracker.stats.shed_placeholder_ops > 0);
1537        assert_eq!(
1538            tracker.stats.shed_channels, 0,
1539            "no channel should be shed while worthless placeholders remain"
1540        );
1541        let op = &tracker.connections[&key].operations[&1];
1542        assert!(
1543            op.field_desc.is_some(),
1544            "introspection is the expensive thing to reacquire; it sheds last"
1545        );
1546        assert!(tracker.memory_estimate() <= tracker.config.max_memory_bytes);
1547    }
1548
1549    #[test]
1550    fn test_cleanup_holds_state_under_the_memory_ceiling() {
1551        let mut tracker = PvaStateTracker::new(PvaStateConfig::new(40_000, 3600));
1552        let key = test_conn_key();
1553
1554        for cid in 0..4_000u32 {
1555            let sid = cid + 1;
1556            tracker.on_create_channel_request(&key, cid, format!("SR:DI:BPM:{cid:04}:X:MEAN"));
1557            tracker.on_create_channel_response(&key, cid, sid);
1558            tracker.on_op_init_request(&key, sid, sid, 13);
1559            tracker.on_op_init_response(&key, sid, Some(desc_with_fields(30)));
1560        }
1561
1562        let unbounded = tracker.memory_estimate();
1563        let budget = unbounded / 4;
1564        tracker.config.max_memory_bytes = budget;
1565        tracker.cleanup_expired();
1566
1567        assert!(
1568            tracker.stats.shed_channels > 0,
1569            "a quarter of the state cannot hold this channel set; coverage \
1570             has to be given up, and shed_channels is what says so"
1571        );
1572        assert!(
1573            tracker.memory_estimate() <= budget,
1574            "estimate {} still over budget {}",
1575            tracker.memory_estimate(),
1576            budget
1577        );
1578        assert_eq!(
1579            tracker.stats.memory_bytes as usize,
1580            tracker.memory_estimate()
1581        );
1582    }
1583
1584    #[test]
1585    fn test_zero_budget_disables_shedding() {
1586        let mut tracker = PvaStateTracker::new(PvaStateConfig::new(10_000, 3600));
1587        let key = test_conn_key();
1588
1589        for ioid in 0..200 {
1590            tracker.on_op_activity(&key, 0, ioid, 13);
1591        }
1592        tracker.config.max_memory_bytes = 0;
1593        tracker.cleanup_expired();
1594
1595        assert_eq!(tracker.stats.shed_placeholder_ops, 0);
1596        assert_eq!(tracker.stats.shed_channels, 0);
1597        assert_eq!(tracker.connections[&key].operations.len(), 200);
1598    }
1599
1600    #[test]
1601    fn test_search_cache_cap_is_enforced() {
1602        let mut tracker = PvaStateTracker::new(PvaStateConfig::new(10_000, 3600));
1603        tracker.config.max_search_cache_entries = 50;
1604
1605        let ip = Some("192.168.1.1".parse::<IpAddr>().unwrap());
1606        let reqs: Vec<(u32, String)> = (0..200u32).map(|i| (i, format!("PV:{i}"))).collect();
1607        tracker.on_search(&reqs, ip);
1608        assert!(tracker.search_cache.len() > 50);
1609
1610        tracker.cleanup_expired();
1611
1612        assert!(tracker.search_cache.is_empty());
1613        assert!(tracker.search_cache_flat.is_empty());
1614        assert!(tracker.stats.shed_search_entries >= 200);
1615    }
1616
1617    #[test]
1618    fn test_create_channel_flow() {
1619        let mut tracker = PvaStateTracker::with_defaults();
1620        let key = test_conn_key();
1621
1622        // Client sends CREATE_CHANNEL
1623        tracker.on_create_channel_request(&key, 1, "TEST:PV:VALUE".to_string());
1624        assert_eq!(tracker.channel_count(), 1);
1625
1626        // Server responds
1627        tracker.on_create_channel_response(&key, 1, 100);
1628
1629        // Verify we can resolve the PV name
1630        let pv_name = tracker.resolve_pv_name(&key, 100, 0);
1631        assert_eq!(pv_name, Some("TEST:PV:VALUE".to_string()));
1632    }
1633
1634    #[test]
1635    fn test_channel_limit() {
1636        let config = PvaStateConfig::new(100, 300);
1637        let mut tracker = PvaStateTracker::new(config);
1638        let key = test_conn_key();
1639
1640        // Add 150 channels (exceeds limit of 100)
1641        for i in 0..150 {
1642            tracker.on_create_channel_request(&key, i, format!("PV:{}", i));
1643        }
1644
1645        // Should have evicted some
1646        assert!(tracker.channel_count() <= 100);
1647    }
1648
1649    #[test]
1650    fn test_destroy_channel() {
1651        let mut tracker = PvaStateTracker::with_defaults();
1652        let key = test_conn_key();
1653
1654        tracker.on_create_channel_request(&key, 1, "TEST:PV".to_string());
1655        tracker.on_create_channel_response(&key, 1, 100);
1656        assert_eq!(tracker.channel_count(), 1);
1657
1658        tracker.on_destroy_channel(&key, 1, 100);
1659        assert_eq!(tracker.channel_count(), 0);
1660    }
1661
1662    #[test]
1663    fn test_channel_snapshots_dedup_unresolved_sid_rows() {
1664        let mut tracker = PvaStateTracker::with_defaults();
1665        let key = test_conn_key();
1666
1667        // Two operations on same unresolved SID should collapse to one virtual channel row.
1668        tracker.on_op_init_request(&key, 777, 1001, 13);
1669        tracker.on_op_init_request(&key, 777, 1002, 13);
1670        tracker.on_op_activity(&key, 777, 1001, 13);
1671        tracker.on_op_activity(&key, 777, 1002, 13);
1672
1673        let snapshots = tracker.channel_snapshots();
1674        assert_eq!(snapshots.len(), 1);
1675        assert_eq!(snapshots[0].sid, Some(777));
1676    }
1677
1678    #[test]
1679    fn test_single_channel_fallback_works_for_simple_connection() {
1680        // When there is truly one channel and zero/one operations, the
1681        // single-channel fallback should resolve the PV name from sid=0.
1682        let mut tracker = PvaStateTracker::with_defaults();
1683        let key = test_conn_key();
1684
1685        tracker.on_create_channel_request(&key, 1, "SIMPLE:PV".to_string());
1686        tracker.on_create_channel_response(&key, 1, 100);
1687
1688        // sid=0, ioid=99 — no matching operation
1689        let pv = tracker.resolve_pv_name(&key, 0, 99);
1690        assert_eq!(pv, Some("SIMPLE:PV".to_string()));
1691    }
1692
1693    #[test]
1694    fn test_no_false_attribution_on_multiplexed_connection() {
1695        // Phoebus scenario: one TCP connection carries many channels, but we
1696        // only captured one CREATE_CHANNEL.  When additional ops arrive with
1697        // sid=0 (server direction), the single-channel fallback must NOT
1698        // attribute them to the one known channel.
1699        let mut tracker = PvaStateTracker::with_defaults();
1700        let key = test_conn_key();
1701
1702        // Capture one channel
1703        tracker.on_create_channel_request(&key, 1, "CAPTURED:PV".to_string());
1704        tracker.on_create_channel_response(&key, 1, 100);
1705
1706        // Simulate many ops arriving (as happens with multiplexed connections).
1707        // First op via on_op_init_request with sid known:
1708        tracker.on_op_init_request(&key, 100, 1, 13); // MONITOR for the known channel
1709
1710        // Additional ops with different SIDs (channels we never saw created):
1711        for ioid in 2..=10 {
1712            tracker.on_op_activity(&key, 0, ioid, 13);
1713        }
1714
1715        // The known IOID=1 should resolve (via its op's pv_name from INIT)
1716        let pv1 = tracker.resolve_pv_name(&key, 100, 1);
1717        assert_eq!(pv1, Some("CAPTURED:PV".to_string()));
1718
1719        // Unknown ioids should NOT resolve to CAPTURED:PV
1720        for ioid in 2..=10 {
1721            let pv = tracker.resolve_pv_name(&key, 0, ioid);
1722            assert_eq!(
1723                pv, None,
1724                "ioid={} should not resolve to the single captured channel",
1725                ioid
1726            );
1727        }
1728    }
1729
1730    #[test]
1731    fn test_on_op_activity_placeholder_not_created_for_multiplexed() {
1732        // When one channel is known but operations already exist, activity
1733        // with sid=0 should create a placeholder WITHOUT a PV name (not
1734        // inheriting from the single captured channel).
1735        let mut tracker = PvaStateTracker::with_defaults();
1736        let key = test_conn_key();
1737
1738        tracker.on_create_channel_request(&key, 1, "KNOWN:PV".to_string());
1739        tracker.on_create_channel_response(&key, 1, 100);
1740
1741        // First op — establishes that operations exist
1742        tracker.on_op_init_request(&key, 100, 1, 13);
1743
1744        // Second op via on_op_activity with sid=0 — should NOT inherit PV name
1745        tracker.on_op_activity(&key, 0, 2, 13);
1746
1747        let pv = tracker.resolve_pv_name(&key, 0, 2);
1748        assert_eq!(
1749            pv, None,
1750            "placeholder for ioid=2 should not inherit PV from single-channel fallback"
1751        );
1752    }
1753
1754    #[test]
1755    fn test_search_cache_populates_and_resolves() {
1756        let mut tracker = PvaStateTracker::with_defaults();
1757        let client_ip: IpAddr = "192.168.1.10".parse().unwrap();
1758
1759        // Simulate SEARCH request with CID → PV name pairs
1760        let pv_requests = vec![
1761            (100, "MOTOR:X:POSITION".to_string()),
1762            (101, "MOTOR:Y:POSITION".to_string()),
1763            (102, "TEMP:SENSOR:1".to_string()),
1764        ];
1765        tracker.on_search(&pv_requests, Some(client_ip));
1766
1767        // Resolve CIDs from a SEARCH_RESPONSE
1768        let resolved = tracker.resolve_search_cids(&[100, 101, 102], Some(client_ip));
1769        assert_eq!(resolved.len(), 3);
1770        assert_eq!(resolved[0], (100, "MOTOR:X:POSITION".to_string()));
1771        assert_eq!(resolved[1], (101, "MOTOR:Y:POSITION".to_string()));
1772        assert_eq!(resolved[2], (102, "TEMP:SENSOR:1".to_string()));
1773    }
1774
1775    #[test]
1776    fn test_search_cache_partial_resolve() {
1777        let mut tracker = PvaStateTracker::with_defaults();
1778        let client_ip: IpAddr = "192.168.1.10".parse().unwrap();
1779
1780        let pv_requests = vec![(100, "MOTOR:X:POSITION".to_string())];
1781        tracker.on_search(&pv_requests, Some(client_ip));
1782
1783        // Resolve with some CIDs that were never cached
1784        let resolved = tracker.resolve_search_cids(&[100, 999], Some(client_ip));
1785        assert_eq!(resolved.len(), 1);
1786        assert_eq!(resolved[0], (100, "MOTOR:X:POSITION".to_string()));
1787    }
1788
1789    #[test]
1790    fn test_search_cache_scoped_by_ip() {
1791        let mut tracker = PvaStateTracker::with_defaults();
1792        let client_a: IpAddr = "192.168.1.10".parse().unwrap();
1793        let client_b: IpAddr = "192.168.1.20".parse().unwrap();
1794
1795        // Both clients use the same CID=1 but different PV names
1796        tracker.on_search(&[(1, "CLIENT_A:PV".to_string())], Some(client_a));
1797        tracker.on_search(&[(1, "CLIENT_B:PV".to_string())], Some(client_b));
1798
1799        // Each client should resolve to its own PV name
1800        let resolved_a = tracker.resolve_search_cids(&[1], Some(client_a));
1801        assert_eq!(resolved_a.len(), 1);
1802        assert_eq!(resolved_a[0].1, "CLIENT_A:PV");
1803
1804        let resolved_b = tracker.resolve_search_cids(&[1], Some(client_b));
1805        assert_eq!(resolved_b.len(), 1);
1806        assert_eq!(resolved_b[0].1, "CLIENT_B:PV");
1807    }
1808
1809    #[test]
1810    fn test_search_cache_flat_fallback() {
1811        let mut tracker = PvaStateTracker::with_defaults();
1812        let client_ip: IpAddr = "192.168.1.10".parse().unwrap();
1813
1814        // Cache with a known client IP
1815        tracker.on_search(&[(42, "SOME:PV:NAME".to_string())], Some(client_ip));
1816
1817        // Resolve without knowing the client IP (flat fallback)
1818        let resolved = tracker.resolve_search_cids(&[42], None);
1819        assert_eq!(resolved.len(), 1);
1820        assert_eq!(resolved[0].1, "SOME:PV:NAME");
1821    }
1822
1823    #[test]
1824    fn test_search_cache_used_by_create_channel_response_fallback() {
1825        // When capture misses CREATE_CHANNEL request but has SEARCH,
1826        // the search cache should resolve PV name in CREATE_CHANNEL response.
1827        let mut tracker = PvaStateTracker::with_defaults();
1828        let key = test_conn_key();
1829        let client_ip: IpAddr = "192.168.1.1".parse().unwrap();
1830
1831        // Simulate SEARCH with CID=5 → "SEARCHED:PV"
1832        tracker.on_search(&[(5, "SEARCHED:PV".to_string())], Some(client_ip));
1833
1834        // Simulate CREATE_CHANNEL response without having seen the request
1835        tracker.on_create_channel_response(&key, 5, 200);
1836
1837        // The PV name should be resolved from search cache
1838        let pv = tracker.resolve_pv_name(&key, 200, 0);
1839        assert_eq!(pv, Some("SEARCHED:PV".to_string()));
1840    }
1841
1842    #[test]
1843    fn test_search_responses_resolved_stat() {
1844        let mut tracker = PvaStateTracker::with_defaults();
1845        let client_ip: IpAddr = "192.168.1.10".parse().unwrap();
1846
1847        tracker.on_search(
1848            &[(1, "PV:A".to_string()), (2, "PV:B".to_string())],
1849            Some(client_ip),
1850        );
1851
1852        assert_eq!(tracker.stats.search_responses_resolved, 0);
1853
1854        tracker.resolve_search_cids(&[1, 2], Some(client_ip));
1855        assert_eq!(tracker.stats.search_responses_resolved, 2);
1856
1857        // Resolving again increments further
1858        tracker.resolve_search_cids(&[1], Some(client_ip));
1859        assert_eq!(tracker.stats.search_responses_resolved, 3);
1860    }
1861
1862    #[test]
1863    fn test_retroactive_resolve_unknown_channels_from_search() {
1864        // Simulates the Java EPICS client scenario:
1865        // 1. Capture starts mid-stream, sees CREATE_CHANNEL responses (cid+sid)
1866        //    but missed the requests → channels are <unknown:cid=N>
1867        // 2. Later a SEARCH arrives with those CIDs → retroactively resolves PV names
1868        let mut tracker = PvaStateTracker::with_defaults();
1869        let key = test_conn_key();
1870
1871        // Step 1: CREATE_CHANNEL responses without prior requests → unknown channels
1872        tracker.on_create_channel_response(&key, 100, 500);
1873        tracker.on_create_channel_response(&key, 101, 501);
1874        tracker.on_create_channel_response(&key, 102, 502);
1875
1876        // Verify channels are unknown
1877        assert_eq!(
1878            tracker.resolve_pv_name(&key, 500, 0),
1879            Some("<unknown:cid=100>".to_string())
1880        );
1881        assert_eq!(
1882            tracker.resolve_pv_name(&key, 501, 0),
1883            Some("<unknown:cid=101>".to_string())
1884        );
1885
1886        // Step 2: SEARCH arrives with CID→PV name mappings
1887        let client_ip: IpAddr = "192.168.1.1".parse().unwrap();
1888        tracker.on_search(
1889            &[
1890                (100, "MOTOR:X:POS".to_string()),
1891                (101, "MOTOR:Y:POS".to_string()),
1892                (102, "TEMP:SENSOR:1".to_string()),
1893            ],
1894            Some(client_ip),
1895        );
1896
1897        // Verify channels are now resolved
1898        assert_eq!(
1899            tracker.resolve_pv_name(&key, 500, 0),
1900            Some("MOTOR:X:POS".to_string())
1901        );
1902        assert_eq!(
1903            tracker.resolve_pv_name(&key, 501, 0),
1904            Some("MOTOR:Y:POS".to_string())
1905        );
1906        assert_eq!(
1907            tracker.resolve_pv_name(&key, 502, 0),
1908            Some("TEMP:SENSOR:1".to_string())
1909        );
1910
1911        // Verify retroactive resolution was counted
1912        assert_eq!(tracker.stats.search_retroactive_resolves, 3);
1913    }
1914
1915    #[test]
1916    fn test_retroactive_resolve_also_updates_operations() {
1917        // When a placeholder operation has pv_name=None and its SID maps
1918        // to a channel that just got retroactively resolved, the operation's
1919        // pv_name should also be updated.
1920        let mut tracker = PvaStateTracker::with_defaults();
1921        let key = test_conn_key();
1922
1923        // CREATE_CHANNEL response without request → <unknown:cid=100>
1924        tracker.on_create_channel_response(&key, 100, 500);
1925
1926        // Op INIT on that channel → operation gets pv_name from channel
1927        // But the channel is unknown, so op gets "<unknown:cid=100>" as name
1928        tracker.on_op_init_request(&key, 500, 1, 13); // MONITOR
1929
1930        // Verify op resolves to unknown
1931        let pv = tracker.resolve_pv_name(&key, 500, 1);
1932        assert!(pv.is_some());
1933        // The op should have inherited the unknown name since it looked up via SID
1934
1935        // SEARCH arrives with the CID→PV mapping
1936        let client_ip: IpAddr = "192.168.1.1".parse().unwrap();
1937        tracker.on_search(&[(100, "RESOLVED:PV".to_string())], Some(client_ip));
1938
1939        // Channel should now be resolved
1940        assert_eq!(
1941            tracker.resolve_pv_name(&key, 500, 0),
1942            Some("RESOLVED:PV".to_string())
1943        );
1944        // Operation should also resolve (via SID→CID→channel)
1945        let pv = tracker.resolve_pv_name(&key, 500, 1);
1946        assert_eq!(pv, Some("RESOLVED:PV".to_string()));
1947    }
1948}