From 9cf6ccfd28667a3d410ac103238a463f3e6c620b Mon Sep 17 00:00:00 2001 From: Leopere Date: Mon, 23 Feb 2026 10:23:05 -0500 Subject: [PATCH] Release camera on lock and re-establish connection - Add entropy/release.rs: try_release_camera() (macOS: killall VDCAssistant/AppleCameraAssistant) - open_camera_with_retry: on lock-like errors, call release then retry - try_reconnect: attempt release before reconnecting - GET/POST /release-camera: release then test_camera(), return JSON - Export try_release_camera from lib; update startup message Co-authored-by: Cursor --- src/entropy/camera.rs | 48 +++++++++++++++++++++++++++--------------- src/entropy/mod.rs | 2 ++ src/entropy/release.rs | 22 +++++++++++++++++++ src/lib.rs | 3 ++- src/main.rs | 43 +++++++++++++++++++++++++++++++++++-- 5 files changed, 98 insertions(+), 20 deletions(-) create mode 100644 src/entropy/release.rs diff --git a/src/entropy/camera.rs b/src/entropy/camera.rs index 3d43710..e9020a2 100644 --- a/src/entropy/camera.rs +++ b/src/entropy/camera.rs @@ -13,6 +13,7 @@ use nokhwa::{ }; use super::config::{CameraConfig, CameraListItem}; +use super::release; /// Retry configuration for camera operations pub const MAX_RETRIES: u32 = 5; @@ -105,13 +106,19 @@ pub fn configure_for_thermal_noise(camera: &mut Camera) { } } -/// Open camera with retry logic. Attempts to reconnect on failure. +fn is_lock_like_error(err: &str) -> bool { + let lower = err.to_lowercase(); + lower.contains("lock") || lower.contains("rejected") || lower.contains("in use") || lower.contains("busy") +} + +/// Open camera with retry logic. On lock-like errors, tries to release the camera (e.g. macOS daemons) then retries. pub fn open_camera_with_retry(config: &CameraConfig) -> Result { let index = CameraIndex::Index(config.index); let format = requested_format(config); - + let mut released = false; + for attempt in 1..=MAX_RETRIES { - match Camera::new(index.clone(), format.clone()) { + let open_stream_err = match Camera::new(index.clone(), format.clone()) { Ok(mut camera) => { match camera.open_stream() { Ok(_) => { @@ -121,31 +128,38 @@ pub fn open_camera_with_retry(config: &CameraConfig) -> Result { } return Ok(camera); } - Err(e) => { - eprintln!("[camera] open_stream failed (attempt {}): {}", attempt, e); - if attempt < MAX_RETRIES { - thread::sleep(Duration::from_millis(RETRY_DELAY_MS)); - } - } + Err(e) => Some(e.to_string()), } } - Err(e) => { - eprintln!("[camera] Camera::new failed (attempt {}): {}", attempt, e); - if attempt < MAX_RETRIES { - thread::sleep(Duration::from_millis(RETRY_DELAY_MS)); - } + Err(e) => Some(e.to_string()), + }; + + if let Some(ref err_str) = open_stream_err { + eprintln!("[camera] open failed (attempt {}): {}", attempt, err_str); + if !released && is_lock_like_error(err_str) { + eprintln!("[camera] lock-like error, attempting to release camera..."); + release::try_release_camera(); + released = true; + thread::sleep(Duration::from_millis(RECONNECT_DELAY_MS)); } } + if attempt < MAX_RETRIES { + thread::sleep(Duration::from_millis(RETRY_DELAY_MS)); + } } - + Err(format!("Failed to open camera index {} after {} attempts", config.index, MAX_RETRIES)) } -/// Try to recover camera connection. Returns new Camera if successful. +/// Try to recover camera connection. Attempts to release the camera first if the error looks like a lock, then retries. pub fn try_reconnect(config: &CameraConfig, error: &str) -> Option { eprintln!("[camera] error: {}, attempting reconnect...", error); + if is_lock_like_error(error) { + eprintln!("[camera] attempting to release camera before reconnect..."); + release::try_release_camera(); + } thread::sleep(Duration::from_millis(RECONNECT_DELAY_MS)); - + match open_camera_with_retry(config) { Ok(camera) => { eprintln!("[camera] successfully reconnected"); diff --git a/src/entropy/mod.rs b/src/entropy/mod.rs index 8fd7c42..c143828 100644 --- a/src/entropy/mod.rs +++ b/src/entropy/mod.rs @@ -11,10 +11,12 @@ mod config; mod live; mod camera; mod extract; +mod release; pub use config::{CameraConfig, CameraListItem}; pub use live::{extract_entropy, fill_entropy, stream_entropy}; pub use camera::{list_cameras, test_camera, open_camera_with_retry, try_reconnect}; +pub use release::try_release_camera; pub use extract::{ extract_raw_lsb, spawn_raw_lsb_stream, CHUNK_SIZE, }; diff --git a/src/entropy/release.rs b/src/entropy/release.rs new file mode 100644 index 0000000..4021e4a --- /dev/null +++ b/src/entropy/release.rs @@ -0,0 +1,22 @@ +//! Best-effort release of camera hold by system daemons (e.g. macOS). +//! Allows the app to re-establish connection without requiring a manual script. + +/// Try to free the camera so it can be opened by this process. +/// +/// On macOS: kills VDCAssistant and AppleCameraAssistant if present +/// (best-effort; may require sudo for full effect — see scripts/release-camera.sh). +/// On other platforms: no-op, returns true. +pub fn try_release_camera() -> bool { + #[cfg(target_os = "macos")] + { + for process in &["VDCAssistant", "AppleCameraAssistant"] { + let _ = std::process::Command::new("killall").arg(process).status(); + } + true + } + + #[cfg(not(target_os = "macos"))] + { + true + } +} diff --git a/src/lib.rs b/src/lib.rs index f65829b..ad0914e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,7 +13,8 @@ pub mod tools; pub use entropy::{ extract_entropy, fill_entropy, stream_entropy, list_cameras, spawn_raw_lsb_stream, - test_camera, extract_raw_lsb, CameraConfig, CameraListItem, CHUNK_SIZE, + test_camera, extract_raw_lsb, try_release_camera, + CameraConfig, CameraListItem, CHUNK_SIZE, }; pub use tools::{ diff --git a/src/main.rs b/src/main.rs index 45678e7..bf3c841 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,7 +12,7 @@ use axum::{ Router, }; use camera_trng::{ - extract_entropy, list_cameras, stream_entropy, test_camera, + extract_entropy, list_cameras, stream_entropy, test_camera, try_release_camera, CameraConfig, CHUNK_SIZE, }; mod qrng_handlers; @@ -84,7 +84,7 @@ async fn main() -> Result<(), Box> { ); if e.contains("Lock Rejected") || e.contains("lock") { eprintln!( - " → To release camera: ./scripts/release-camera.sh then restart." + " → App will try to release the camera on next request, or hit GET/POST /release-camera" ); } } @@ -102,6 +102,7 @@ async fn main() -> Result<(), Box> { .route("/8ball", get(get_eightball)) .route("/tools", get(tools_page)) .route("/health", get(health)) + .route("/release-camera", get(release_camera).post(release_camera)) .route("/.well-known/mcp.json", get(mcp_wellknown)) .route("/.well-known/skill.md", get(get_skill_md)) .route("/docs", get(get_docs)) @@ -143,6 +144,44 @@ async fn main() -> Result<(), Box> { async fn index() -> Html<&'static str> { Html(INDEX_HTML) } async fn tools_page() -> Html<&'static str> { Html(TOOLS_HTML) } +/// Release camera hold (e.g. macOS daemons) and re-establish connection. +/// GET or POST /release-camera. Returns JSON with ok, released, and camera info or error. +async fn release_camera() -> Response { + let result = tokio::task::spawn_blocking(|| { + try_release_camera(); + std::thread::sleep(std::time::Duration::from_millis(800)); + match test_camera(&CameraConfig::from_env()) { + Ok((w, h, frame_size)) => Ok::<_, String>((w, h, frame_size)), + Err(e) => Err(e), + } + }) + .await; + + match result { + Ok(Ok((w, h, frame_size))) => { + let body = serde_json::json!({ + "ok": true, + "released": true, + "camera": { "width": w, "height": h, "frame_size": frame_size }, + }); + Response::builder() + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(body.to_string())) + .unwrap() + } + Ok(Err(e)) => ( + StatusCode::SERVICE_UNAVAILABLE, + Json(serde_json::json!({ + "ok": false, + "released": true, + "error": e, + })), + ) + .into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), + } +} + /// Health check: lightweight probe — confirms the server is running. /// Actual camera availability is tested on every /random request. async fn health() -> Response {