1use 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
17#[non_exhaustive]
18pub enum RemoteUnlinkOutcome {
19 Removed,
20 AlreadyAbsent,
21}
22
23#[derive(Clone, Copy, Debug, Eq, PartialEq)]
25#[non_exhaustive]
26pub enum LocalIdentityOutcome {
27 Removed,
28 AlreadyAbsent,
29}
30
31#[derive(Clone, Copy, Debug, Eq, PartialEq)]
33pub struct UnlinkOutcome {
34 remote: RemoteUnlinkOutcome,
35 local_identity: LocalIdentityOutcome,
36}
37
38impl UnlinkOutcome {
39 #[must_use]
41 pub const fn new(remote: RemoteUnlinkOutcome, local_identity: LocalIdentityOutcome) -> Self {
42 Self {
43 remote,
44 local_identity,
45 }
46 }
47
48 #[must_use]
50 pub const fn remote(&self) -> RemoteUnlinkOutcome {
51 self.remote
52 }
53
54 #[must_use]
56 pub const fn local_identity(&self) -> LocalIdentityOutcome {
57 self.local_identity
58 }
59}
60
61#[derive(Clone, Copy, Debug, Eq, PartialEq)]
63#[non_exhaustive]
64pub enum UnlinkErrorCategory {
65 Remote,
66 LocalIdentity,
67 Cancelled,
68}
69
70#[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 #[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 #[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 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 pub fn cancel_unlink(&self) {
227 if let Some(cancellation) = &self.unlink_cancel {
228 cancellation.cancel();
229 }
230 }
231
232 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;