//! Caller-owned CPU execution host. One worker, two bounded queued inputs; browser pumping //! uses the same incremental task without threads. Shared ownership never includes CAD state. use std::collections::VecDeque; #[cfg(not(target_arch = "wasm32"))] use std::sync::Condvar; use std::sync::{ Arc, Mutex, Weak, atomic::{AtomicBool, AtomicU64, Ordering}, }; use serde::Serialize; use super::{ Admission, AlphaConvention, CancelToken, ColourEncoding, ErrorCode, FrameInfo, ImageRequest, ImageTask, JobError, JobId, Phase, Progress, Result, TaskPoll, admit, }; use crate::{ Availability, ExecutorDescriptor, ExecutorId, ProductScene, RenderFormat, RenderLimits, RenderMode, RenderOperation, RenderProfile, ScratchLayout, }; const MEMORY_LIMIT: u64 = 512 * 1024 * 1024; const QUEUE_LIMIT: usize = 2; #[cfg(test)] #[path = "host_tests.rs"] mod tests; #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub enum HostKind { NativeWorker, Cooperative, } struct Budget { used: AtomicU64, } struct Reservation { budget: Arc, bytes: u64, } impl Budget { fn reserve(self: &Arc, bytes: u64) -> Result { let mut used = self.used.load(Ordering::Acquire); loop { let next = used .checked_add(bytes) .filter(|n| *n <= MEMORY_LIMIT) .ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "CPU image host's 512 MiB admission pool is full"))?; match self.used.compare_exchange_weak(used, next, Ordering::AcqRel, Ordering::Acquire) { Ok(_) => break, Err(current) => used = current, } } Ok(Reservation { budget: self.clone(), bytes }) } } impl Reservation { fn shrink(&mut self, bytes: u64) -> Result<()> { if bytes > self.bytes { return Err(JobError::new(ErrorCode::LimitExceeded, "completed PNG exceeds its reservation")); } self.budget.used.fetch_sub(self.bytes - bytes, Ordering::AcqRel); self.bytes = bytes; Ok(()) } } impl Drop for Reservation { fn drop(&mut self) { self.budget.used.fetch_sub(self.bytes, Ordering::AcqRel); } } /// Borrow encoded bytes through the frame, so retaining the data also retains its admission lease. pub struct RenderFrame { pub info: FrameInfo, png: Vec, _reservation: Reservation, } impl RenderFrame { pub fn png(&self) -> &[u8] { &self.png } } #[derive(Clone, Debug, Serialize)] pub struct JobStatus { pub job: JobId, pub scene_generation: u64, pub request: ImageRequest, pub resolved: crate::ResolvedRequest, pub progress: Progress, pub error: Option, pub frame: Option, } struct Data { progress: Progress, error: Option, frame: Option>, } struct Record { id: JobId, scene_generation: u64, request: ImageRequest, resolved: crate::ResolvedRequest, cancel: CancelToken, data: Mutex, #[cfg(not(target_arch = "wasm32"))] done: Condvar, } impl Record { fn notify(&self) { #[cfg(not(target_arch = "wasm32"))] self.done.notify_all(); } } #[derive(Clone)] pub struct JobHandle(Arc); impl JobHandle { pub fn id(&self) -> JobId { self.0.id } pub fn status(&self) -> Result { let data = self.0.data.lock().map_err(|_| JobError::new(ErrorCode::Internal, "render status lock poisoned"))?; Ok(JobStatus { job: self.0.id, scene_generation: self.0.scene_generation, request: self.0.request.clone(), resolved: self.0.resolved.clone(), progress: data.progress, error: data.error.clone(), frame: data.frame.as_ref().map(|f| f.info.clone()), }) } pub fn cancel(&self) -> Result { { let mut data = self.0.data.lock().map_err(|_| JobError::new(ErrorCode::Internal, "render status lock poisoned"))?; if !data.progress.phase.terminal() { self.0.cancel.cancel(); data.progress.phase = if data.progress.phase == Phase::Queued { Phase::Cancelled } else { Phase::Cancelling }; } } self.0.notify(); self.status() } /// Native application helper; engine status commands remain non-blocking. #[cfg(not(target_arch = "wasm32"))] pub fn wait_timeout(&self, timeout: std::time::Duration) -> Result { let data = self.0.data.lock().map_err(|_| JobError::new(ErrorCode::Internal, "render status lock poisoned"))?; let (data, timed) = self .0 .done .wait_timeout_while(data, timeout, |d| !d.progress.phase.terminal()) .map_err(|_| JobError::new(ErrorCode::Internal, "render completion lock poisoned"))?; if timed.timed_out() && !data.progress.phase.terminal() { return Err(JobError::new(ErrorCode::TimedOut, "render wait timed out")); } drop(data); self.status() } pub fn frame(&self) -> Result>> { let data = self.0.data.lock().map_err(|_| JobError::new(ErrorCode::Internal, "render result lock poisoned"))?; let frame = data.frame.clone(); if frame.as_ref().is_some_and(|f| f.info.job != self.0.id || f.info.scene_generation != self.0.scene_generation) { return Err(JobError::new(ErrorCode::WrongOwner, "render frame identity does not match its job")); } Ok(frame) } } struct Work { scene: ProductScene, handle: JobHandle, reservation: Reservation, } struct Running { task: ImageTask, handle: JobHandle, reservation: Option, } fn terminal(handle: &JobHandle, error: JobError) { if let Ok(mut data) = handle.0.data.lock() { if data.progress.phase == Phase::Completed { return; } if handle.0.cancel.is_cancelled() || error.code == ErrorCode::Cancelled { data.progress.phase = Phase::Cancelled; data.error = None; } else { data.progress.phase = Phase::Failed; data.error = Some(error); } data.frame = None; } handle.0.notify(); } impl Running { fn begin(work: Work) -> Option { if work.handle.0.cancel.is_cancelled() { terminal(&work.handle, JobError::new(ErrorCode::Cancelled, "render cancelled")); return None; } if let Ok(mut data) = work.handle.0.data.lock() && !work.handle.0.cancel.is_cancelled() { data.progress.phase = Phase::Preparing; } let task = ImageTask::new(work.scene, work.handle.0.request.clone(), work.handle.0.cancel.clone()); match task { Ok(task) => Some(Self { task, handle: work.handle, reservation: Some(work.reservation) }), Err(e) => { terminal(&work.handle, e); None } } } fn advance(&mut self, units: usize) -> Result { let result = self.task.step(units)?; let mut data = self.handle.0.data.lock().map_err(|_| JobError::new(ErrorCode::Internal, "render status lock poisoned"))?; // The final publication check is under the same lock as cancellation. if self.handle.0.cancel.is_cancelled() { data.progress.phase = Phase::Cancelled; data.frame = None; self.handle.0.notify(); return Ok(false); } match result { TaskPoll::Pending(progress) => { data.progress = progress; Ok(true) } TaskPoll::Complete(png) => { let mut reservation = self.reservation.take().ok_or_else(|| JobError::new(ErrorCode::Internal, "missing render reservation"))?; reservation.shrink(png.capacity() as u64 + 1024)?; let info = FrameInfo { job: self.handle.0.id, scene_generation: self.handle.0.scene_generation, backend: self.handle.0.resolved.backend.clone(), executor: self.handle.0.resolved.executor.clone(), width: self.handle.0.request.width, height: self.handle.0.request.height, encoding: ColourEncoding::Srgb, alpha: AlphaConvention::Straight, }; data.frame = Some(Arc::new(RenderFrame { info, png, _reservation: reservation })); data.progress = Progress { phase: Phase::Completed, done: 1, total: 1 }; self.handle.0.notify(); Ok(false) } } } fn guarded_step(&mut self, units: usize) -> bool { let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.advance(units))); match result { Ok(Ok(more)) => more, Ok(Err(e)) => { terminal(&self.handle, e); false } Err(_) => { terminal(&self.handle, JobError::new(ErrorCode::Internal, "CPU image host encountered an internal error")); false } } } } struct Shared { queue: Mutex>, active: Mutex>>, stop: AtomicBool, budget: Arc, #[cfg(not(target_arch = "wasm32"))] wake: Condvar, } struct Owner { shared: Arc, kind: HostKind, running: Mutex>, #[cfg(not(target_arch = "wasm32"))] _worker: Option>, } impl Drop for Owner { fn drop(&mut self) { self.shared.stop.store(true, Ordering::Release); if let Ok(active) = self.shared.active.lock() && let Some(record) = active.as_ref().and_then(Weak::upgrade) { let _ = JobHandle(record).cancel(); } if let Ok(mut queue) = self.shared.queue.lock() { for work in queue.iter() { let _ = work.handle.cancel(); } queue.clear(); } if let Ok(mut running) = self.running.lock() && let Some(work) = running.take() { let _ = work.handle.cancel(); terminal(&work.handle, JobError::new(ErrorCode::Cancelled, "render host closed")); } #[cfg(not(target_arch = "wasm32"))] self.shared.wake.notify_all(); // Detaching cannot stall the UI; the bounded worker observes stop/cancel at its next unit. } } #[derive(Clone)] pub struct CpuHost(Arc); impl CpuHost { fn shared() -> Arc { Arc::new(Shared { queue: Mutex::new(VecDeque::new()), active: Mutex::new(None), stop: AtomicBool::new(false), budget: Arc::new(Budget { used: AtomicU64::new(0) }), #[cfg(not(target_arch = "wasm32"))] wake: Condvar::new(), }) } pub fn cooperative() -> Self { Self(Arc::new(Owner { shared: Self::shared(), kind: HostKind::Cooperative, running: Mutex::new(None), #[cfg(not(target_arch = "wasm32"))] _worker: None, })) } pub fn for_platform() -> Result { #[cfg(target_arch = "wasm32")] { Ok(Self::cooperative()) } #[cfg(not(target_arch = "wasm32"))] { let shared = Self::shared(); let worker_shared = shared.clone(); let worker = std::thread::Builder::new() .name("solidcraft-render".into()) .spawn(move || native_worker(worker_shared)) .map_err(|e| JobError::new(ErrorCode::HostUnavailable, format!("could not start CPU image worker: {e}")))?; Ok(Self(Arc::new(Owner { shared, kind: HostKind::NativeWorker, running: Mutex::new(None), _worker: Some(worker) }))) } } pub fn kind(&self) -> HostKind { self.0.kind } pub fn same_host(&self, other: &Self) -> bool { Arc::ptr_eq(&self.0, &other.0) } pub fn is_available(&self) -> bool { !self.0.shared.stop.load(Ordering::Acquire) } pub fn admitted_bytes(&self) -> u64 { self.0.shared.budget.used.load(Ordering::Acquire) } pub fn descriptor(&self) -> Result { Ok(ExecutorDescriptor { id: ExecutorId::new("cpu-product").map_err(|e| JobError::new(ErrorCode::Internal, e.to_string()))?, availability: if self.0.shared.stop.load(Ordering::Acquire) { Availability::Unavailable { reason: "CPU image host has stopped".into() } } else { Availability::Available }, profiles: vec![RenderProfile { mode: RenderMode::Preview, operation: RenderOperation::Image, formats: vec![RenderFormat::Png], features: vec![crate::RenderFeature::Pbr], }], limits: RenderLimits::default(), scratch: ScratchLayout { sample_scale: 2, sample_bytes_per_pixel: 8, output_bytes_per_pixel: 8, encoding_bytes_per_pixel: 8 }, }) } pub fn submit(&self, id: JobId, scene_generation: u64, scene: ProductScene, request: ImageRequest) -> Result { if id.session == 0 || id.serial == 0 || id.generation == 0 { return Err(JobError::new(ErrorCode::InvalidRequest, "invalid render job identity")); } let Admission { estimated_total_bytes, .. } = admit(&scene, &request)?; let mut backend = crate::builtin_backend(); backend.executors.push(self.descriptor()?); let resolved = crate::resolve_request( &[backend], &crate::RenderRequest { backend: None, mode: RenderMode::Preview, operation: RenderOperation::Image, width: u64::from(request.width), height: u64::from(request.height), format: Some(RenderFormat::Png), features: scene.required_features(), policy: Default::default(), }, ) .map_err(|e| JobError::new(ErrorCode::HostUnavailable, e.to_string()))?; if resolved.executor.as_str() != "cpu-product" { return Err(JobError::new(ErrorCode::HostUnavailable, "CPU product executor is unavailable")); } let mut queue = self.0.shared.queue.lock().map_err(|_| JobError::new(ErrorCode::HostUnavailable, "render queue lock poisoned"))?; if self.0.shared.stop.load(Ordering::Acquire) { return Err(JobError::new(ErrorCode::HostUnavailable, "CPU image host has stopped")); } queue.retain(|work| !work.handle.0.cancel.is_cancelled()); if queue.len() >= QUEUE_LIMIT { return Err(JobError::new(ErrorCode::QueueFull, "CPU image queue is full")); } let reservation = self.0.shared.budget.reserve(estimated_total_bytes)?; let handle = JobHandle(Arc::new(Record { id, scene_generation, request, resolved, cancel: CancelToken::default(), data: Mutex::new(Data { progress: Progress { phase: Phase::Queued, done: 0, total: 1 }, error: None, frame: None }), #[cfg(not(target_arch = "wasm32"))] done: Condvar::new(), })); queue.push_back(Work { scene, handle: handle.clone(), reservation }); drop(queue); #[cfg(not(target_arch = "wasm32"))] self.0.shared.wake.notify_one(); Ok(handle) } /// Cooperative callers pump a small quantum independently of polling a job's status. /// Native jobs run even when the UI is idle or a headless caller is not polling. pub fn pump(&self, units: usize) -> Result { if self.0.kind != HostKind::Cooperative { return Ok(false); } let mut running = self.0.running.lock().map_err(|_| JobError::new(ErrorCode::HostUnavailable, "cooperative host lock poisoned"))?; if running.is_none() { let work = self.0.shared.queue.lock().map_err(|_| JobError::new(ErrorCode::HostUnavailable, "render queue lock poisoned"))?.pop_front(); if let Some(work) = work { *running = Running::begin(work); } } if let Some(work) = running.as_mut() { if let Ok(mut active) = self.0.shared.active.lock() { *active = Some(Arc::downgrade(&work.handle.0)); } if !work.guarded_step(units) { *running = None; if let Ok(mut active) = self.0.shared.active.lock() { *active = None; } } } Ok(running.is_some() || !self.0.shared.queue.lock().map_err(|_| JobError::new(ErrorCode::HostUnavailable, "render queue lock poisoned"))?.is_empty()) } } #[cfg(not(target_arch = "wasm32"))] fn native_worker(shared: Arc) { loop { let mut queue = match shared.queue.lock() { Ok(q) => q, Err(_) => { shared.stop.store(true, Ordering::Release); return; } }; while queue.is_empty() && !shared.stop.load(Ordering::Acquire) { queue = match shared.wake.wait(queue) { Ok(q) => q, Err(_) => { shared.stop.store(true, Ordering::Release); return; } }; } if shared.stop.load(Ordering::Acquire) { return; } let work = queue.pop_front(); drop(queue); let Some(mut running) = work.and_then(Running::begin) else { continue }; if let Ok(mut active) = shared.active.lock() { *active = Some(Arc::downgrade(&running.handle.0)); } loop { if shared.stop.load(Ordering::Acquire) { let _ = running.handle.cancel(); } if !running.guarded_step(4096) { break; } } if let Ok(mut active) = shared.active.lock() { *active = None; } } }