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 <cursoragent@cursor.com>
This commit is contained in:
Leopere 2026-02-23 10:23:05 -05:00
parent b26cd18579
commit 9cf6ccfd28
Signed by: colin
SSH Key Fingerprint: SHA256:nRPCQTeMFLdGytxRQmPVK9VXY3/ePKQ5lGRyJhT5DY8
5 changed files with 98 additions and 20 deletions

View File

@ -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<Camera, String> {
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<Camera, String> {
}
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<Camera> {
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");

View File

@ -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,
};

22
src/entropy/release.rs Normal file
View File

@ -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
}
}

View File

@ -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::{

View File

@ -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<dyn std::error::Error>> {
);
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<dyn std::error::Error>> {
.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<dyn std::error::Error>> {
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 {