//! Per-document, transient render ownership. Captured scenes remain immutable when CAD edits //! continue; callers get the captured revision and must never present it as a newer scene. use solidcraft_render::{ ExecutorDescriptor, job::{CpuHost, ErrorCode, JobError, JobHandle, JobId, JobStatus, Phase, RenderFrame}, }; use std::collections::VecDeque; use std::sync::{ Arc, atomic::{AtomicU64, Ordering}, }; static NEXT_OWNER: AtomicU64 = AtomicU64::new(1); fn owner() -> u64 { let mut n = NEXT_OWNER.load(Ordering::Relaxed); loop { let Some(next) = n.checked_add(1) else { return 0 }; match NEXT_OWNER.compare_exchange_weak(n, next, Ordering::Relaxed, Ordering::Relaxed) { Ok(_) => return n, Err(current) => n = current, } } } pub(crate) struct Jobs { owner: u64, serial: u64, generation: u64, pub(crate) host: Option, pub(crate) descriptor: Option, live: Vec, history: VecDeque, last_good: Option, } impl Default for Jobs { fn default() -> Self { Self { owner: owner(), serial: 0, generation: 0, host: None, descriptor: None, live: Vec::new(), history: VecDeque::new(), last_good: None } } } impl Drop for Jobs { fn drop(&mut self) { for job in &self.live { let _ = job.cancel(); } } } impl Jobs { pub(crate) fn scratch(&self) -> Self { let mut jobs = Self::default(); jobs.host = self.host.clone(); jobs.descriptor = self.descriptor.clone(); jobs } pub(crate) fn collect(&mut self) -> Result<(), JobError> { let mut i = 0; while i < self.live.len() { let status = self.live.get(i).ok_or_else(|| JobError::new(ErrorCode::Internal, "missing live job"))?.status()?; if !status.progress.phase.terminal() { i += 1; continue; } let handle = self.live.remove(i); if status.progress.phase == Phase::Completed && self.last_good.as_ref().is_none_or(|old| old.id().serial < handle.id().serial) { self.last_good = Some(handle); } self.history.push_back(status); while self.history.len() > 4 { self.history.pop_front(); } } Ok(()) } pub(crate) fn start( &mut self, revision: u64, scene: solidcraft_render::ProductScene, request: solidcraft_render::job::ImageRequest, replace: bool, ) -> Result { self.collect()?; let host = self.host.as_ref().ok_or_else(|| JobError::new(ErrorCode::HostUnavailable, "no image host is installed"))?.clone(); if replace { for job in self.live.drain(..) { let mut status = job.cancel()?; // Replaced handles are deliberately abandoned: keep a cancellation tombstone, // not a permanently "cancelling" snapshot. The token already prevents any // publication; the host can finish releasing its buffers asynchronously. if !status.progress.phase.terminal() { status.progress.phase = Phase::Cancelled; } self.history.push_back(status); } while self.history.len() > 4 { self.history.pop_front(); } } if self.live.len() >= 2 { return Err(JobError::new(ErrorCode::QueueFull, "this document already has an active and pending render")); } if self.owner == 0 { return Err(JobError::new(ErrorCode::LimitExceeded, "render session identity exhausted")); } let serial = self.serial.checked_add(1).ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "render job identity exhausted"))?; let generation = self.generation.checked_add(1).ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "render generation exhausted"))?; let handle = host.submit(JobId { session: self.owner, serial, generation }, revision, scene, request)?; let status = handle.status()?; self.serial = serial; self.generation = generation; self.live.push(handle); Ok(status) } fn check_owner(&self, id: JobId) -> Result<(), JobError> { if id.session != self.owner { return Err(JobError::new(ErrorCode::WrongOwner, "render job belongs to another document session")); } Ok(()) } pub(crate) fn handle(&self, id: JobId) -> Result { self.check_owner(id)?; self.live .iter() .chain(self.last_good.iter()) .find(|j| j.id() == id) .cloned() .ok_or_else(|| JobError::new(ErrorCode::UnknownJob, "render job is no longer retained")) } pub(crate) fn status(&mut self, id: JobId) -> Result { self.check_owner(id)?; self.collect()?; if let Ok(handle) = self.handle(id) { return handle.status(); } self.history.iter().find(|s| s.job == id).cloned().ok_or_else(|| JobError::new(ErrorCode::UnknownJob, "render job is no longer retained")) } pub(crate) fn frame(&mut self, id: JobId) -> Result, JobError> { self.collect()?; self.handle(id)?.frame()?.ok_or_else(|| JobError::new(ErrorCode::NotCompleted, "render has no completed image")) } } impl crate::Session { /// A host is installed by the application, never by document data or a command. pub fn set_render_job_host(&mut self, host: Option) -> crate::Result<()> { if self.render_jobs.host.as_ref().zip(host.as_ref()).is_some_and(|(a, b)| a.same_host(b)) { return Ok(()); } let descriptor = host.as_ref().map(CpuHost::descriptor).transpose().map_err(job_error)?; let mut backend = self.render_backend(); backend.executors.retain(|e| self.render_jobs.descriptor.as_ref().is_none_or(|old| old.id != e.id)); if let Some(d) = &descriptor { backend.executors.push(d.clone()); } solidcraft_render::resolve_request( &[backend], &solidcraft_render::RenderRequest { backend: None, mode: solidcraft_render::RenderMode::Preview, operation: solidcraft_render::RenderOperation::Image, width: 1, height: 1, format: Some(solidcraft_render::RenderFormat::Png), features: Vec::new(), policy: Default::default(), }, ) .map_err(|e| crate::EngineError::Other(e.to_string()))?; let mut jobs = Jobs::default(); jobs.host = host; jobs.descriptor = descriptor; self.render_jobs = jobs; Ok(()) } pub fn render_job_host(&self) -> Option<&CpuHost> { self.render_jobs.host.as_ref() } /// Explicit native CLI waiting is separate from non-blocking status commands. pub fn render_job_handle(&self, id: JobId) -> crate::Result { self.render_jobs.handle(id).map_err(job_error) } } pub(crate) fn job_error(error: JobError) -> crate::EngineError { crate::EngineError::Other(error.to_string()) }