Skip to main content

coven/sync/
device_join_transport.rs

1//! The joining device's side of the storage-mediated device-join transport.
2//!
3//! The admitting side's driver lives in coven-core beside the Store it
4//! advances; this is its counterpart on the device being admitted, where the
5//! join steps hang off [`DeviceJoinClient`] rather than an open Store.
6//!
7//! Each step is the same call a host driving the join by hand would make. The
8//! transport only replaces handing the artifacts across: publish what the step
9//! produced, wait for what the next step needs.
10
11use tokio::sync::watch;
12
13use crate::config::Config;
14use crate::sync::join::{BootstrapError, DeviceJoinClient};
15use crate::sync::store::{
16    DeviceJoinAbandonment, DeviceJoinAction, DeviceJoinActivation, DeviceJoinCancellation,
17    DeviceJoinCleanupActivation, DeviceJoinOfferBundle, DeviceJoinRoles, DeviceJoinStatus,
18    DeviceJoinStep, DeviceJoinTransport, DeviceJoinTransportTiming,
19    DeviceProviderAdmissionApproval, ProviderReadyDeviceBootstrap,
20};
21
22/// How a join driven through the transport ended for the joining device.
23#[derive(Clone, Debug)]
24pub enum DeviceJoinTransportOutcome {
25    /// The device is a member: its store is saved and its config returned.
26    Joined(Config),
27    /// The owner gave up on the attempt before it completed.
28    Abandoned(DeviceJoinAbandonment),
29}
30
31impl DeviceJoinClient {
32    /// Join through the transport: one call from the scanned offer bundle to a
33    /// saved member [`Config`], or to the owner's abandonment of the attempt.
34    ///
35    /// Every step resumes from the joiner journal, so calling this again after
36    /// a crash picks up where the last durable step left off — a republished
37    /// artifact that is already at its slot is accepted as the same transfer,
38    /// and an awaited artifact is simply read again.
39    pub async fn join_via_transport(
40        &self,
41        bundle: &DeviceJoinOfferBundle,
42        timing: DeviceJoinTransportTiming,
43        on_status: impl Fn(&str),
44        cancel: &watch::Receiver<bool>,
45    ) -> Result<DeviceJoinTransportOutcome, BootstrapError> {
46        let storage = self.transport_storage().await?;
47        let transport = DeviceJoinTransport::open(&storage, bundle, DeviceJoinRoles::joiner())?;
48        let attempt_id = bundle.offer.attempt_id;
49
50        // Each pass takes the joiner journal's durable state and performs the
51        // one step that follows it — never an earlier step, which the journal
52        // refuses once it is past. A step that produced an artifact but died
53        // before publishing it republishes here; a step whose artifact is
54        // already at its slot publishes the same transfer again for nothing.
55        loop {
56            match self.device_join_status(attempt_id)? {
57                None | Some(DeviceJoinStatus::AwaitingAccessRequest { .. }) => {
58                    let request = self
59                        .prepare_provider_access_request(bundle.offer.clone())
60                        .await?;
61                    transport
62                        .publish(&DeviceJoinAction::TransferProviderAccessRequest(request))
63                        .await?;
64                }
65                Some(DeviceJoinStatus::Abandoned { abandonment }) => {
66                    return self.accept_abandonment(&transport, abandonment).await;
67                }
68                Some(DeviceJoinStatus::AwaitingProviderAdmission { request }) => {
69                    transport
70                        .publish(&DeviceJoinAction::TransferProviderAccessRequest(request))
71                        .await?;
72                    // The owner may give up on the attempt while this device
73                    // waits, so the wait watches the abandonment slot alongside
74                    // the approval rather than sitting out its deadline.
75                    let approval = match transport
76                        .await_step::<DeviceProviderAdmissionApproval>(timing)
77                        .await?
78                    {
79                        DeviceJoinStep::Continue(approval) => approval,
80                        DeviceJoinStep::Abandoned(abandonment) => {
81                            return self.accept_abandonment(&transport, abandonment).await;
82                        }
83                    };
84                    let registration_request = self.prepare_registration_request(approval).await?;
85                    transport
86                        .publish(&DeviceJoinAction::TransferRegistrationRequest(
87                            registration_request,
88                        ))
89                        .await?;
90                }
91                Some(DeviceJoinStatus::AwaitingRegistrationRequest { approval }) => {
92                    let registration_request = self.prepare_registration_request(approval).await?;
93                    transport
94                        .publish(&DeviceJoinAction::TransferRegistrationRequest(
95                            registration_request,
96                        ))
97                        .await?;
98                }
99                Some(DeviceJoinStatus::AwaitingBootstrap { request }) => {
100                    transport
101                        .publish(&DeviceJoinAction::TransferRegistrationRequest(request))
102                        .await?;
103                    let provider_ready = transport
104                        .await_artifact::<ProviderReadyDeviceBootstrap>(timing)
105                        .await?;
106                    let readiness =
107                        Box::pin(self.bootstrap_pending_device(provider_ready, &on_status, cancel))
108                            .await?;
109                    transport
110                        .publish(&DeviceJoinAction::TransferReadiness(readiness))
111                        .await?;
112                }
113                Some(DeviceJoinStatus::AwaitingReadiness { bootstrap }) => {
114                    let readiness =
115                        Box::pin(self.bootstrap_pending_device(bootstrap, &on_status, cancel))
116                            .await?;
117                    transport
118                        .publish(&DeviceJoinAction::TransferReadiness(readiness))
119                        .await?;
120                }
121                Some(DeviceJoinStatus::AwaitingProviderCompletion { readiness }) => {
122                    transport
123                        .publish(&DeviceJoinAction::TransferReadiness(readiness))
124                        .await?;
125                    let activation = transport
126                        .await_artifact::<DeviceJoinActivation>(timing)
127                        .await?;
128                    return self.finish(&transport, activation, &on_status).await;
129                }
130                Some(DeviceJoinStatus::AwaitingCompletion { activation }) => {
131                    return self.finish(&transport, activation, &on_status).await;
132                }
133                Some(DeviceJoinStatus::Activated { store }) => {
134                    return self.finish(&transport, store.activation, &on_status).await;
135                }
136                Some(_) => return Err(crate::DeviceJoinError::JournalConflict.into()),
137            }
138        }
139    }
140
141    /// Save the store and clear the attempt's namespace: the join is complete,
142    /// so every artifact has been consumed and neither side has anything left
143    /// to read.
144    async fn finish(
145        &self,
146        transport: &DeviceJoinTransport<'_>,
147        activation: DeviceJoinActivation,
148        on_status: &impl Fn(&str),
149    ) -> Result<DeviceJoinTransportOutcome, BootstrapError> {
150        let config = self.complete_device_join(activation, on_status).await?;
151        transport.delete_attempt_slots().await?;
152        Ok(DeviceJoinTransportOutcome::Joined(config))
153    }
154
155    /// Record the owner's abandonment and clear the attempt's namespace: the
156    /// abandonment is the last artifact either side publishes, and this device
157    /// has just read it.
158    async fn accept_abandonment(
159        &self,
160        transport: &DeviceJoinTransport<'_>,
161        abandonment: DeviceJoinAbandonment,
162    ) -> Result<DeviceJoinTransportOutcome, BootstrapError> {
163        let accepted = self.accept_device_join_abandonment(abandonment).await?;
164        transport.delete_attempt_slots().await?;
165        Ok(DeviceJoinTransportOutcome::Abandoned(accepted))
166    }
167
168    /// Carry a cancelled attempt through the transport to its activated
169    /// cleanup, then remove the attempt's transport slots.
170    ///
171    /// The joiner is the last reader in the unwind — it consumes the cleanup
172    /// activation the owner publishes last — so the deletion belongs here, at
173    /// the same point the joiner discards the rest of its pending join state.
174    pub async fn close_device_join_via_transport(
175        &self,
176        bundle: &DeviceJoinOfferBundle,
177        timing: DeviceJoinTransportTiming,
178    ) -> Result<(), BootstrapError> {
179        let storage = self.transport_storage().await?;
180        let transport = DeviceJoinTransport::open(&storage, bundle, DeviceJoinRoles::joiner())?;
181        let attempt_id = bundle.offer.attempt_id;
182
183        match self.device_join_status(attempt_id)? {
184            // The cleanup already landed; all that is left is clearing the
185            // namespace, which a run that died before it would not have done.
186            Some(DeviceJoinStatus::CleanupActivated { activation }) => {
187                if self
188                    .resume_device_joins()?
189                    .contains(&DeviceJoinAction::CompleteCleanup(activation.clone()))
190                {
191                    self.complete_cancelled_device_join(activation).await?;
192                }
193                transport.delete_attempt_slots().await?;
194                return Ok(());
195            }
196            Some(DeviceJoinStatus::JoinerClosed { terminal }) => {
197                transport
198                    .publish(&DeviceJoinAction::TransferJoinerTerminal(terminal))
199                    .await?;
200            }
201            _ => {
202                let cancellation = transport
203                    .await_artifact::<DeviceJoinCancellation>(timing)
204                    .await?;
205                let terminal = self.close_pending_device_join(cancellation).await?;
206                transport
207                    .publish(&DeviceJoinAction::TransferJoinerTerminal(terminal))
208                    .await?;
209            }
210        }
211
212        let activation = transport
213            .await_artifact::<DeviceJoinCleanupActivation>(timing)
214            .await?;
215        self.complete_cancelled_device_join(activation).await?;
216        transport.delete_attempt_slots().await?;
217        Ok(())
218    }
219}