Skip to main content

liminal_server/auth_lys/
grant_stream_owner.rs

1//! One owner of Lys's consumer-opened grant log, stopped and joined before store release.
2
3#[path = "grant_stream_owner/binding.rs"]
4mod binding;
5#[path = "grant_stream_owner/config.rs"]
6mod config;
7#[path = "grant_stream_owner/configured.rs"]
8mod configured;
9#[path = "grant_stream_owner/engine.rs"]
10mod engine;
11#[path = "grant_stream_owner/http.rs"]
12mod http;
13#[path = "grant_stream_owner/state.rs"]
14mod state;
15
16pub use config::{GrantStreamConfig, GrantSubjectConfig};
17pub use state::{StreamDiagnostics, StreamHealth, StreamState};
18
19use crate::auth_lys::live::drafts::{DraftDecisionHook, PullReport};
20use crate::config::types::LysConfig;
21use lys_identity::grants::change_stream::{ChangesPage, ChangesRequest, LogName};
22use std::sync::Arc;
23use std::thread::JoinHandle;
24
25/// Only stable local names cross diagnostics, errors, Debug and tracing.
26#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
27#[error("{name}")]
28pub struct StreamError {
29    name: &'static str,
30    transport_ended: bool,
31}
32
33impl StreamError {
34    pub(super) const fn named(name: &'static str) -> Self {
35        Self {
36            name,
37            transport_ended: false,
38        }
39    }
40    const fn transport() -> Self {
41        Self {
42            name: "revocation_stream_unavailable",
43            transport_ended: true,
44        }
45    }
46    /// The named failure, containing no transport error or remote response text.
47    #[must_use]
48    pub const fn name(self) -> &'static str {
49        self.name
50    }
51}
52
53/// The guard must remain on the server start stack until its shutdown finalizer joins it.
54pub struct GrantStreamOwner {
55    state: Arc<StreamState>,
56    worker: Option<JoinHandle<Result<(), StreamError>>>,
57    draft_hook: Option<Arc<dyn DraftDecisionHook>>,
58}
59
60impl std::fmt::Debug for GrantStreamOwner {
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        f.debug_struct("GrantStreamOwner")
63            .field("state", &self.state)
64            .field("worker", &self.worker.is_some())
65            .finish_non_exhaustive()
66    }
67}
68
69impl GrantStreamOwner {
70    /// Starts the configured owner on the standalone server's existing ask runtime.
71    ///
72    /// # Errors
73    /// A missing runtime or any refusal from [`Self::start_configured`].
74    pub fn start_for_server(
75        config: &crate::config::types::ServerConfig,
76        supervisor: &crate::server::ConnectionSupervisor,
77        health: &crate::health::HealthServerHandle,
78        runtime: Option<&tokio::runtime::Runtime>,
79        draft_hook: Option<Arc<dyn DraftDecisionHook>>,
80    ) -> Result<Option<Self>, crate::ServerError> {
81        let Some(lys) = config.auth.as_ref().and_then(|auth| auth.lys.as_ref()) else {
82            return Ok(None);
83        };
84        if lys.membership.is_none() && lys.grant_stream.is_none() {
85            // SRV-039: with no owner, the root question is asked once at start.
86            crate::auth_lys::root_presence_start::start(lys, health, runtime)?;
87            return Ok(None);
88        }
89        let runtime = runtime.ok_or_else(|| config_error("grant_stream_runtime_missing"))?;
90        Self::start_configured(
91            lys,
92            supervisor,
93            health,
94            runtime.handle().clone(),
95            draft_hook,
96        )
97    }
98
99    /// Installs both authorities before any connection and starts exactly one read.
100    ///
101    /// The supplied handle is the server's already owned Lys runtime; shutdown
102    /// joins this guard before that runtime or the store can be released.
103    ///
104    /// # Errors
105    /// Refuses invalid bounds, unreadable credentials or keys, a foreign membership
106    /// log, a second owner or authority, and failure to spawn the owned worker.
107    pub fn start_configured(
108        lys: &LysConfig,
109        supervisor: &crate::server::ConnectionSupervisor,
110        health: &crate::health::HealthServerHandle,
111        handle: tokio::runtime::Handle,
112        draft_hook: Option<Arc<dyn DraftDecisionHook>>,
113    ) -> Result<Option<Self>, crate::ServerError> {
114        let Some(config) = lys.grant_stream.as_ref() else {
115            if lys.membership.is_some() {
116                return Err(config_error(
117                    "auth.lys.membership requires auth.lys.grant_stream",
118                ));
119            }
120            return Ok(None);
121        };
122        let configured::Installation {
123            state,
124            engine,
125            http,
126            root,
127        } = configured::prepare(lys, config, supervisor, health, handle.clone())?;
128        let worker_state = Arc::clone(&state);
129        let worker = std::thread::Builder::new()
130            .name("liminal-grants".into())
131            .spawn(move || {
132                let result = handle.block_on(read(engine, http, root));
133                if let Err(error) = result {
134                    worker_state.update(|inner| {
135                        if !matches!(inner.diagnostics.readiness, "gap" | "reset") {
136                            inner.diagnostics.readiness = "terminal";
137                        }
138                        inner.diagnostics.last_error = Some(error.name());
139                        inner.diagnostics.stopped = true;
140                        Ok(())
141                    })?;
142                } else {
143                    worker_state.update(|inner| {
144                        inner.diagnostics.readiness = "unavailable";
145                        inner.diagnostics.last_error = Some("grant_stream_stopped");
146                        inner.diagnostics.stopped = true;
147                        Ok(())
148                    })?;
149                }
150                result
151            })
152            .map_err(|_| config_error("grant_stream_worker_unavailable"))?;
153        Ok(Some(Self {
154            state,
155            worker: Some(worker),
156            draft_hook,
157        }))
158    }
159
160    /// The health/progress authority installed at start.
161    #[must_use]
162    pub fn state(&self) -> Arc<StreamState> {
163        Arc::clone(&self.state)
164    }
165
166    /// Hook for an explicitly accepted published draft decision, never a grant change.
167    /// The current Lys frame contract has no such variant; unknown rows remain terminal.
168    ///
169    /// # Errors
170    /// A missing hook or a named approved-pull failure, retained without draft words.
171    pub async fn draft_decided(&self) -> Result<PullReport, StreamError> {
172        let hook = self
173            .draft_hook
174            .as_ref()
175            .ok_or(StreamError::named("grant_stream_draft_hook_missing"))?;
176        let result = hook
177            .draft_decided()
178            .await
179            .map_err(|_| StreamError::named("grant_stream_draft_pull_failed"));
180        if let Err(error) = result.as_ref() {
181            self.state.update(|inner| {
182                inner.diagnostics.last_error = Some(error.name());
183                Ok(())
184            })?;
185        }
186        result
187    }
188
189    /// Cancels every pending HTTP ask and joins the worker, including on startup unwind.
190    ///
191    /// # Errors
192    /// The terminal reader failure or an unexpectedly terminated worker.
193    pub fn finish(&mut self) -> Result<(), StreamError> {
194        self.draft_hook = None;
195        self.state.stop.send_replace(true);
196        match self.worker.take() {
197            Some(worker) => worker
198                .join()
199                .map_err(|_| StreamError::named("grant_stream_worker_panicked"))?,
200            None => Ok(()),
201        }
202    }
203}
204
205impl Drop for GrantStreamOwner {
206    fn drop(&mut self) {
207        if let Err(error) = self.finish() {
208            tracing::error!(cause = error.name(), "grant stream shutdown failed");
209        }
210    }
211}
212
213fn config_error(message: &str) -> crate::ServerError {
214    crate::ServerError::ConfigValidation {
215        message: message.into(),
216    }
217}
218fn as_config(error: StreamError) -> crate::ServerError {
219    config_error(error.name())
220}
221
222/// The shutdown finalizer joins the reader before flushing or releasing the store.
223///
224/// # Errors
225/// The owner's terminal failure or a failed worker join, by its stable name.
226pub fn finish_configured(owner: &mut Option<GrantStreamOwner>) -> Result<(), crate::ServerError> {
227    owner
228        .as_mut()
229        .map_or(Ok(()), |owner| owner.finish().map_err(as_config))
230}
231
232pub(crate) fn read_credential(
233    path: &std::path::Path,
234    bound: usize,
235) -> Result<zeroize::Zeroizing<String>, crate::ServerError> {
236    http::read_file(path, bound).map_err(as_config)
237}
238
239async fn read(
240    mut engine: engine::Engine,
241    http: http::Http,
242    root: Arc<crate::auth_lys::root_presence::RootPresence>,
243) -> Result<(), StreamError> {
244    let result = read_pages(&mut engine, &http, &root).await;
245    if let Err(error) = result.as_ref() {
246        engine.terminal(error.name())?;
247    }
248    result
249}
250
251async fn read_pages(
252    engine: &mut engine::Engine,
253    http: &http::Http,
254    root: &crate::auth_lys::root_presence::RootPresence,
255) -> Result<(), StreamError> {
256    let mut reopens = 0_usize;
257    loop {
258        if *http.stop.borrow() {
259            engine.lost()?;
260            return Ok(());
261        }
262        let before = engine.state.snapshot()?;
263        let question = ChangesRequest {
264            log: Some(LogName {
265                identity: engine.state.log.identity.clone(),
266                epoch: engine.state.log.epoch,
267            }),
268            after: before.cursor,
269            limit: engine.config.page_positions,
270            wait: true,
271            placements: true,
272            placed: before.placed,
273        };
274        engine.state.update(|inner| {
275            inner.diagnostics.requests = inner
276                .diagnostics
277                .requests
278                .checked_add(1)
279                .ok_or(StreamError::named("grant_stream_counter_exhausted"))?;
280            Ok(())
281        })?;
282        let page = match http
283            .post::<_, ChangesPage>("grants/changes", &question)
284            .await
285        {
286            Ok(page) => page,
287            Err(error) if error.name() == "grant_stream_stopped" => {
288                engine.lost()?;
289                return Ok(());
290            }
291            Err(error) if error.transport_ended => {
292                engine.lost()?;
293                if reopens >= engine.config.max_transport_reopens {
294                    return Err(error);
295                }
296                reopens += 1;
297                engine.state.update(|inner| {
298                    inner.diagnostics.reopens = inner
299                        .diagnostics
300                        .reopens
301                        .checked_add(1)
302                        .ok_or(StreamError::named("grant_stream_counter_exhausted"))?;
303                    Ok(())
304                })?;
305                continue;
306            }
307            Err(error) => {
308                engine.lost()?;
309                return Err(error);
310            }
311        };
312        if let Err(error) = engine.page(page) {
313            if error.name() == "grant_stream_membership_failed" && *http.stop.borrow() {
314                engine.lost()?;
315                return Ok(());
316            }
317            return Err(error);
318        }
319        // SRV-038: asked on the ready fence's own event, never on a timer.
320        if engine.take_root_owed() {
321            root.ask_now().await;
322        }
323        let after = engine.state.snapshot()?;
324        if (before.cursor, before.placed) == (after.cursor, after.placed) {
325            return Err(StreamError::named("grant_stream_no_progress"));
326        }
327        reopens = 0;
328    }
329}
330
331#[cfg(test)]
332#[path = "grant_stream_owner/tests.rs"]
333pub(crate) mod tests;