Skip to main content

wowlab_node/core/
unlink.rs

1//! Observable unlink execution and lifecycle completion.
2
3use std::{fmt, pin::Pin};
4
5use tokio::sync::mpsc;
6use tokio_util::sync::CancellationToken;
7
8use super::{NodeCore, NodeCoreEvent};
9use crate::{
10    ConnectionStatus, NodeState,
11    config::{IdentityCleanupOutcome, NodeKeyStore},
12    sentinel::{SentinelClient, SentinelError, SentinelUnlinkOutcome},
13};
14
15/// Result of the remote unlink operation.
16#[derive(Clone, Copy, Debug, Eq, PartialEq)]
17#[non_exhaustive]
18pub enum RemoteUnlinkOutcome {
19    Removed,
20    AlreadyAbsent,
21}
22
23/// Result of deleting the persisted local node identity.
24#[derive(Clone, Copy, Debug, Eq, PartialEq)]
25#[non_exhaustive]
26pub enum LocalIdentityOutcome {
27    Removed,
28    AlreadyAbsent,
29}
30
31/// Completed remote and local unlink outcome.
32#[derive(Clone, Copy, Debug, Eq, PartialEq)]
33pub struct UnlinkOutcome {
34    remote: RemoteUnlinkOutcome,
35    local_identity: LocalIdentityOutcome,
36}
37
38impl UnlinkOutcome {
39    /// Construct a completed unlink outcome.
40    #[must_use]
41    pub const fn new(remote: RemoteUnlinkOutcome, local_identity: LocalIdentityOutcome) -> Self {
42        Self {
43            remote,
44            local_identity,
45        }
46    }
47
48    /// Remote unlink result.
49    #[must_use]
50    pub const fn remote(&self) -> RemoteUnlinkOutcome {
51        self.remote
52    }
53
54    /// Local identity-cleanup result.
55    #[must_use]
56    pub const fn local_identity(&self) -> LocalIdentityOutcome {
57        self.local_identity
58    }
59}
60
61/// Stable category for an unlink failure.
62#[derive(Clone, Copy, Debug, Eq, PartialEq)]
63#[non_exhaustive]
64pub enum UnlinkErrorCategory {
65    Remote,
66    LocalIdentity,
67    Cancelled,
68}
69
70/// Failure to complete the Node-owned unlink lifecycle.
71#[derive(thiserror::Error)]
72#[error("{kind}")]
73#[non_exhaustive]
74pub struct UnlinkError {
75    #[source]
76    kind: UnlinkErrorKind,
77}
78
79#[derive(Debug, thiserror::Error)]
80enum UnlinkErrorKind {
81    #[error("Failed to unlink node: {0}")]
82    Remote(#[source] SentinelError),
83    #[error("Node was unlinked remotely, but local identity cleanup failed: {source}")]
84    LocalIdentity {
85        remote: RemoteUnlinkOutcome,
86        #[source]
87        source: wowlab_fs::error::Error,
88    },
89    #[error("Node unlink was cancelled")]
90    Cancelled,
91}
92
93impl UnlinkError {
94    fn remote(source: SentinelError) -> Self {
95        Self {
96            kind: UnlinkErrorKind::Remote(source),
97        }
98    }
99
100    fn local_identity(remote: RemoteUnlinkOutcome, source: wowlab_fs::error::Error) -> Self {
101        Self {
102            kind: UnlinkErrorKind::LocalIdentity { remote, source },
103        }
104    }
105
106    fn cancelled() -> Self {
107        Self {
108            kind: UnlinkErrorKind::Cancelled,
109        }
110    }
111
112    /// Stable failure category.
113    #[must_use]
114    pub const fn category(&self) -> UnlinkErrorCategory {
115        match &self.kind {
116            UnlinkErrorKind::Remote(_) => UnlinkErrorCategory::Remote,
117            UnlinkErrorKind::LocalIdentity { .. } => UnlinkErrorCategory::LocalIdentity,
118            UnlinkErrorKind::Cancelled => UnlinkErrorCategory::Cancelled,
119        }
120    }
121
122    /// Completed remote outcome when only local cleanup failed.
123    #[must_use]
124    pub const fn remote_outcome(&self) -> Option<RemoteUnlinkOutcome> {
125        match &self.kind {
126            UnlinkErrorKind::LocalIdentity { remote, .. } => Some(*remote),
127            UnlinkErrorKind::Remote(_) | UnlinkErrorKind::Cancelled => None,
128        }
129    }
130}
131
132impl fmt::Debug for UnlinkError {
133    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
134        f.debug_struct("UnlinkError")
135            .field("category", &self.category())
136            .field("remote_outcome", &self.remote_outcome())
137            .finish_non_exhaustive()
138    }
139}
140
141pub(super) type UnlinkResult = Result<UnlinkOutcome, UnlinkError>;
142pub(super) type UnlinkReceiver = mpsc::Receiver<UnlinkResult>;
143type UnlinkFuture<'a> =
144    Pin<Box<dyn Future<Output = Result<SentinelUnlinkOutcome, SentinelError>> + Send + 'a>>;
145
146trait RemoteUnlinkExecutor: Send + Sync {
147    fn unlink(&self) -> UnlinkFuture<'_>;
148}
149
150impl RemoteUnlinkExecutor for SentinelClient {
151    fn unlink(&self) -> UnlinkFuture<'_> {
152        Box::pin(self.unlink())
153    }
154}
155
156#[derive(Clone, Debug)]
157pub(super) struct UnlinkOrigin {
158    state: NodeState,
159    connection: ConnectionStatus,
160    registered: bool,
161}
162
163impl UnlinkOrigin {
164    fn capture(core: &NodeCore) -> Self {
165        Self {
166            state: core.state.clone(),
167            connection: core.connection_status,
168            registered: core.registered,
169        }
170    }
171}
172
173#[derive(Clone, Debug, Eq, PartialEq)]
174struct UnlinkTerminalState {
175    state: NodeState,
176    connection: ConnectionStatus,
177    registered: bool,
178    restart_realtime: bool,
179}
180
181fn terminal_state(origin: &UnlinkOrigin, result: &UnlinkResult) -> UnlinkTerminalState {
182    if result.is_ok() {
183        return UnlinkTerminalState {
184            state: NodeState::Unlinked,
185            connection: ConnectionStatus::Disconnected,
186            registered: false,
187            restart_realtime: false,
188        };
189    }
190
191    UnlinkTerminalState {
192        state: origin.state.clone(),
193        connection: origin.connection,
194        registered: origin.registered,
195        restart_realtime: origin.registered && matches!(origin.state, NodeState::Running),
196    }
197}
198
199impl NodeCore {
200    /// Start an observable unlink operation; repeated calls while active are idempotent.
201    pub fn request_unlink(&mut self) {
202        if self.unlink_rx.is_some() {
203            return;
204        }
205
206        let origin = UnlinkOrigin::capture(self);
207        let sentinel = self.sentinel.clone();
208        let key_store = self.config.key_store();
209        let cancellation = CancellationToken::new();
210        let task_cancellation = cancellation.clone();
211        let (sender, receiver) = mpsc::channel(1);
212
213        self.unlink_origin = Some(origin);
214        self.unlink_cancel = Some(cancellation);
215        self.unlink_rx = Some(receiver);
216        self.set_unlink_state(NodeState::Unlinking);
217        self.disconnect_for_unlink();
218
219        self.runtime.spawn(async move {
220            let result = execute_unlink(&sentinel, &key_store, &task_cancellation).await;
221            let _ = sender.send(result).await;
222        });
223    }
224
225    /// Cancel an active unlink without deleting the persisted identity.
226    pub fn cancel_unlink(&self) {
227        if let Some(cancellation) = &self.unlink_cancel {
228            cancellation.cancel();
229        }
230    }
231
232    /// Whether an unlink request is awaiting terminal completion.
233    pub fn is_unlinking(&self) -> bool {
234        self.unlink_rx.is_some()
235    }
236
237    pub(super) fn check_unlink(&mut self) {
238        let Some(receiver) = self.unlink_rx.as_mut() else {
239            return;
240        };
241
242        let result = match receiver.try_recv() {
243            Ok(result) => result,
244            Err(mpsc::error::TryRecvError::Empty) => return,
245            Err(mpsc::error::TryRecvError::Disconnected) => Err(UnlinkError::cancelled()),
246        };
247
248        self.unlink_rx = None;
249        self.unlink_cancel = None;
250        let Some(origin) = self.unlink_origin.take() else {
251            let _ = self
252                .unlink_event_tx
253                .send(NodeCoreEvent::UnlinkCompleted(
254                    Err(UnlinkError::cancelled()),
255                ));
256
257            return;
258        };
259
260        let terminal = terminal_state(&origin, &result);
261
262        self.registered = terminal.registered;
263        self.set_unlink_state(terminal.state);
264        self.set_unlink_connection(terminal.connection);
265
266        match &result {
267            Ok(_) => {
268                self.config.token_claim = None;
269            }
270            Err(error) => {
271                tracing::warn!(%error, "Node unlink did not complete");
272
273                if terminal.restart_realtime {
274                    self.start_realtime();
275                }
276            }
277        }
278
279        let _ = self
280            .unlink_event_tx
281            .send(NodeCoreEvent::UnlinkCompleted(result));
282    }
283
284    fn set_unlink_state(&mut self, state: NodeState) {
285        self.state = state;
286        let _ = self
287            .unlink_event_tx
288            .send(NodeCoreEvent::StateChanged(self.state.clone()));
289    }
290
291    fn set_unlink_connection(&mut self, status: ConnectionStatus) {
292        self.connection_status = status;
293        let _ = self
294            .unlink_event_tx
295            .send(NodeCoreEvent::ConnectionChanged(self.connection_status));
296    }
297
298    fn disconnect_for_unlink(&mut self) {
299        if let Some(token) = self.realtime_shutdown.take() {
300            token.cancel();
301        }
302
303        self.realtime_rx = None;
304        self.set_unlink_connection(ConnectionStatus::Disconnected);
305    }
306}
307
308async fn execute_unlink(
309    sentinel: &impl RemoteUnlinkExecutor,
310    key_store: &NodeKeyStore,
311    cancellation: &CancellationToken,
312) -> UnlinkResult {
313    let remote = tokio::select! {
314        biased;
315        () = cancellation.cancelled() => return Err(UnlinkError::cancelled()),
316        result = sentinel.unlink() => result.map_err(UnlinkError::remote)?,
317    };
318    let remote = match remote {
319        SentinelUnlinkOutcome::Removed => RemoteUnlinkOutcome::Removed,
320        SentinelUnlinkOutcome::AlreadyAbsent => RemoteUnlinkOutcome::AlreadyAbsent,
321    };
322    let local_identity = key_store
323        .delete()
324        .map_err(|source| UnlinkError::local_identity(remote, source))?;
325    let local_identity = match local_identity {
326        IdentityCleanupOutcome::Removed => LocalIdentityOutcome::Removed,
327        IdentityCleanupOutcome::AlreadyAbsent => LocalIdentityOutcome::AlreadyAbsent,
328    };
329
330    Ok(UnlinkOutcome::new(remote, local_identity))
331}
332
333#[cfg(test)]
334mod tests;