Skip to main content

opc_da_client/
com_worker.rs

1use crate::backend::connector::{ConnectedGroup, ConnectedServer, ServerConnector};
2use crate::bindings::da::{
3    OPC_BRANCH, OPC_BROWSE_DOWN, OPC_BROWSE_UP, OPC_DS_DEVICE, OPC_LEAF, OPC_NS_FLAT, tagOPCITEMDEF,
4};
5use crate::helpers::{
6    filetime_to_string, format_hresult, opc_value_to_variant, quality_to_string,
7    variant_to_display_string, variant_to_string,
8};
9use crate::native_browse::{BrowseSessions, capabilities_for_server};
10use crate::opc_da::errors::{OpcError, OpcResult};
11use crate::opc_da::typedefs::{GroupHandle, ItemHandle};
12use crate::provider::{
13    BrowseCapabilities, BrowsePage, BrowsePageRequest, BrowseSessionToken, OpcValue, TagValue,
14    WriteResult,
15};
16use std::collections::HashMap;
17use std::sync::Arc;
18use std::sync::atomic::{AtomicUsize, Ordering};
19use tokio::sync::{mpsc, oneshot};
20
21/// Controls whether read values preserve machine semantics or use TUI-oriented display formatting.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum ReadPresentation {
24    /// Return `VT_BSTR` contents exactly as stored by COM.
25    Semantic,
26    /// Wrap `VT_BSTR` contents in quotes for human-readable display.
27    Display,
28}
29
30/// Represents a asynchronous request dispatched to the COM worker thread.
31pub enum ComRequest {
32    /// Request to enumerate available OPC DA servers on a host.
33    ListServers {
34        /// Hostname or IP address to target.
35        host: String,
36        /// One-shot channel to send back the server enumeration result.
37        reply: oneshot::Sender<OpcResult<Vec<String>>>,
38    },
39    /// Request to read current values, quality, and timestamps for tag IDs.
40    ReadTagValues {
41        /// OPC server ProgID.
42        server: String,
43        /// List of fully qualified tag identifiers to read.
44        tag_ids: Vec<String>,
45        /// Value formatting intent for this read.
46        presentation: ReadPresentation,
47        /// One-shot channel to send back the tag values result.
48        reply: oneshot::Sender<OpcResult<Vec<TagValue>>>,
49    },
50    /// Request to write a typed value to a single tag.
51    WriteTagValue {
52        /// OPC server ProgID.
53        server: String,
54        /// Tag identifier to write.
55        tag_id: String,
56        /// Typed value to write.
57        value: OpcValue,
58        /// One-shot channel to send back the write operation result.
59        reply: oneshot::Sender<OpcResult<WriteResult>>,
60    },
61    /// Request to recursively browse available tags on a server.
62    BrowseTags {
63        /// OPC server ProgID.
64        server: String,
65        /// Maximum number of tags to discover before stopping.
66        max_tags: usize,
67        /// Atomic counter tracking total tags discovered.
68        progress: Arc<AtomicUsize>,
69        /// Shared mutex-protected vector storing discovered tag names incrementally.
70        tags_sink: Arc<std::sync::Mutex<Vec<String>>>,
71        /// One-shot channel to send back the complete tag discovery list.
72        reply: oneshot::Sender<OpcResult<Vec<String>>>,
73    },
74    /// Request the native browse capabilities of a server.
75    BrowseCapabilities {
76        /// OPC server ProgID.
77        server: String,
78        /// One-shot channel to send back the capabilities.
79        reply: oneshot::Sender<OpcResult<BrowseCapabilities>>,
80    },
81    /// Open an isolated native browse session.
82    OpenBrowseSession {
83        /// OPC server ProgID.
84        server: String,
85        /// One-shot channel to send back the opaque session token.
86        reply: oneshot::Sender<OpcResult<BrowseSessionToken>>,
87    },
88    /// Request one bounded native browse page.
89    BrowsePage {
90        /// Opaque browse session token.
91        session: BrowseSessionToken,
92        /// One-level browse request.
93        request: BrowsePageRequest,
94        /// One-shot channel to send back the page.
95        reply: oneshot::Sender<OpcResult<BrowsePage>>,
96    },
97    /// Close an isolated native browse session.
98    CloseBrowseSession {
99        /// Opaque browse session token.
100        session: BrowseSessionToken,
101        /// One-shot channel to report completion.
102        reply: oneshot::Sender<OpcResult<()>>,
103    },
104}
105
106/// Dedicated background worker thread manager handling COM MTA apartment thread affinity.
107///
108/// Dispatches requests received over an `mpsc` channel to Windows COM interfaces while maintaining
109/// a persistent connection pool and transparently evicting stale connection handles on RPC errors.
110pub struct ComWorker<C: ServerConnector + 'static> {
111    /// Channel sender for dispatching requests to the worker loop.
112    pub sender: mpsc::Sender<ComRequest>,
113    /// Thread join handle for clean worker thread teardown.
114    pub handle: Option<std::thread::JoinHandle<()>>,
115    _phantom: std::marker::PhantomData<C>,
116}
117
118#[allow(clippy::cast_possible_wrap)]
119fn is_connection_error(err: &OpcError) -> bool {
120    if let OpcError::Com { source } = err {
121        let code = source.code().0;
122        code == windows::core::HRESULT(0x8007_06BA_u32 as i32).0
123            || code == windows::core::HRESULT(0x8007_06BF_u32 as i32).0
124            || code == windows::core::HRESULT(0x8007_06BE_u32 as i32).0
125            || code == windows::core::HRESULT(0x8008_0005_u32 as i32).0
126    } else {
127        false
128    }
129}
130
131impl<C: ServerConnector + 'static> ComWorker<C> {
132    /// Creates a dummy/closed `ComWorker` handle used when background worker initialization fails.
133    pub fn closed() -> Self {
134        let (tx, _rx) = mpsc::channel(1);
135        Self {
136            sender: tx,
137            handle: None,
138            _phantom: std::marker::PhantomData,
139        }
140    }
141
142    #[allow(clippy::too_many_lines)]
143    #[tracing::instrument(skip(connector))]
144    pub fn start(connector: Arc<C>) -> Result<Self, OpcError> {
145        let (tx, mut rx) = mpsc::channel(32);
146        let (init_tx, init_rx) = std::sync::mpsc::channel();
147
148        let handle = std::thread::spawn(move || {
149            tracing::debug!("COM worker thread spawned, initializing COM (MTA)");
150            let _guard = match crate::ComGuard::new() {
151                Ok(g) => {
152                    tracing::info!("COM MTA initialized successfully on worker thread");
153                    let _ = init_tx.send(Ok(()));
154                    g
155                }
156                Err(e) => {
157                    tracing::error!(error = ?e, "COM worker failed to initialize MTA");
158                    let _ =
159                        init_tx.send(Err(OpcError::Internal("COM init failed on worker".into())));
160                    return;
161                }
162            };
163
164            let mut cache: HashMap<String, C::Server> = HashMap::new();
165            let mut browse_sessions = BrowseSessions::default();
166
167            while let Some(req) = rx.blocking_recv() {
168                browse_sessions.cleanup_expired();
169                match req {
170                    ComRequest::ListServers { host, reply } => {
171                        let span = tracing::info_span!("opc.list_servers", host = %host);
172                        let _enter = span.enter();
173                        #[cfg(feature = "dev-diagnostics")]
174                        tracing::trace!(host = %host, "list_servers: starting operation");
175                        let start = std::time::Instant::now();
176                        let servers = connector.enumerate_servers();
177                        if let Ok(s) = &servers {
178                            tracing::info!(
179                                count = s.len(),
180                                elapsed_ms =
181                                    u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
182                                "list_servers completed"
183                            );
184                        } else if let Err(e) = &servers {
185                            crate::opc_da::errors::log_opc_error(e, "list_servers");
186                            tracing::error!(
187                                error = ?e,
188                                elapsed_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
189                                "list_servers failed"
190                            );
191                        }
192                        let _ = reply.send(servers);
193                    }
194
195                    ComRequest::ReadTagValues {
196                        server,
197                        tag_ids,
198                        presentation,
199                        reply,
200                    } => {
201                        let result = Self::dispatch_with_retry(
202                            &mut cache,
203                            &connector,
204                            &server,
205                            |opc_server| {
206                                Self::handle_read(&server, &tag_ids, presentation, opc_server)
207                            },
208                        );
209                        let _ = reply.send(result);
210                    }
211                    ComRequest::WriteTagValue {
212                        server,
213                        tag_id,
214                        value,
215                        reply,
216                    } => {
217                        let result = Self::dispatch_with_retry(
218                            &mut cache,
219                            &connector,
220                            &server,
221                            |opc_server| Self::handle_write(&server, &tag_id, &value, opc_server),
222                        );
223                        let _ = reply.send(result);
224                    }
225                    ComRequest::BrowseTags {
226                        server,
227                        max_tags,
228                        progress,
229                        tags_sink,
230                        reply,
231                    } => {
232                        let result = Self::dispatch_with_retry(
233                            &mut cache,
234                            &connector,
235                            &server,
236                            |opc_server| {
237                                Self::handle_browse(
238                                    &server, max_tags, &progress, &tags_sink, opc_server,
239                                )
240                            },
241                        );
242                        let _ = reply.send(result);
243                    }
244                    ComRequest::BrowseCapabilities { server, reply } => {
245                        if reply.is_closed() {
246                            continue;
247                        }
248                        let result = Self::dispatch_with_retry(
249                            &mut cache,
250                            &connector,
251                            &server,
252                            capabilities_for_server,
253                        );
254                        let _ = reply.send(result);
255                    }
256                    ComRequest::OpenBrowseSession { server, reply } => {
257                        if reply.is_closed() {
258                            continue;
259                        }
260                        let result = connector
261                            .connect(&server)
262                            .and_then(|opc_server| browse_sessions.open(opc_server));
263                        if let Err(Ok(session)) = reply.send(result) {
264                            let _ = browse_sessions.close(&session);
265                        }
266                    }
267                    ComRequest::BrowsePage {
268                        session,
269                        request,
270                        reply,
271                    } => {
272                        if reply.is_closed() {
273                            let _ = browse_sessions.close(&session);
274                            continue;
275                        }
276                        let result = browse_sessions.page(&session, request);
277                        if reply.send(result).is_err() {
278                            let _ = browse_sessions.close(&session);
279                        }
280                    }
281                    ComRequest::CloseBrowseSession { session, reply } => {
282                        let result = browse_sessions.close(&session);
283                        let _ = reply.send(result);
284                    }
285                }
286            }
287
288            tracing::debug!("COM worker thread exiting cleanly");
289        });
290
291        init_rx
292            .recv()
293            .map_err(|_| OpcError::Internal("COM worker thread panicked during init".into()))??;
294
295        tracing::debug!("COM worker thread started");
296
297        Ok(Self {
298            sender: tx,
299            handle: Some(handle),
300            _phantom: std::marker::PhantomData,
301        })
302    }
303
304    #[tracing::instrument(skip(self, req_builder))]
305    pub async fn send_request<F, R>(&self, req_builder: F) -> OpcResult<R>
306    where
307        F: FnOnce(oneshot::Sender<OpcResult<R>>) -> ComRequest,
308    {
309        if self
310            .handle
311            .as_ref()
312            .is_some_and(std::thread::JoinHandle::is_finished)
313        {
314            tracing::error!("COM worker thread panicked or exited unexpectedly");
315            return Err(OpcError::Internal("COM worker thread panicked".into()));
316        }
317
318        let (tx, rx) = oneshot::channel();
319        let req = req_builder(tx);
320
321        self.sender
322            .send(req)
323            .await
324            .map_err(|_| OpcError::Internal("COM worker channel closed (worker stopped)".into()))?;
325
326        rx.await
327            .map_err(|_| OpcError::Internal("COM worker shut down during request".into()))?
328    }
329
330    fn dispatch_with_retry<F, R>(
331        cache: &mut HashMap<String, C::Server>,
332        connector: &Arc<C>,
333        server_name: &str,
334        operation: F,
335    ) -> OpcResult<R>
336    where
337        F: Fn(&C::Server) -> OpcResult<R>,
338    {
339        let server_ref = match cache.entry(server_name.to_string()) {
340            std::collections::hash_map::Entry::Occupied(e) => {
341                tracing::trace!(server = %server_name, "Cache hit");
342                e.into_mut()
343            }
344            std::collections::hash_map::Entry::Vacant(e) => {
345                tracing::debug!(server = %server_name, "Cache miss, connecting");
346                let srv = connector.connect(server_name)?;
347                tracing::info!(server = %server_name, "Connection established, added to pool");
348                e.insert(srv)
349            }
350        };
351
352        match operation(server_ref) {
353            Err(e) if is_connection_error(&e) => {
354                tracing::warn!(server = %server_name, error = ?e, "Evicting stale connection");
355                cache.remove(server_name);
356                tracing::debug!(server = %server_name, "Reconnecting");
357                let fresh_srv = connector.connect(server_name).map_err(|connect_e| {
358                    tracing::error!(error = ?connect_e, "Reconnect failed");
359                    connect_e
360                })?;
361                let fresh_ref = &fresh_srv;
362                let result = operation(fresh_ref);
363                tracing::info!(server = %server_name, "Reconnection successful, pool updated");
364                cache.insert(server_name.to_string(), fresh_srv);
365                result
366            }
367            other => other,
368        }
369    }
370
371    #[allow(clippy::too_many_lines)]
372    fn handle_read(
373        server_name: &str,
374        tag_ids: &[String],
375        presentation: ReadPresentation,
376        opc_server: &C::Server,
377    ) -> OpcResult<Vec<TagValue>> {
378        let span = tracing::info_span!(
379            "opc.read_tag_values",
380            server = %server_name,
381            tag_count = tag_ids.len()
382        );
383        let _enter = span.enter();
384        #[cfg(feature = "dev-diagnostics")]
385        tracing::trace!(
386            server = %server_name,
387            tag_count = tag_ids.len(),
388            sample_tags = ?tag_ids.iter().take(5).collect::<Vec<_>>(),
389            "read_tag_values: starting operation"
390        );
391        let start = std::time::Instant::now();
392
393        let mut revised_update_rate = 0u32;
394        let mut server_handle = GroupHandle::default();
395        let group = opc_server.add_group(
396            "opc-da-client-read",
397            true,
398            1000,
399            server_handle,
400            0,
401            0.0,
402            0,
403            &mut revised_update_rate,
404            &mut server_handle,
405        )?;
406
407        let item_id_wides: Vec<Vec<u16>> = tag_ids
408            .iter()
409            .map(|tag_id| tag_id.encode_utf16().chain(std::iter::once(0)).collect())
410            .collect();
411
412        let item_defs: Vec<tagOPCITEMDEF> = item_id_wides
413            .iter()
414            .enumerate()
415            .map(|(idx, wide)| tagOPCITEMDEF {
416                szAccessPath: windows::core::PWSTR::null(),
417                szItemID: windows::core::PWSTR(wide.as_ptr().cast_mut()),
418                bActive: windows::Win32::Foundation::TRUE,
419                #[allow(clippy::cast_possible_truncation)]
420                hClient: idx as u32,
421                dwBlobSize: 0,
422                pBlob: std::ptr::null_mut(),
423                vtRequestedDataType: 0,
424                wReserved: 0,
425            })
426            .collect();
427
428        let (results, errors) = group.add_items(&item_defs)?;
429
430        // RemoteArray::len() returns u32; tag_ids.len() returns usize.
431        if results.len() as usize != tag_ids.len() || errors.len() as usize != tag_ids.len() {
432            if let Err(e) = opc_server.remove_group(server_handle, true) {
433                tracing::warn!(error = ?e, operation = "read_tag_values", "Failed to remove OPC group during cleanup");
434            }
435            return Err(OpcError::Internal(
436                "OPC server returned mismatched result array sizes".into(),
437            ));
438        }
439
440        let mut tag_values: Vec<TagValue> = tag_ids
441            .iter()
442            .map(|tag_id| TagValue {
443                tag_id: tag_id.clone(),
444                value: "Error".to_string(),
445                quality: "Bad — not added to group".to_string(),
446                timestamp: String::new(),
447            })
448            .collect();
449
450        let mut server_handles: Vec<ItemHandle> = Vec::new();
451        let mut valid_indices = Vec::new();
452
453        for (idx, (item_result, error)) in results
454            .as_slice()
455            .iter()
456            .zip(errors.as_slice().iter())
457            .enumerate()
458        {
459            if error.is_ok() {
460                server_handles.push(ItemHandle(item_result.hServer));
461                valid_indices.push(idx);
462            } else {
463                let hint = format_hresult(*error);
464                tracing::warn!(
465                    tag = %tag_ids[idx],
466                    error = %hint,
467                    "read_tag_values: add_items rejected tag"
468                );
469                tag_values[idx].quality = format!("Bad — {hint}");
470            }
471        }
472
473        if server_handles.is_empty() {
474            if let Err(e) = opc_server.remove_group(server_handle, true) {
475                tracing::warn!(error = ?e, operation = "read_tag_values", "Failed to remove OPC group during cleanup");
476            }
477            return Ok(tag_values);
478        }
479
480        let (item_states, read_errors) = group.read(OPC_DS_DEVICE, &server_handles)?;
481        let item_states_slice = item_states.as_slice();
482        let read_errors_slice = read_errors.as_slice();
483
484        for (i, idx) in valid_indices.iter().enumerate() {
485            let state = &item_states_slice[i];
486            let read_error = &read_errors_slice[i];
487
488            let (value_str, quality_str) = if read_error.is_ok() {
489                (
490                    match presentation {
491                        ReadPresentation::Semantic => variant_to_string(&state.vDataValue),
492                        ReadPresentation::Display => variant_to_display_string(&state.vDataValue),
493                    },
494                    quality_to_string(state.wQuality),
495                )
496            } else {
497                let full_msg = format_hresult(*read_error);
498                tracing::warn!(
499                    tag = %tag_ids[*idx],
500                    error = ?read_error,
501                    hint = %full_msg,
502                    "read_tag_values: per-item read error"
503                );
504                ("Error".to_string(), format!("Bad — {full_msg}"))
505            };
506
507            tag_values[*idx] = TagValue {
508                tag_id: tag_ids[*idx].clone(),
509                value: value_str,
510                quality: quality_str,
511                timestamp: filetime_to_string(state.ftTimeStamp),
512            };
513        }
514
515        tracing::info!(
516            count = tag_values.len(),
517            elapsed_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
518            "read_tag_values completed"
519        );
520        if let Err(e) = opc_server.remove_group(server_handle, true) {
521            tracing::warn!(error = ?e, operation = "read_tag_values", "Failed to remove OPC group during cleanup");
522        }
523        Ok(tag_values)
524    }
525
526    #[allow(clippy::too_many_lines)]
527    fn handle_write(
528        server_name: &str,
529        tag_id: &str,
530        value: &OpcValue,
531        opc_server: &C::Server,
532    ) -> OpcResult<WriteResult> {
533        let span = tracing::info_span!(
534            "opc.write_tag_value",
535            server = %server_name,
536            tag = %tag_id
537        );
538        let _enter = span.enter();
539        #[cfg(feature = "dev-diagnostics")]
540        tracing::trace!(
541            server = %server_name,
542            tag = %tag_id,
543            value = ?value,
544            "write_tag_value: starting operation"
545        );
546        let start = std::time::Instant::now();
547
548        let mut revised_update_rate = 0u32;
549        let mut server_handle = GroupHandle::default();
550        let group = opc_server.add_group(
551            "opc-da-client-write",
552            true,
553            1000,
554            GroupHandle(0),
555            0,
556            0.0,
557            0,
558            &mut revised_update_rate,
559            &mut server_handle,
560        )?;
561
562        let mut item_id_wide: Vec<u16> = tag_id.encode_utf16().chain(std::iter::once(0)).collect();
563        let item_def = tagOPCITEMDEF {
564            szAccessPath: windows::core::PWSTR::null(),
565            szItemID: windows::core::PWSTR(item_id_wide.as_mut_ptr()),
566            bActive: windows::Win32::Foundation::TRUE,
567            hClient: 0,
568            dwBlobSize: 0,
569            pBlob: std::ptr::null_mut(),
570            vtRequestedDataType: 0,
571            wReserved: 0,
572        };
573
574        let (results, errors) = group.add_items(&[item_def])?;
575        let item_res = results
576            .as_slice()
577            .first()
578            .ok_or_else(|| OpcError::Internal("Server returned empty item results".to_string()))?;
579        let item_err = errors
580            .as_slice()
581            .first()
582            .ok_or_else(|| OpcError::Internal("Server returned empty item errors".to_string()))?;
583
584        if let Err(e) = item_err.ok() {
585            tracing::warn!(error = ?e, "write_tag_value: failed to add tag to group");
586            if let Err(e) = opc_server.remove_group(server_handle, true) {
587                tracing::warn!(error = ?e, operation = "write_tag_value", "Failed to remove OPC group during cleanup");
588            }
589            return Ok(WriteResult {
590                tag_id: tag_id.to_string(),
591                success: false,
592                error: Some(format!("Failed to add tag: {}", format_hresult(*item_err))),
593            });
594        }
595
596        let item_handle = ItemHandle(item_res.hServer);
597        let variant = opc_value_to_variant(value);
598
599        let write_errors = group.write(&[item_handle], &[variant])?;
600        let write_err = write_errors
601            .as_slice()
602            .first()
603            .ok_or_else(|| OpcError::Internal("Server returned empty write errors".to_string()))?;
604
605        let write_result = if write_err.is_ok() {
606            tracing::info!(
607                elapsed_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
608                "write_tag_value completed"
609            );
610            WriteResult {
611                tag_id: tag_id.to_string(),
612                success: true,
613                error: None,
614            }
615        } else {
616            let msg = format_hresult(*write_err);
617            tracing::warn!(
618                error = %msg,
619                elapsed_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
620                "write_tag_value: server rejected write"
621            );
622            WriteResult {
623                tag_id: tag_id.to_string(),
624                success: false,
625                error: Some(msg),
626            }
627        };
628
629        if let Err(e) = opc_server.remove_group(server_handle, true) {
630            tracing::warn!(error = ?e, operation = "write_tag_value", "Failed to remove OPC group during cleanup");
631        }
632        Ok(write_result)
633    }
634
635    fn handle_browse(
636        server_name: &str,
637        max_tags: usize,
638        progress: &Arc<AtomicUsize>,
639        tags_sink: &Arc<std::sync::Mutex<Vec<String>>>,
640        opc_server: &C::Server,
641    ) -> OpcResult<Vec<String>> {
642        let span = tracing::info_span!("opc.browse_tags", server = %server_name, max_tags);
643        let _enter = span.enter();
644        #[cfg(feature = "dev-diagnostics")]
645        tracing::trace!(
646            server = %server_name,
647            max_tags,
648            "browse_tags: starting operation"
649        );
650        let start = std::time::Instant::now();
651
652        let org = opc_server.query_organization()?;
653        let mut tags = Vec::new();
654
655        if org == OPC_NS_FLAT.0 as u32 {
656            let mut string_iter = opc_server.begin_da2_browse(OPC_LEAF.0 as u32, Some(""), 0, 0)?;
657            while let Some(tag_res) = string_iter.next_string() {
658                if tags.len() >= max_tags {
659                    break;
660                }
661                let tag = tag_res?;
662                tags.push(tag.clone());
663                if let Ok(mut sink) = tags_sink.lock() {
664                    sink.push(tag);
665                }
666                progress.fetch_add(1, Ordering::Relaxed);
667            }
668        } else {
669            Self::browse_recursive(opc_server, &mut tags, max_tags, progress, tags_sink, 0)?;
670        }
671        tracing::info!(
672            count = tags.len(),
673            elapsed_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
674            "browse_tags completed"
675        );
676        Ok(tags)
677    }
678
679    fn browse_recursive(
680        server: &C::Server,
681        tags: &mut Vec<String>,
682        max_tags: usize,
683        progress: &Arc<AtomicUsize>,
684        tags_sink: &Arc<std::sync::Mutex<Vec<String>>>,
685        depth: usize,
686    ) -> OpcResult<()> {
687        const MAX_DEPTH: usize = 50;
688        if depth > MAX_DEPTH || tags.len() >= max_tags {
689            if depth > MAX_DEPTH {
690                tracing::warn!(depth, "Max browse depth reached, truncating");
691            }
692            return Ok(());
693        }
694
695        let mut branch_enum = server.begin_da2_browse(OPC_BRANCH.0 as u32, Some(""), 0, 0)?;
696        let mut branches = Vec::new();
697        while let Some(result) = branch_enum.next_string() {
698            match result {
699                Ok(name) => branches.push(name),
700                Err(e) => {
701                    tracing::warn!(error = ?e, "Branch iteration error, skipping");
702                }
703            }
704        }
705
706        let mut leaf_enum = server.begin_da2_browse(OPC_LEAF.0 as u32, Some(""), 0, 0)?;
707        while let Some(tag_res) = leaf_enum.next_string() {
708            if tags.len() >= max_tags {
709                return Ok(());
710            }
711            let browse_name = tag_res?;
712            let tag = match server.get_item_id(&browse_name) {
713                Ok(id) => id,
714                Err(e) => {
715                    tracing::warn!(
716                        browse_name = %browse_name,
717                        error = ?e,
718                        "get_item_id failed, using browse name as fallback"
719                    );
720                    browse_name
721                }
722            };
723            tags.push(tag.clone());
724            if let Ok(mut sink) = tags_sink.lock() {
725                sink.push(tag);
726            }
727            progress.fetch_add(1, Ordering::Relaxed);
728        }
729
730        for branch in branches {
731            if tags.len() >= max_tags {
732                return Ok(());
733            }
734            if let Err(e) = server.change_browse_position(OPC_BROWSE_DOWN.0 as u32, &branch) {
735                tracing::warn!(
736                    branch = %branch,
737                    error = ?e,
738                    "Failed to browse down, skipping branch"
739                );
740                continue;
741            }
742
743            if let Err(e) =
744                Self::browse_recursive(server, tags, max_tags, progress, tags_sink, depth + 1)
745            {
746                tracing::warn!(error = ?e, "browse_recursive error");
747            }
748
749            if let Err(e) = server.change_browse_position(OPC_BROWSE_UP.0 as u32, "") {
750                tracing::warn!(error = ?e, "Failed to browse up, stopping recursion");
751                break;
752            }
753        }
754
755        Ok(())
756    }
757}
758
759impl<C: ServerConnector + 'static> Drop for ComWorker<C> {
760    fn drop(&mut self) {
761        tracing::debug!("ComWorker dropping — channel closing, signaling thread shutdown");
762    }
763}
764
765#[cfg(test)]
766mod tests {
767    #![allow(
768        clippy::single_char_pattern,
769        clippy::cast_possible_wrap,
770        clippy::ptr_as_ptr,
771        clippy::borrow_as_ptr,
772        clippy::mixed_attributes_style,
773        clippy::unreadable_literal,
774        clippy::undocumented_unsafe_blocks,
775        clippy::manual_assert
776    )]
777    use super::*;
778    use crate::backend::connector::{
779        BrowseStringIterator, ConnectedGroup, ConnectedServer, RemoteArray, ServerConnector,
780        StringIterator,
781    };
782    use crate::bindings::da::OPC_FLAT;
783    use crate::bindings::da::{tagOPCDATASOURCE, tagOPCITEMDEF, tagOPCITEMRESULT, tagOPCITEMSTATE};
784    use crate::provider::BrowseNodeFilter;
785
786    use std::sync::Mutex;
787    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
788
789    #[derive(Default)]
790    struct MockState {
791        connect_count: AtomicUsize,
792        should_fail_connect: AtomicBool,
793        should_fail_write: AtomicBool,
794        should_fail_with_connection_error: AtomicBool,
795        should_panic_on_request: AtomicBool,
796        read_value: Mutex<String>,
797    }
798
799    struct ConfigurableMockConnector {
800        state: Arc<MockState>,
801    }
802
803    struct ConfigurableMockServer {
804        state: Arc<MockState>,
805    }
806
807    struct ConfigurableMockGroup {
808        state: Arc<MockState>,
809    }
810
811    impl ConnectedGroup for ConfigurableMockGroup {
812        fn add_items(
813            &self,
814            _items: &[tagOPCITEMDEF],
815        ) -> OpcResult<(
816            RemoteArray<tagOPCITEMRESULT>,
817            RemoteArray<windows::core::HRESULT>,
818        )> {
819            use windows::Win32::Foundation::S_OK;
820
821            let res = tagOPCITEMRESULT {
822                hServer: 1,
823                vtCanonicalDataType: 0,
824                wReserved: 0,
825                dwAccessRights: 1,
826                dwBlobSize: 0,
827                pBlob: std::ptr::null_mut(),
828            };
829
830            let res_ptr = unsafe {
831                windows::Win32::System::Com::CoTaskMemAlloc(std::mem::size_of::<tagOPCITEMRESULT>())
832            } as *mut tagOPCITEMRESULT;
833            unsafe {
834                std::ptr::write(res_ptr, res);
835            }
836            let res_array = RemoteArray::from_mut_ptr(res_ptr, 1);
837
838            let err_ptr = unsafe {
839                windows::Win32::System::Com::CoTaskMemAlloc(std::mem::size_of::<
840                    windows::core::HRESULT,
841                >())
842            } as *mut windows::core::HRESULT;
843            unsafe {
844                std::ptr::write(err_ptr, S_OK);
845            }
846            let err_array = RemoteArray::from_mut_ptr(err_ptr, 1);
847
848            Ok((res_array, err_array))
849        }
850
851        fn read(
852            &self,
853            _source: tagOPCDATASOURCE,
854            _server_handles: &[crate::opc_da::typedefs::ItemHandle],
855        ) -> OpcResult<(
856            RemoteArray<tagOPCITEMSTATE>,
857            RemoteArray<windows::core::HRESULT>,
858        )> {
859            use windows::Win32::Foundation::S_OK;
860
861            let value = self.state.read_value.lock().unwrap().clone();
862            let item_state = tagOPCITEMSTATE {
863                hClient: 0,
864                ftTimeStamp: windows::Win32::Foundation::FILETIME::default(),
865                wQuality: 0xC0,
866                wReserved: 0,
867                vDataValue: opc_value_to_variant(&OpcValue::String(value)),
868            };
869            let state_ptr = unsafe {
870                windows::Win32::System::Com::CoTaskMemAlloc(std::mem::size_of::<tagOPCITEMSTATE>())
871            } as *mut tagOPCITEMSTATE;
872            unsafe {
873                std::ptr::write(state_ptr, item_state);
874            }
875
876            let error_ptr = unsafe {
877                windows::Win32::System::Com::CoTaskMemAlloc(std::mem::size_of::<
878                    windows::core::HRESULT,
879                >())
880            } as *mut windows::core::HRESULT;
881            unsafe {
882                std::ptr::write(error_ptr, S_OK);
883            }
884
885            Ok((
886                RemoteArray::from_mut_ptr(state_ptr, 1),
887                RemoteArray::from_mut_ptr(error_ptr, 1),
888            ))
889        }
890
891        fn write(
892            &self,
893            _server_handles: &[crate::opc_da::typedefs::ItemHandle],
894            _values: &[windows::Win32::System::Variant::VARIANT],
895        ) -> OpcResult<RemoteArray<windows::core::HRESULT>> {
896            if self
897                .state
898                .should_fail_with_connection_error
899                .load(Ordering::Relaxed)
900            {
901                // RPC server unavailable (0x800706BA) triggers connection eviction
902                return Err(OpcError::Com {
903                    source: windows::core::Error::from_hresult(windows::core::HRESULT(
904                        0x800706BA_u32 as i32,
905                    )),
906                });
907            }
908
909            let hr = if self.state.should_fail_write.load(Ordering::Relaxed) {
910                windows::Win32::Foundation::E_FAIL
911            } else {
912                windows::Win32::Foundation::S_OK
913            };
914
915            let hr_ptr = unsafe {
916                windows::Win32::System::Com::CoTaskMemAlloc(std::mem::size_of::<
917                    windows::core::HRESULT,
918                >())
919            } as *mut windows::core::HRESULT;
920            unsafe {
921                std::ptr::write(hr_ptr, hr);
922            }
923
924            Ok(RemoteArray::from_mut_ptr(hr_ptr, 1))
925        }
926    }
927
928    impl ConnectedServer for ConfigurableMockServer {
929        type Group = ConfigurableMockGroup;
930
931        fn query_organization(&self) -> OpcResult<u32> {
932            Ok(0)
933        }
934
935        fn browse_opc_item_ids(
936            &self,
937            _browse_type: u32,
938            _filter: Option<&str>,
939            _data_type: u16,
940            _access_rights: u32,
941        ) -> OpcResult<StringIterator> {
942            Err(OpcError::NotImplemented("mock".into()))
943        }
944
945        fn change_browse_position(&self, _direction: u32, _name: &str) -> OpcResult<()> {
946            Ok(())
947        }
948
949        fn get_item_id(&self, _item_name: &str) -> OpcResult<String> {
950            Ok(String::new())
951        }
952
953        fn add_group(
954            &self,
955            _name: &str,
956            _active: bool,
957            _update_rate: u32,
958            _client_handle: crate::opc_da::typedefs::GroupHandle,
959            _time_bias: i32,
960            _percent_deadband: f32,
961            _locale_id: u32,
962            _revised_update_rate: &mut u32,
963            _server_handle: &mut crate::opc_da::typedefs::GroupHandle,
964        ) -> OpcResult<Self::Group> {
965            if self.state.should_panic_on_request.load(Ordering::Relaxed) {
966                panic!("Simulated worker panic");
967            }
968            Ok(ConfigurableMockGroup {
969                state: self.state.clone(),
970            })
971        }
972
973        fn remove_group(
974            &self,
975            _server_group: crate::opc_da::typedefs::GroupHandle,
976            _force: bool,
977        ) -> OpcResult<()> {
978            Ok(())
979        }
980    }
981
982    impl ServerConnector for ConfigurableMockConnector {
983        type Server = ConfigurableMockServer;
984
985        fn enumerate_servers(&self) -> OpcResult<Vec<String>> {
986            if self.state.should_fail_connect.load(Ordering::Relaxed) {
987                Err(OpcError::Internal("Server enumeration failed".into()))
988            } else {
989                Ok(vec!["Mock.Server.1".into()])
990            }
991        }
992
993        fn connect(&self, _server_name: &str) -> OpcResult<Self::Server> {
994            if self.state.should_fail_connect.load(Ordering::Relaxed) {
995                Err(OpcError::Internal("Connection failed".into()))
996            } else {
997                self.state.connect_count.fetch_add(1, Ordering::Relaxed);
998                Ok(ConfigurableMockServer {
999                    state: self.state.clone(),
1000                })
1001            }
1002        }
1003    }
1004
1005    struct WorkerMockConnector;
1006    struct WorkerMockServer;
1007    struct WorkerMockGroup;
1008
1009    impl ConnectedGroup for WorkerMockGroup {
1010        fn add_items(
1011            &self,
1012            _items: &[tagOPCITEMDEF],
1013        ) -> OpcResult<(
1014            RemoteArray<tagOPCITEMRESULT>,
1015            RemoteArray<windows::core::HRESULT>,
1016        )> {
1017            Err(OpcError::NotImplemented("mock".into()))
1018        }
1019        fn read(
1020            &self,
1021            _source: tagOPCDATASOURCE,
1022            _server_handles: &[crate::opc_da::typedefs::ItemHandle],
1023        ) -> OpcResult<(
1024            RemoteArray<tagOPCITEMSTATE>,
1025            RemoteArray<windows::core::HRESULT>,
1026        )> {
1027            Err(OpcError::NotImplemented("mock".into()))
1028        }
1029        fn write(
1030            &self,
1031            _server_handles: &[crate::opc_da::typedefs::ItemHandle],
1032            _values: &[windows::Win32::System::Variant::VARIANT],
1033        ) -> OpcResult<RemoteArray<windows::core::HRESULT>> {
1034            Err(OpcError::NotImplemented("mock".into()))
1035        }
1036    }
1037
1038    impl ConnectedServer for WorkerMockServer {
1039        type Group = WorkerMockGroup;
1040        fn query_organization(&self) -> OpcResult<u32> {
1041            Err(OpcError::NotImplemented("mock".into()))
1042        }
1043        fn browse_opc_item_ids(
1044            &self,
1045            _browse_type: u32,
1046            _filter: Option<&str>,
1047            _data_type: u16,
1048            _access_rights: u32,
1049        ) -> OpcResult<StringIterator> {
1050            Err(OpcError::NotImplemented("mock".into()))
1051        }
1052        fn change_browse_position(&self, _direction: u32, _name: &str) -> OpcResult<()> {
1053            Err(OpcError::NotImplemented("mock".into()))
1054        }
1055        fn get_item_id(&self, _item_name: &str) -> OpcResult<String> {
1056            Err(OpcError::NotImplemented("mock".into()))
1057        }
1058        fn add_group(
1059            &self,
1060            _name: &str,
1061            _active: bool,
1062            _update_rate: u32,
1063            _client_handle: crate::opc_da::typedefs::GroupHandle,
1064            _time_bias: i32,
1065            _percent_deadband: f32,
1066            _locale_id: u32,
1067            _revised_update_rate: &mut u32,
1068            _server_handle: &mut crate::opc_da::typedefs::GroupHandle,
1069        ) -> OpcResult<Self::Group> {
1070            Err(OpcError::NotImplemented("mock".into()))
1071        }
1072        fn remove_group(
1073            &self,
1074            _server_group: crate::opc_da::typedefs::GroupHandle,
1075            _force: bool,
1076        ) -> OpcResult<()> {
1077            Err(OpcError::NotImplemented("mock".into()))
1078        }
1079    }
1080
1081    impl ServerConnector for WorkerMockConnector {
1082        type Server = WorkerMockServer;
1083        fn enumerate_servers(&self) -> OpcResult<Vec<String>> {
1084            Ok(vec!["Mock.Server.1".into()])
1085        }
1086        fn connect(&self, _server_name: &str) -> OpcResult<Self::Server> {
1087            Ok(WorkerMockServer)
1088        }
1089    }
1090
1091    #[tokio::test]
1092    async fn test_worker_starts_and_stops() {
1093        let worker = tokio::task::spawn_blocking(|| {
1094            ComWorker::start(Arc::new(WorkerMockConnector)).unwrap()
1095        })
1096        .await
1097        .unwrap();
1098        drop(worker);
1099    }
1100
1101    #[tokio::test]
1102    async fn test_worker_list_servers() {
1103        let worker = tokio::task::spawn_blocking(|| {
1104            ComWorker::start(Arc::new(WorkerMockConnector)).unwrap()
1105        })
1106        .await
1107        .unwrap();
1108        let (reply, _rx) = oneshot::channel();
1109        worker
1110            .sender
1111            .send(ComRequest::ListServers {
1112                host: "localhost".into(),
1113                reply,
1114            })
1115            .await
1116            .unwrap();
1117        // Wait for implementation
1118    }
1119
1120    struct MismatchedConnector;
1121    struct MismatchedServer;
1122    struct MismatchedGroup;
1123
1124    impl ConnectedGroup for MismatchedGroup {
1125        fn add_items(
1126            &self,
1127            _items: &[tagOPCITEMDEF],
1128        ) -> OpcResult<(
1129            RemoteArray<tagOPCITEMRESULT>,
1130            RemoteArray<windows::core::HRESULT>,
1131        )> {
1132            Ok((RemoteArray::empty(), RemoteArray::empty()))
1133        }
1134        fn read(
1135            &self,
1136            _source: tagOPCDATASOURCE,
1137            _server_handles: &[crate::opc_da::typedefs::ItemHandle],
1138        ) -> OpcResult<(
1139            RemoteArray<tagOPCITEMSTATE>,
1140            RemoteArray<windows::core::HRESULT>,
1141        )> {
1142            Ok((RemoteArray::empty(), RemoteArray::empty()))
1143        }
1144        fn write(
1145            &self,
1146            _server_handles: &[crate::opc_da::typedefs::ItemHandle],
1147            _values: &[windows::Win32::System::Variant::VARIANT],
1148        ) -> OpcResult<RemoteArray<windows::core::HRESULT>> {
1149            Ok(RemoteArray::empty())
1150        }
1151    }
1152
1153    impl ConnectedServer for MismatchedServer {
1154        type Group = MismatchedGroup;
1155        fn query_organization(&self) -> OpcResult<u32> {
1156            Ok(0)
1157        }
1158        fn browse_opc_item_ids(
1159            &self,
1160            _b: u32,
1161            _f: Option<&str>,
1162            _d: u16,
1163            _a: u32,
1164        ) -> OpcResult<StringIterator> {
1165            Err(OpcError::NotImplemented("mock".into()))
1166        }
1167        fn change_browse_position(&self, _direction: u32, _name: &str) -> OpcResult<()> {
1168            Ok(())
1169        }
1170        fn get_item_id(&self, _item_name: &str) -> OpcResult<String> {
1171            Ok(String::new())
1172        }
1173        fn add_group(
1174            &self,
1175            _name: &str,
1176            _active: bool,
1177            _update_rate: u32,
1178            _client_handle: crate::opc_da::typedefs::GroupHandle,
1179            _time_bias: i32,
1180            _percent_deadband: f32,
1181            _locale_id: u32,
1182            _revised_update_rate: &mut u32,
1183            _server_handle: &mut crate::opc_da::typedefs::GroupHandle,
1184        ) -> OpcResult<Self::Group> {
1185            Ok(MismatchedGroup)
1186        }
1187        fn remove_group(
1188            &self,
1189            _server_group: crate::opc_da::typedefs::GroupHandle,
1190            _force: bool,
1191        ) -> OpcResult<()> {
1192            Ok(())
1193        }
1194    }
1195
1196    impl ServerConnector for MismatchedConnector {
1197        type Server = MismatchedServer;
1198        fn enumerate_servers(&self) -> OpcResult<Vec<String>> {
1199            Ok(vec![])
1200        }
1201        fn connect(&self, _server_name: &str) -> OpcResult<Self::Server> {
1202            Ok(MismatchedServer)
1203        }
1204    }
1205
1206    #[tokio::test]
1207    async fn test_worker_read_tag_values_mismatched_lengths() {
1208        let worker = tokio::task::spawn_blocking(|| {
1209            ComWorker::start(Arc::new(MismatchedConnector)).unwrap()
1210        })
1211        .await
1212        .unwrap();
1213
1214        let result = worker
1215            .send_request(|reply| ComRequest::ReadTagValues {
1216                server: "MockServer".to_string(),
1217                tag_ids: vec!["Tag1".to_string(), "Tag2".to_string()],
1218                presentation: ReadPresentation::Semantic,
1219                reply,
1220            })
1221            .await;
1222
1223        assert!(
1224            result.is_err(),
1225            "Expected read to fail due to mismatched lengths"
1226        );
1227        if let Err(OpcError::Internal(msg)) = result {
1228            assert!(msg.contains("mismatched result array sizes"));
1229        } else {
1230            panic!("Expected OpcError::Internal, got {:?}", result);
1231        }
1232    }
1233
1234    #[tokio::test]
1235    async fn test_worker_routes_read_presentation() {
1236        let state = Arc::new(MockState {
1237            read_value: Mutex::new("AUT".to_string()),
1238            ..MockState::default()
1239        });
1240        let connector = Arc::new(ConfigurableMockConnector { state });
1241        let worker = tokio::task::spawn_blocking(move || ComWorker::start(connector).unwrap())
1242            .await
1243            .unwrap();
1244
1245        let semantic = worker
1246            .send_request(|reply| ComRequest::ReadTagValues {
1247                server: "Mock.Server.1".to_string(),
1248                tag_ids: vec!["StringTag".to_string()],
1249                presentation: ReadPresentation::Semantic,
1250                reply,
1251            })
1252            .await
1253            .unwrap();
1254        assert_eq!(semantic[0].value, "AUT");
1255
1256        let display = worker
1257            .send_request(|reply| ComRequest::ReadTagValues {
1258                server: "Mock.Server.1".to_string(),
1259                tag_ids: vec!["StringTag".to_string()],
1260                presentation: ReadPresentation::Display,
1261                reply,
1262            })
1263            .await
1264            .unwrap();
1265        assert_eq!(display[0].value, "\"AUT\"");
1266    }
1267
1268    #[tokio::test]
1269    async fn test_worker_write_tag_value() {
1270        let state = Arc::new(MockState::default());
1271        let connector = Arc::new(ConfigurableMockConnector {
1272            state: state.clone(),
1273        });
1274        let worker = tokio::task::spawn_blocking(move || ComWorker::start(connector).unwrap())
1275            .await
1276            .unwrap();
1277
1278        let result = worker
1279            .send_request(|reply| ComRequest::WriteTagValue {
1280                server: "Mock.Server.1".to_string(),
1281                tag_id: "Random.Int4".to_string(),
1282                value: OpcValue::Int(42),
1283                reply,
1284            })
1285            .await
1286            .expect("Request should succeed");
1287
1288        assert_eq!(result.tag_id, "Random.Int4");
1289        assert!(result.success, "Write should be successful");
1290        assert!(result.error.is_none());
1291    }
1292
1293    #[tokio::test]
1294    async fn test_connection_cache_reuse() {
1295        let state = Arc::new(MockState::default());
1296        let connector = Arc::new(ConfigurableMockConnector {
1297            state: state.clone(),
1298        });
1299        let worker = tokio::task::spawn_blocking(move || ComWorker::start(connector).unwrap())
1300            .await
1301            .unwrap();
1302
1303        let _ = worker
1304            .send_request(|reply| ComRequest::WriteTagValue {
1305                server: "Mock.Server.1".to_string(),
1306                tag_id: "Tag1".to_string(),
1307                value: OpcValue::Int(1),
1308                reply,
1309            })
1310            .await
1311            .unwrap();
1312
1313        let _ = worker
1314            .send_request(|reply| ComRequest::WriteTagValue {
1315                server: "Mock.Server.1".to_string(),
1316                tag_id: "Tag2".to_string(),
1317                value: OpcValue::Int(2),
1318                reply,
1319            })
1320            .await
1321            .unwrap();
1322
1323        assert_eq!(
1324            state.connect_count.load(Ordering::Relaxed),
1325            1,
1326            "Server connection should be cached and reused"
1327        );
1328    }
1329
1330    #[tokio::test]
1331    async fn test_stale_connection_eviction() {
1332        let state = Arc::new(MockState::default());
1333        let connector = Arc::new(ConfigurableMockConnector {
1334            state: state.clone(),
1335        });
1336        let worker = tokio::task::spawn_blocking(move || ComWorker::start(connector).unwrap())
1337            .await
1338            .unwrap();
1339
1340        // Initial connect
1341        let _ = worker
1342            .send_request(|reply| ComRequest::WriteTagValue {
1343                server: "Mock.Server.1".to_string(),
1344                tag_id: "Tag1".to_string(),
1345                value: OpcValue::Int(1),
1346                reply,
1347            })
1348            .await
1349            .unwrap();
1350
1351        assert_eq!(state.connect_count.load(Ordering::Relaxed), 1);
1352
1353        // Enable connection error flag to trigger eviction on next operation
1354        state
1355            .should_fail_with_connection_error
1356            .store(true, Ordering::Relaxed);
1357
1358        // Next request triggers eviction and reconnect attempt
1359        let _ = worker
1360            .send_request(|reply| ComRequest::WriteTagValue {
1361                server: "Mock.Server.1".to_string(),
1362                tag_id: "Tag2".to_string(),
1363                value: OpcValue::Int(2),
1364                reply,
1365            })
1366            .await;
1367
1368        assert_eq!(
1369            state.connect_count.load(Ordering::Relaxed),
1370            2,
1371            "Stale connection should be evicted and reconnected"
1372        );
1373    }
1374
1375    #[tokio::test]
1376    async fn test_worker_panic_propagation() {
1377        let state = Arc::new(MockState::default());
1378        state.should_panic_on_request.store(true, Ordering::Relaxed);
1379        let connector = Arc::new(ConfigurableMockConnector {
1380            state: state.clone(),
1381        });
1382        let worker = tokio::task::spawn_blocking(move || ComWorker::start(connector).unwrap())
1383            .await
1384            .unwrap();
1385
1386        let result = worker
1387            .send_request(|reply| ComRequest::WriteTagValue {
1388                server: "Mock.Server.1".to_string(),
1389                tag_id: "Tag1".to_string(),
1390                value: OpcValue::Int(1),
1391                reply,
1392            })
1393            .await;
1394
1395        assert!(result.is_err());
1396        if let Err(OpcError::Internal(msg)) = result {
1397            assert!(
1398                msg.contains("shut down")
1399                    || msg.contains("channel closed")
1400                    || msg.contains("panicked"),
1401                "Expected worker termination message, got: {}",
1402                msg
1403            );
1404        } else {
1405            panic!("Expected OpcError::Internal, got {:?}", result);
1406        }
1407    }
1408
1409    #[tokio::test]
1410    async fn test_drop_during_active_request() {
1411        let state = Arc::new(MockState::default());
1412        let connector = Arc::new(ConfigurableMockConnector {
1413            state: state.clone(),
1414        });
1415        let worker = tokio::task::spawn_blocking(move || ComWorker::start(connector).unwrap())
1416            .await
1417            .unwrap();
1418
1419        // Dropping worker handle closes channel gracefully
1420        drop(worker);
1421    }
1422
1423    #[tokio::test]
1424    async fn test_worker_init_failure() {
1425        struct FailingInitConnector;
1426        impl ServerConnector for FailingInitConnector {
1427            type Server = ConfigurableMockServer;
1428            fn enumerate_servers(&self) -> OpcResult<Vec<String>> {
1429                Err(OpcError::Internal("COM subsystem failed".into()))
1430            }
1431            fn connect(&self, _name: &str) -> OpcResult<Self::Server> {
1432                Err(OpcError::Internal("COM subsystem failed".into()))
1433            }
1434        }
1435
1436        let worker = tokio::task::spawn_blocking(|| {
1437            ComWorker::start(Arc::new(FailingInitConnector)).unwrap()
1438        })
1439        .await
1440        .unwrap();
1441
1442        let result = worker
1443            .send_request(|reply| ComRequest::ListServers {
1444                host: "localhost".into(),
1445                reply,
1446            })
1447            .await;
1448
1449        assert!(
1450            result.is_err(),
1451            "ListServers request should fail when connector enumeration fails"
1452        );
1453    }
1454
1455    #[derive(Default)]
1456    struct BranchOnlyFlatState {
1457        flat_calls: AtomicUsize,
1458        position: Mutex<Vec<String>>,
1459    }
1460
1461    struct BranchOnlyFlatConnector {
1462        state: Arc<BranchOnlyFlatState>,
1463    }
1464
1465    struct BranchOnlyFlatServer {
1466        state: Arc<BranchOnlyFlatState>,
1467    }
1468
1469    impl ConnectedServer for BranchOnlyFlatServer {
1470        type Group = WorkerMockGroup;
1471
1472        fn query_organization(&self) -> OpcResult<u32> {
1473            Ok(crate::bindings::da::OPC_NS_HIERARCHIAL.0.cast_unsigned())
1474        }
1475
1476        fn browse_opc_item_ids(
1477            &self,
1478            _browse_type: u32,
1479            _filter: Option<&str>,
1480            _data_type: u16,
1481            _access_rights: u32,
1482        ) -> OpcResult<StringIterator> {
1483            Err(OpcError::NotImplemented("mock".to_string()))
1484        }
1485
1486        fn begin_da2_browse(
1487            &self,
1488            browse_type: u32,
1489            _filter: Option<&str>,
1490            _data_type: u16,
1491            _access_rights: u32,
1492        ) -> OpcResult<Box<dyn BrowseStringIterator>> {
1493            let position = self.state.position.lock().unwrap();
1494            let values = if browse_type == OPC_FLAT.0.cast_unsigned() {
1495                self.state.flat_calls.fetch_add(1, Ordering::Relaxed);
1496                vec!["Area".to_string()]
1497            } else if browse_type == OPC_BRANCH.0.cast_unsigned() && position.is_empty() {
1498                vec!["Area".to_string()]
1499            } else if browse_type == OPC_LEAF.0.cast_unsigned() && position.as_slice() == ["Area"] {
1500                vec!["Tag".to_string()]
1501            } else {
1502                vec![]
1503            };
1504            Ok(Box::new(values.into_iter().map(Ok)))
1505        }
1506
1507        fn change_browse_position(&self, direction: u32, name: &str) -> OpcResult<()> {
1508            let mut position = self.state.position.lock().unwrap();
1509            if direction == OPC_BROWSE_DOWN.0.cast_unsigned() {
1510                position.push(name.to_string());
1511            } else if direction == OPC_BROWSE_UP.0.cast_unsigned() {
1512                position.pop();
1513            }
1514            drop(position);
1515            Ok(())
1516        }
1517
1518        fn get_item_id(&self, item_name: &str) -> OpcResult<String> {
1519            let position = self.state.position.lock().unwrap();
1520            let item_id = format!("{}.{}", position.join("."), item_name);
1521            drop(position);
1522            Ok(item_id)
1523        }
1524
1525        fn add_group(
1526            &self,
1527            _name: &str,
1528            _active: bool,
1529            _update_rate: u32,
1530            _client_handle: GroupHandle,
1531            _time_bias: i32,
1532            _percent_deadband: f32,
1533            _locale_id: u32,
1534            _revised_update_rate: &mut u32,
1535            _server_handle: &mut GroupHandle,
1536        ) -> OpcResult<Self::Group> {
1537            Err(OpcError::NotImplemented("mock".to_string()))
1538        }
1539
1540        fn remove_group(&self, _server_group: GroupHandle, _force: bool) -> OpcResult<()> {
1541            Err(OpcError::NotImplemented("mock".to_string()))
1542        }
1543    }
1544
1545    impl ServerConnector for BranchOnlyFlatConnector {
1546        type Server = BranchOnlyFlatServer;
1547
1548        fn enumerate_servers(&self) -> OpcResult<Vec<String>> {
1549            Ok(vec![])
1550        }
1551
1552        fn connect(&self, _server_name: &str) -> OpcResult<Self::Server> {
1553            Ok(BranchOnlyFlatServer {
1554                state: self.state.clone(),
1555            })
1556        }
1557    }
1558
1559    #[tokio::test]
1560    async fn hierarchical_browse_does_not_treat_branch_only_opc_flat_as_items() {
1561        let state = Arc::new(BranchOnlyFlatState::default());
1562        let connector = Arc::new(BranchOnlyFlatConnector {
1563            state: state.clone(),
1564        });
1565        let worker = tokio::task::spawn_blocking(move || ComWorker::start(connector).unwrap())
1566            .await
1567            .unwrap();
1568
1569        let result = worker
1570            .send_request(|reply| ComRequest::BrowseTags {
1571                server: "Mock.Server".to_string(),
1572                max_tags: 10,
1573                progress: Arc::new(AtomicUsize::new(0)),
1574                tags_sink: Arc::new(Mutex::new(Vec::new())),
1575                reply,
1576            })
1577            .await
1578            .unwrap();
1579
1580        assert_eq!(result, vec!["Area.Tag"]);
1581        assert_eq!(state.flat_calls.load(Ordering::Relaxed), 0);
1582    }
1583
1584    #[derive(Default)]
1585    struct CancelledBrowseState {
1586        connect_count: AtomicUsize,
1587        drop_count: AtomicUsize,
1588    }
1589
1590    struct CancelledBrowseConnector {
1591        state: Arc<CancelledBrowseState>,
1592    }
1593
1594    struct CancelledBrowseServer {
1595        state: Arc<CancelledBrowseState>,
1596    }
1597
1598    impl Drop for CancelledBrowseServer {
1599        fn drop(&mut self) {
1600            self.state.drop_count.fetch_add(1, Ordering::Relaxed);
1601        }
1602    }
1603
1604    impl ConnectedServer for CancelledBrowseServer {
1605        type Group = WorkerMockGroup;
1606
1607        fn query_organization(&self) -> OpcResult<u32> {
1608            Ok(OPC_NS_FLAT.0.cast_unsigned())
1609        }
1610
1611        fn browse_opc_item_ids(
1612            &self,
1613            _browse_type: u32,
1614            _filter: Option<&str>,
1615            _data_type: u16,
1616            _access_rights: u32,
1617        ) -> OpcResult<StringIterator> {
1618            Err(OpcError::NotImplemented("mock".to_string()))
1619        }
1620
1621        fn begin_da2_browse(
1622            &self,
1623            _browse_type: u32,
1624            _filter: Option<&str>,
1625            _data_type: u16,
1626            _access_rights: u32,
1627        ) -> OpcResult<Box<dyn BrowseStringIterator>> {
1628            Ok(Box::new(std::iter::empty()))
1629        }
1630
1631        fn change_browse_position(&self, _direction: u32, _name: &str) -> OpcResult<()> {
1632            Ok(())
1633        }
1634
1635        fn get_item_id(&self, _item_name: &str) -> OpcResult<String> {
1636            Err(OpcError::NotImplemented("mock".to_string()))
1637        }
1638
1639        fn add_group(
1640            &self,
1641            _name: &str,
1642            _active: bool,
1643            _update_rate: u32,
1644            _client_handle: GroupHandle,
1645            _time_bias: i32,
1646            _percent_deadband: f32,
1647            _locale_id: u32,
1648            _revised_update_rate: &mut u32,
1649            _server_handle: &mut GroupHandle,
1650        ) -> OpcResult<Self::Group> {
1651            Err(OpcError::NotImplemented("mock".to_string()))
1652        }
1653
1654        fn remove_group(&self, _server_group: GroupHandle, _force: bool) -> OpcResult<()> {
1655            Ok(())
1656        }
1657    }
1658
1659    impl ServerConnector for CancelledBrowseConnector {
1660        type Server = CancelledBrowseServer;
1661
1662        fn enumerate_servers(&self) -> OpcResult<Vec<String>> {
1663            Ok(vec![])
1664        }
1665
1666        fn connect(&self, _server_name: &str) -> OpcResult<Self::Server> {
1667            self.state.connect_count.fetch_add(1, Ordering::Relaxed);
1668            Ok(CancelledBrowseServer {
1669                state: self.state.clone(),
1670            })
1671        }
1672    }
1673
1674    #[tokio::test]
1675    async fn cancelled_native_browse_requests_release_or_avoid_sessions() {
1676        let state = Arc::new(CancelledBrowseState::default());
1677        let connector = Arc::new(CancelledBrowseConnector {
1678            state: state.clone(),
1679        });
1680        let worker = tokio::task::spawn_blocking(move || ComWorker::start(connector).unwrap())
1681            .await
1682            .unwrap();
1683
1684        let session = worker
1685            .send_request(|reply| ComRequest::OpenBrowseSession {
1686                server: "Mock.Server".to_string(),
1687                reply,
1688            })
1689            .await
1690            .unwrap();
1691        assert_eq!(state.connect_count.load(Ordering::Relaxed), 1);
1692
1693        let (page_reply, page_receiver) = oneshot::channel();
1694        drop(page_receiver);
1695        worker
1696            .sender
1697            .send(ComRequest::BrowsePage {
1698                session,
1699                request: BrowsePageRequest {
1700                    parent: None,
1701                    filter: BrowseNodeFilter::All,
1702                    max_elements: 10,
1703                    continuation: None,
1704                },
1705                reply: page_reply,
1706            })
1707            .await
1708            .unwrap();
1709        worker
1710            .send_request(|reply| ComRequest::ListServers {
1711                host: "localhost".to_string(),
1712                reply,
1713            })
1714            .await
1715            .unwrap();
1716        assert_eq!(state.drop_count.load(Ordering::Relaxed), 1);
1717
1718        let (open_reply, open_receiver) = oneshot::channel();
1719        drop(open_receiver);
1720        worker
1721            .sender
1722            .send(ComRequest::OpenBrowseSession {
1723                server: "Mock.Server".to_string(),
1724                reply: open_reply,
1725            })
1726            .await
1727            .unwrap();
1728        worker
1729            .send_request(|reply| ComRequest::ListServers {
1730                host: "localhost".to_string(),
1731                reply,
1732            })
1733            .await
1734            .unwrap();
1735        assert_eq!(state.connect_count.load(Ordering::Relaxed), 1);
1736    }
1737}