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                    warn!(
91                        %peer,
92                        "dropped OCLA gRPC connection because no gRPC service is attached"
93                    );
94                }
95                Err(error) => {
96                    warn!(%error, "OCLA gRPC listener stopped accepting connections");
97                    break;
98                }
99            }
100        }
101    });
102
103    let Ok(mut stored_task) = grpc_task().lock() else {
104        task.abort();
105        GRPC_RUNNING.store(false, Ordering::Release);
106        return Err(OclaError::InvalidRequest(
107            "OCLA gRPC task state is unavailable".into(),
108        ));
109    };
110    if GRPC_RUNNING.load(Ordering::Acquire)
111        || stored_task
112            .as_ref()
113            .is_some_and(|existing| !existing.is_finished())
114    {
115        task.abort();
116        return Err(OclaError::InvalidRequest(
117            "OCLA gRPC server is already running".into(),
118        ));
119    }
120    if let Some(existing) = stored_task.take() {
121        existing.abort();
122    }
123    *stored_task = Some(task);
124    GRPC_RUNNING.store(true, Ordering::Release);
125    Ok(())
126}
127
128/// Stop the OCLA gRPC listener task, if one is active.
129pub async fn stop_grpc_server() {
130    let task = if let Ok(mut stored_task) = grpc_task().lock() {
131        stored_task.take()
132    } else {
133        warn!("OCLA gRPC task state is unavailable during shutdown");
134        None
135    };
136    if let Some(task) = task {
137        task.abort();
138        let _ = task.await;
139    }
140    GRPC_RUNNING.store(false, Ordering::Release);
141}
142
143/// Returns whether the OCLA gRPC TCP listener task is running.
144///
145/// This only means the TCP listener is reserved; it does not mean a gRPC service
146/// is attached or responding.
147pub fn is_grpc_listener_running() -> bool {
148    GRPC_RUNNING.load(Ordering::Acquire)
149}
150
151/// Returns whether an OCLA gRPC service is attached and ready to respond.
152///
153/// This remains false until the standalone gRPC package is wired to the listener.
154pub fn is_grpc_service_ready() -> bool {
155    false
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    #[test]
163    fn grpc_config_defaults_to_disabled_loopback_listener() {
164        let config = GrpcConfig::default();
165
166        assert!(!config.enabled);
167        assert_eq!(config.listen, DEFAULT_GRPC_LISTEN);
168    }
169
170    #[test]
171    fn grpc_listen_address_parses() {
172        let config = GrpcConfig {
173            listen: "127.0.0.1:60051".into(),
174            ..GrpcConfig::default()
175        };
176
177        assert_eq!(config.listen.parse::<SocketAddr>().unwrap().port(), 60051);
178    }
179
180    #[tokio::test]
181    #[serial_test::serial]
182    async fn grpc_server_binds_and_accepts() {
183        let config = GrpcConfig {
184            enabled: true,
185            listen: "127.0.0.1:0".into(),
186        };
187
188        assert!(start_grpc_server(&config).await.is_ok());
189        assert!(is_grpc_listener_running());
190        assert!(!is_grpc_service_ready());
191        stop_grpc_server().await;
192        assert!(!is_grpc_listener_running());
193    }
194
195    #[tokio::test]
196    #[serial_test::serial]
197    async fn disabled_config_does_not_bind() {
198        stop_grpc_server().await;
199        assert!(start_grpc_server(&GrpcConfig::default()).await.is_ok());
200        assert!(!is_grpc_listener_running());
201        assert!(!is_grpc_service_ready());
202    }
203
204    #[tokio::test]
205    #[serial_test::serial]
206    async fn stop_sets_unavailable() {
207        let config = GrpcConfig {
208            enabled: true,
209            listen: "127.0.0.1:0".into(),
210        };
211
212        start_grpc_server(&config).await.unwrap();
213        assert!(is_grpc_listener_running());
214        assert!(!is_grpc_service_ready());
215        stop_grpc_server().await;
216        assert!(!is_grpc_listener_running());
217    }
218}