Skip to main content

lean_ctx/core/ocla/
grpc_bridge.rs

1//! Dependency-free configuration and lifecycle bridge for the OCLA gRPC server.
2//!
3//! The standalone `lean-ctx-ocla-grpc` package owns the verifier service. This
4//! module keeps the library's configuration surface independent of that
5//! package while tracking the listener lifecycle used by the binary wiring.
6
7use std::net::SocketAddr;
8use std::sync::atomic::{AtomicBool, Ordering};
9use std::sync::{Mutex, OnceLock};
10
11use serde::{Deserialize, Serialize};
12use tokio::net::TcpListener;
13use tokio::task::JoinHandle;
14use tracing::{debug, warn};
15
16use super::types::{OclaError, OclaResult};
17
18const DEFAULT_GRPC_LISTEN: &str = "127.0.0.1:50051";
19static GRPC_RUNNING: AtomicBool = AtomicBool::new(false);
20static GRPC_TASK: OnceLock<Mutex<Option<JoinHandle<()>>>> = OnceLock::new();
21
22fn grpc_task() -> &'static Mutex<Option<JoinHandle<()>>> {
23    GRPC_TASK.get_or_init(|| Mutex::new(None))
24}
25
26/// Configuration for the optional loopback OCLA gRPC listener.
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
28#[serde(default)]
29pub struct GrpcConfig {
30    /// Whether the gRPC listener should be started.
31    pub enabled: bool,
32    /// Loopback address on which the listener is reserved.
33    pub listen: String,
34}
35
36impl Default for GrpcConfig {
37    fn default() -> Self {
38        Self {
39            enabled: false,
40            listen: DEFAULT_GRPC_LISTEN.to_owned(),
41        }
42    }
43}
44
45struct RunningGuard;
46
47impl Drop for RunningGuard {
48    fn drop(&mut self) {
49        GRPC_RUNNING.store(false, Ordering::Release);
50    }
51}
52
53/// Reserve the configured loopback listener in a Tokio task.
54///
55/// The standalone gRPC package attaches `lean_ctx_ocla_grpc::serve` to this
56/// listener at binary level; the main library intentionally has no dependency
57/// on that package.
58pub async fn start_grpc_server(config: &GrpcConfig) -> OclaResult<()> {
59    if !config.enabled {
60        return Ok(());
61    }
62
63    let address = config.listen.parse::<SocketAddr>().map_err(|error| {
64        OclaError::InvalidRequest(format!("invalid OCLA gRPC listen address: {error}"))
65    })?;
66    if !address.ip().is_loopback() {
67        return Err(OclaError::InvalidRequest(
68            "OCLA gRPC listener must use a loopback address".into(),
69        ));
70    }
71    if GRPC_RUNNING.load(Ordering::Acquire) {
72        return Err(OclaError::InvalidRequest(
73            "OCLA gRPC server is already running".into(),
74        ));
75    }
76    tokio::runtime::Handle::try_current().map_err(|_| {
77        OclaError::InvalidRequest("OCLA gRPC startup requires a Tokio runtime".into())
78    })?;
79
80    let listener = TcpListener::bind(address).await.map_err(|error| {
81        OclaError::InvalidRequest(format!("failed to bind OCLA gRPC listener: {error}"))
82    })?;
83
84    let task = tokio::spawn(async move {
85        let _running = RunningGuard;
86        debug!(%address, "OCLA gRPC listener task started");
87        loop {
88            match listener.accept().await {
89                Ok((stream, peer)) => {
90                    debug!(%peer, "accepted OCLA gRPC connection");
91                    drop(stream);
92                }
93                Err(error) => {
94                    warn!(%error, "OCLA gRPC listener stopped accepting connections");
95                    break;
96                }
97            }
98        }
99    });
100
101    let Ok(mut stored_task) = grpc_task().lock() else {
102        task.abort();
103        GRPC_RUNNING.store(false, Ordering::Release);
104        return Err(OclaError::InvalidRequest(
105            "OCLA gRPC task state is unavailable".into(),
106        ));
107    };
108    if GRPC_RUNNING.load(Ordering::Acquire)
109        || stored_task
110            .as_ref()
111            .is_some_and(|existing| !existing.is_finished())
112    {
113        task.abort();
114        return Err(OclaError::InvalidRequest(
115            "OCLA gRPC server is already running".into(),
116        ));
117    }
118    if let Some(existing) = stored_task.take() {
119        existing.abort();
120    }
121    *stored_task = Some(task);
122    GRPC_RUNNING.store(true, Ordering::Release);
123    Ok(())
124}
125
126/// Stop the OCLA gRPC listener task, if one is active.
127pub async fn stop_grpc_server() {
128    let task = if let Ok(mut stored_task) = grpc_task().lock() {
129        stored_task.take()
130    } else {
131        warn!("OCLA gRPC task state is unavailable during shutdown");
132        None
133    };
134    if let Some(task) = task {
135        task.abort();
136        let _ = task.await;
137    }
138    GRPC_RUNNING.store(false, Ordering::Release);
139}
140
141/// Returns whether the OCLA gRPC listener task is running.
142pub fn is_grpc_available() -> bool {
143    GRPC_RUNNING.load(Ordering::Acquire)
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn grpc_config_defaults_to_disabled_loopback_listener() {
152        let config = GrpcConfig::default();
153
154        assert!(!config.enabled);
155        assert_eq!(config.listen, DEFAULT_GRPC_LISTEN);
156    }
157
158    #[test]
159    fn grpc_listen_address_parses() {
160        let config = GrpcConfig {
161            listen: "127.0.0.1:60051".into(),
162            ..GrpcConfig::default()
163        };
164
165        assert_eq!(config.listen.parse::<SocketAddr>().unwrap().port(), 60051);
166    }
167
168    #[tokio::test]
169    #[serial_test::serial]
170    async fn grpc_server_binds_and_accepts() {
171        let config = GrpcConfig {
172            enabled: true,
173            listen: "127.0.0.1:0".into(),
174        };
175
176        assert!(start_grpc_server(&config).await.is_ok());
177        assert!(is_grpc_available());
178        stop_grpc_server().await;
179        assert!(!is_grpc_available());
180    }
181
182    #[tokio::test]
183    #[serial_test::serial]
184    async fn disabled_config_does_not_bind() {
185        stop_grpc_server().await;
186        assert!(start_grpc_server(&GrpcConfig::default()).await.is_ok());
187        assert!(!is_grpc_available());
188    }
189
190    #[tokio::test]
191    #[serial_test::serial]
192    async fn stop_sets_unavailable() {
193        let config = GrpcConfig {
194            enabled: true,
195            listen: "127.0.0.1:0".into(),
196        };
197
198        start_grpc_server(&config).await.unwrap();
199        assert!(is_grpc_available());
200        stop_grpc_server().await;
201        assert!(!is_grpc_available());
202    }
203}