@ondeinference / onde / commits / f1bdf2b

Lazily initialize PulseClient in ChatEngine with OnceLock

Pulse telemetry client is now created on first async use, not in the constructor. This avoids requiring a Tokio runtime at construction time and ensures correct initialization in UniFFI contexts.

keypair34 committed Apr 28, 2026 at 20:34 UTC f1bdf2b7adae2264eac42ac9d857837fb4a7b3f0
1 file changed +33 -18
src/inference/engine.rs
+33 -18
@@ -242,7 +242,7 @@ enum HistoryEntry {
242 ))]
243 pub struct ChatEngine {
244 inner: Mutex<Option<LoadedModel>>,
245 - pulse: Option<crate::pulse::PulseClient>,
245 + pulse: std::sync::OnceLock<Option<crate::pulse::PulseClient>>,
246 }
247
248 #[cfg(any(
@@ -260,26 +260,41 @@ impl ChatEngine {
260
261 /// Create a new engine with no model loaded.
262 pub fn new() -> Self {
263 - let environment = Self::pulse_environment();
264 - let edge_id = std::env::var("ONDE_EDGE_ID").unwrap_or_else(|_| "onde-unknown".to_string());
265 - let pulse = crate::pulse::PulseClient::new(environment, edge_id);
266 -
267 - match &pulse {
268 - Some(_) => {
269 - log::info!("ChatEngine: pulse telemetry enabled (environment={environment})")
270 - }
271 - None => log::info!(
272 - "ChatEngine: pulse telemetry disabled \
273 - (GRESIQ_API_KEY / GRESIQ_API_SECRET not embedded at SDK build time)"
274 - ),
275 - }
276 -
263 Self {
264 inner: Mutex::new(None),
279 - pulse,
265 + pulse: std::sync::OnceLock::new(),
266 }
267 }
268
269 + /// Lazily initialise the pulse telemetry client.
270 + ///
271 + /// The underlying GresIQ SDK creates a `reqwest::Client`, which needs
272 + /// a Tokio runtime. `ChatEngine::new()` is called from a sync UniFFI
273 + /// constructor where no runtime exists yet, so we defer creation to the
274 + /// first async call site (e.g. `load_gguf_model` or `send_message`).
275 + fn pulse(&self) -> Option<&crate::pulse::PulseClient> {
276 + self.pulse
277 + .get_or_init(|| {
278 + let environment = Self::pulse_environment();
279 + let edge_id =
280 + std::env::var("ONDE_EDGE_ID").unwrap_or_else(|_| "onde-unknown".to_string());
281 + let client = crate::pulse::PulseClient::new(environment, edge_id);
282 +
283 + match &client {
284 + Some(_) => log::info!(
285 + "ChatEngine: pulse telemetry enabled (environment={environment})"
286 + ),
287 + None => log::info!(
288 + "ChatEngine: pulse telemetry disabled \
289 + (GRESIQ_API_KEY / GRESIQ_API_SECRET not embedded at SDK build time)"
290 + ),
291 + }
292 +
293 + client
294 + })
295 + .as_ref()
296 + }
297 +
298 /// Resolve the pulse environment from the `GRESIQ_ENVIRONMENT` env var.
299 /// Defaults to `Production` when the var is absent or unrecognised.
300 fn pulse_environment() -> smbcloud_gresiq_sdk::Environment {
@@ -456,7 +471,7 @@ impl ChatEngine {
471 };
472 drop(old_model); // free old weights outside the lock
473
459 - if let Some(ref pulse) = self.pulse {
474 + if let Some(pulse) = self.pulse() {
475 pulse
476 .record_model_loaded(pulse_model_id, pulse_model_name, elapsed.as_millis() as u64)
477 .await;
@@ -867,7 +882,7 @@ impl ChatEngine {
882 }
883 }
884
870 - if let Some(ref pulse) = self.pulse {
885 + if let Some(pulse) = self.pulse() {
886 pulse.record_inference(
887 pulse_model_id,
888 crate::pulse::next_request_id(),