Skip to main content

pb_mapper_client/sdk/
handle.rs

1//! Handles for a running tunnel: [`Registration`] and [`Connection`].
2//!
3//! Both wrap the same `LiveTunnel` — a cancellation token, the worker's join
4//! handle, and the status channel it publishes to. A handle observes and stops
5//! its tunnel; it never drives the traffic itself.
6
7use std::time::Duration;
8
9use tokio::sync::{Mutex, watch};
10use tokio::task::JoinHandle;
11use tokio_util::sync::CancellationToken;
12
13use super::{Error, Result, TunnelStatus};
14
15pub(crate) struct LiveTunnel {
16    shutdown: CancellationToken,
17    join: Mutex<Option<JoinHandle<()>>>,
18    status: watch::Receiver<TunnelStatus>,
19}
20
21impl LiveTunnel {
22    pub(crate) fn new(
23        shutdown: CancellationToken,
24        join: JoinHandle<()>,
25        status: watch::Receiver<TunnelStatus>,
26    ) -> Self {
27        Self {
28            shutdown,
29            join: Mutex::new(Some(join)),
30            status,
31        }
32    }
33
34    pub(crate) fn status(&self) -> TunnelStatus {
35        self.status.borrow().clone()
36    }
37
38    pub(crate) fn subscribe(&self) -> watch::Receiver<TunnelStatus> {
39        self.status.clone()
40    }
41
42    pub(crate) async fn wait_ready(&self) -> Result<()> {
43        wait_for_connected(&mut self.status.clone()).await
44    }
45
46    pub(crate) async fn wait_ready_timeout(&self, timeout: Duration) -> Result<()> {
47        match tokio::time::timeout(timeout, self.wait_ready()).await {
48            Ok(result) => result,
49            Err(_) => Err(Error::ReadyTimeout { timeout }),
50        }
51    }
52
53    pub(crate) async fn stop(&self) -> Result<()> {
54        self.shutdown.cancel();
55        // Retain ownership while awaiting: cancellation of stop must not detach
56        // the worker, and concurrent stop calls must observe the same cleanup.
57        let mut slot = self.join.lock().await;
58        if let Some(handle) = slot.as_mut() {
59            match tokio::time::timeout(Duration::from_secs(5), &mut *handle).await {
60                Ok(Ok(())) => {}
61                Ok(Err(join_error)) if join_error.is_cancelled() => {}
62                Ok(Err(join_error)) => {
63                    slot.take();
64                    return Err(Error::protocol(format!("tunnel task failed: {join_error}")));
65                }
66                Err(_) => {
67                    handle.abort();
68                    let _ = handle.await;
69                }
70            }
71            slot.take();
72        }
73        Ok(())
74    }
75}
76
77impl Drop for LiveTunnel {
78    fn drop(&mut self) {
79        self.shutdown.cancel();
80        if let Some(handle) = self.join.get_mut().take() {
81            handle.abort();
82        }
83    }
84}
85
86async fn wait_for_connected(status: &mut watch::Receiver<TunnelStatus>) -> Result<()> {
87    loop {
88        match status.borrow().clone() {
89            TunnelStatus::Connected => return Ok(()),
90            TunnelStatus::Failed(reason) => return Err(Error::TunnelFailed { reason }),
91            TunnelStatus::Stopped => return Err(Error::Stopped),
92            TunnelStatus::Starting | TunnelStatus::Retrying => {}
93        }
94        status.changed().await.map_err(|_| Error::Stopped)?;
95    }
96}
97
98/// Declares one end of a live tunnel as a public handle.
99///
100/// [`Registration`] and [`Connection`] share a lifecycle down to the last method:
101/// the same status, the same readiness wait, the same stop. They stay distinct
102/// types so neither can be passed where the other is meant, and this macro is
103/// what keeps the two from drifting apart.
104macro_rules! tunnel_handle {
105    ($(#[$doc:meta])* $name:ident) => {
106        $(#[$doc])*
107        pub struct $name {
108            inner: LiveTunnel,
109            key: String,
110        }
111
112        impl $name {
113            pub(crate) fn new(inner: LiveTunnel, key: String) -> Self {
114                Self { inner, key }
115            }
116
117            /// The service key this tunnel is bound to.
118            pub fn key(&self) -> &str {
119                &self.key
120            }
121
122            /// The latest status its worker reported.
123            pub fn status(&self) -> TunnelStatus {
124                self.inner.status()
125            }
126
127            /// Subscribe to status changes. Useful for N-API event bridges.
128            pub fn subscribe(&self) -> watch::Receiver<TunnelStatus> {
129                self.inner.subscribe()
130            }
131
132            /// Resolve once the tunnel is connected, or fails, or stops.
133            pub async fn wait_ready(&self) -> Result<()> {
134                self.inner.wait_ready().await
135            }
136
137            /// [`Self::wait_ready`], bounded by `timeout`.
138            pub async fn wait_ready_timeout(&self, timeout: Duration) -> Result<()> {
139                self.inner.wait_ready_timeout(timeout).await
140            }
141
142            /// Cancel the worker and wait for it to unwind.
143            pub async fn stop(&self) -> Result<()> {
144                self.inner.stop().await
145            }
146        }
147    };
148}
149
150tunnel_handle!(
151    /// A live `register` tunnel: a local service published on the relay.
152    Registration
153);
154
155tunnel_handle!(
156    /// A live `connect` tunnel: a local listener forwarding to a registered service.
157    Connection
158);
159
160#[cfg(test)]
161#[allow(clippy::unwrap_used)]
162mod tests {
163    use super::*;
164    use std::{future::Future, task::Poll};
165
166    async fn poll_pending(future: &mut std::pin::Pin<Box<impl Future>>) {
167        std::future::poll_fn(|cx| {
168            assert!(future.as_mut().poll(cx).is_pending());
169            Poll::Ready(())
170        })
171        .await;
172    }
173
174    #[tokio::test]
175    async fn cancelled_stop_keeps_worker_owned_until_handle_drop() {
176        let (tx, mut rx) = watch::channel(TunnelStatus::Starting);
177        let (started_tx, started_rx) = tokio::sync::oneshot::channel();
178        let worker = tokio::spawn(async move {
179            let _tx = tx;
180            let _ = started_tx.send(());
181            std::future::pending::<()>().await;
182        });
183        let tunnel = LiveTunnel::new(CancellationToken::new(), worker, rx.clone());
184        started_rx.await.unwrap();
185        let mut stopping = Box::pin(tunnel.stop());
186        poll_pending(&mut stopping).await;
187        drop(stopping);
188        drop(tunnel);
189        assert!(
190            tokio::time::timeout(Duration::from_secs(1), rx.changed())
191                .await
192                .unwrap()
193                .is_err()
194        );
195    }
196
197    #[tokio::test]
198    async fn concurrent_stop_calls_both_wait_for_cleanup() {
199        let (tx, rx) = watch::channel(TunnelStatus::Starting);
200        let (release, released) = tokio::sync::oneshot::channel();
201        let worker = tokio::spawn(async move {
202            let _tx = tx;
203            let _ = released.await;
204        });
205        let tunnel = LiveTunnel::new(CancellationToken::new(), worker, rx);
206        let mut first = Box::pin(tunnel.stop());
207        let mut second = Box::pin(tunnel.stop());
208        poll_pending(&mut first).await;
209        poll_pending(&mut second).await;
210        release.send(()).unwrap();
211        first.await.unwrap();
212        second.await.unwrap();
213    }
214}