1use std::{
17 collections::HashMap,
18 path::{Path, PathBuf},
19 sync::{
20 atomic::{AtomicUsize, Ordering},
21 Arc, Mutex,
22 },
23 time::Duration,
24};
25
26use tokio::sync::{OwnedSemaphorePermit, Semaphore, SemaphorePermit};
27
28use crate::{
29 error::{AppError, AppResult},
30 massprops, shape,
31 vault::Vault,
32};
33
34pub const CACHE_VERSION: u32 = 4;
40
41pub const MAX_PARALLEL: usize = 2;
43
44pub const MAX_RENDERS: usize = 2;
48
49#[derive(Clone, Copy, Debug, PartialEq, Eq)]
56pub struct PreviewLimits {
57 pub max_result_bytes: u64,
59 pub max_mesh_bytes: u64,
61 pub max_step_bytes: u64,
63 pub max_memory_bytes: u64,
65}
66
67const MIB: u64 = 1024 * 1024;
68
69impl Default for PreviewLimits {
70 fn default() -> Self {
71 PreviewLimits {
72 max_result_bytes: 64 * MIB,
73 max_mesh_bytes: 256 * MIB,
74 max_step_bytes: crate::mesh::MAX_STEP_BYTES as u64,
75 max_memory_bytes: 1024 * MIB,
76 }
77 }
78}
79
80impl PreviewLimits {
81 pub fn max_bytes(&self, format: PreviewFormat) -> u64 {
83 if format.is_result() {
84 self.max_result_bytes
85 } else if format.is_brep() {
86 self.max_step_bytes
87 } else {
88 self.max_mesh_bytes
89 }
90 }
91}
92
93const TOO_LARGE: &str = "This file is too large for a 3D preview";
95
96fn too_large_message(size: u64, limit: u64) -> String {
98 format!(
99 "{TOO_LARGE} ({} MB, at most {} MB) – attach a rendered picture or a report, \
100 or download the file.",
101 size.div_ceil(MIB),
102 limit / MIB
103 )
104}
105
106fn check_size(input: &Path, limit: u64) -> anyhow::Result<()> {
108 let size = std::fs::metadata(input)?.len();
109 if size > limit {
110 anyhow::bail!("{}", too_large_message(size, limit));
111 }
112 Ok(())
113}
114
115pub fn limit_memory(bytes: u64) -> bool {
120 if bytes == 0 {
121 return false;
122 }
123 #[cfg(unix)]
124 {
125 rlimit::setrlimit(rlimit::Resource::AS, bytes, bytes).is_ok()
126 }
127 #[cfg(not(unix))]
128 {
129 false
130 }
131}
132
133fn describe_failure(aborted: bool, stderr: &str, memory_limit: u64) -> String {
136 let out_of_memory = aborted
137 || stderr.contains("memory allocation")
138 || stderr.contains("out of memory")
139 || stderr.contains("capacity overflow");
140 if out_of_memory && memory_limit > 0 {
141 return format!(
142 "The preview needs more memory than allowed ({} MB) – attach a rendered picture \
143 or a report, or download the file.",
144 memory_limit / MIB
145 );
146 }
147 stderr
148 .lines()
149 .rev()
150 .find(|l| !l.trim().is_empty())
151 .unwrap_or("the converter stopped unexpectedly")
152 .trim_start_matches("Error: ")
153 .chars()
154 .take(500)
155 .collect()
156}
157
158#[derive(Clone, Copy, Debug, PartialEq, Eq)]
160pub enum PreviewFormat {
161 Stl,
162 Obj,
163 Ply,
164 Off,
165 ThreeMf,
166 Amf,
167 Gltf,
168 Glb,
169 ThreeDs,
170 Step,
171 NxPart,
173 Jt,
176 SwPart,
178 SwAssembly,
180 Rhino,
182 InvPart,
184 InvAssembly,
186 CatiaPart,
188 CatiaProduct,
190 SketchUp,
192 CreoPart,
194 CreoAssembly,
196 FusionDesign,
198 Pts,
199 Xyz,
200 Las,
201 VtkLegacy,
203 Vtu,
204 Vtp,
205 Frd,
206}
207
208impl PreviewFormat {
209 pub const ALL: [PreviewFormat; 30] = [
210 PreviewFormat::Stl,
211 PreviewFormat::Obj,
212 PreviewFormat::Ply,
213 PreviewFormat::Off,
214 PreviewFormat::ThreeMf,
215 PreviewFormat::Amf,
216 PreviewFormat::Gltf,
217 PreviewFormat::Glb,
218 PreviewFormat::ThreeDs,
219 PreviewFormat::Step,
220 PreviewFormat::NxPart,
221 PreviewFormat::Jt,
222 PreviewFormat::SwPart,
223 PreviewFormat::SwAssembly,
224 PreviewFormat::Rhino,
225 PreviewFormat::InvPart,
226 PreviewFormat::InvAssembly,
227 PreviewFormat::CatiaPart,
228 PreviewFormat::CatiaProduct,
229 PreviewFormat::SketchUp,
230 PreviewFormat::CreoPart,
231 PreviewFormat::CreoAssembly,
232 PreviewFormat::FusionDesign,
233 PreviewFormat::Pts,
234 PreviewFormat::Xyz,
235 PreviewFormat::Las,
236 PreviewFormat::VtkLegacy,
237 PreviewFormat::Vtu,
238 PreviewFormat::Vtp,
239 PreviewFormat::Frd,
240 ];
241
242 pub fn of(filename: &str) -> Option<PreviewFormat> {
244 crate::cadformat::detect(filename.trim(), &[]).and_then(|f| Self::of_format(f.id))
245 }
246
247 pub fn of_format(format_id: &str) -> Option<PreviewFormat> {
249 Self::ALL.into_iter().find(|p| p.as_str() == format_id)
250 }
251
252 pub fn as_str(self) -> &'static str {
254 match self {
255 PreviewFormat::Stl => "stl",
256 PreviewFormat::Obj => "obj",
257 PreviewFormat::Ply => "ply",
258 PreviewFormat::Off => "off",
259 PreviewFormat::ThreeMf => "3mf",
260 PreviewFormat::Amf => "amf",
261 PreviewFormat::Gltf => "gltf",
262 PreviewFormat::Glb => "glb",
263 PreviewFormat::ThreeDs => "3ds",
264 PreviewFormat::Step => "step",
265 PreviewFormat::NxPart => "nx-part",
266 PreviewFormat::Jt => "jt",
267 PreviewFormat::SwPart => "solidworks-part",
268 PreviewFormat::SwAssembly => "solidworks-assembly",
269 PreviewFormat::Rhino => "rhino",
270 PreviewFormat::InvPart => "inventor-part",
271 PreviewFormat::InvAssembly => "inventor-assembly",
272 PreviewFormat::CatiaPart => "catia-part",
273 PreviewFormat::CatiaProduct => "catia-product",
274 PreviewFormat::SketchUp => "sketchup",
275 PreviewFormat::CreoPart => "creo-part",
276 PreviewFormat::CreoAssembly => "creo-assembly",
277 PreviewFormat::FusionDesign => "fusion-design",
278 PreviewFormat::Pts => "pts",
279 PreviewFormat::Xyz => "xyz",
280 PreviewFormat::Las => "las",
281 PreviewFormat::VtkLegacy => "vtk-legacy",
282 PreviewFormat::Vtu => "vtk-unstructured",
283 PreviewFormat::Vtp => "vtk-polydata",
284 PreviewFormat::Frd => "calculix-frd",
285 }
286 }
287
288 pub fn is_result(self) -> bool {
290 matches!(
291 self,
292 PreviewFormat::VtkLegacy | PreviewFormat::Vtu | PreviewFormat::Vtp | PreviewFormat::Frd
293 )
294 }
295
296 pub fn is_brep(self) -> bool {
298 matches!(self, PreviewFormat::Step | PreviewFormat::CreoPart)
299 }
300
301 pub fn may_be_assembly(self) -> bool {
306 matches!(
307 self,
308 PreviewFormat::NxPart
309 | PreviewFormat::SwAssembly
310 | PreviewFormat::InvAssembly
311 | PreviewFormat::CatiaProduct
312 | PreviewFormat::CreoAssembly
313 )
314 }
315
316 pub fn is_point_cloud(self) -> bool {
317 matches!(
318 self,
319 PreviewFormat::Pts | PreviewFormat::Xyz | PreviewFormat::Las
320 )
321 }
322
323 pub fn time_limit(self) -> Duration {
325 if self.is_brep() {
326 Duration::from_secs(180)
327 } else if self.is_result() {
328 Duration::from_secs(120)
330 } else {
331 Duration::from_secs(60)
332 }
333 }
334}
335
336pub fn convert_file(format: PreviewFormat, input: &Path) -> anyhow::Result<Vec<u8>> {
338 Ok(convert(format, input)?.encode())
339}
340
341pub fn convert(format: PreviewFormat, input: &Path) -> anyhow::Result<crate::mesh::Mesh> {
343 convert_within(format, input, PreviewLimits::default().max_bytes(format))
344}
345
346pub fn convert_within(
348 format: PreviewFormat,
349 input: &Path,
350 max_bytes: u64,
351) -> anyhow::Result<crate::mesh::Mesh> {
352 check_size(input, max_bytes)?;
353 let data = std::fs::read(input)?;
354 crate::mesh::read(format.as_str(), &data)
355}
356
357pub const CACHED_MESH: &str = "tcm";
360
361pub fn run_job(
369 format: &str,
370 input: &Path,
371 output: Option<&Path>,
372 signature: Option<&Path>,
373 mass: Option<&Path>,
374 max_input_bytes: u64,
375) -> anyhow::Result<crate::mesh::Mesh> {
376 let mesh = if format == CACHED_MESH {
377 check_size(input, max_input_bytes)?;
378 let mesh = crate::mesh::Mesh::decode(&std::fs::read(input)?)?;
379 mesh.check()?;
380 mesh
381 } else {
382 let format = PreviewFormat::of_format(format)
383 .ok_or_else(|| anyhow::anyhow!("no 3D preview for the format '{format}'"))?;
384 convert_within(format, input, max_input_bytes)?
385 };
386 if let Some(output) = output {
387 std::fs::write(output, mesh.encode())?;
388 }
389 if let Some(path) = signature {
390 std::fs::write(path, serde_json::to_vec(&signature_outcome(&mesh))?)?;
391 }
392 if let Some(path) = mass {
393 std::fs::write(path, serde_json::to_vec(&mass_outcome(&mesh))?)?;
394 }
395 Ok(mesh)
396}
397
398fn mass_outcome(mesh: &crate::mesh::Mesh) -> massprops::Outcome {
401 let computed =
402 std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| massprops::compute(mesh)));
403 match computed {
404 Ok(Ok(g)) => massprops::Outcome::Geometry(g),
405 Ok(Err(e)) => massprops::Outcome::Error(e.to_string()),
406 Err(_) => massprops::Outcome::Error("The mass properties could not be computed.".into()),
407 }
408}
409
410fn signature_outcome(mesh: &crate::mesh::Mesh) -> shape::Outcome {
412 if let Some((shown, total)) = mesh.faces {
421 return shape::Outcome::Error(format!(
422 "The 3D preview shows only {shown} of {total} faces, so the shape cannot be compared."
423 ));
424 }
425 let computed = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| shape::compute(mesh)));
427 match computed {
428 Ok(Ok(s)) => shape::Outcome::Signature(s),
429 Ok(Err(e)) => shape::Outcome::Error(e.to_string()),
430 Err(_) => shape::Outcome::Error("The shape signature could not be computed.".into()),
431 }
432}
433
434pub fn worker_exe() -> Option<PathBuf> {
441 std::env::current_exe()
442 .ok()
443 .filter(|exe| is_worker_name(exe))
444}
445
446fn is_worker_name(exe: &Path) -> bool {
447 exe.file_stem()
448 .and_then(|s| s.to_str())
449 .is_some_and(|s| s == "teamcraft" || s == crate::localserver::SIDECAR_NAME)
450}
451
452#[derive(Clone, Default)]
455struct Turns(Arc<Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>>);
456
457impl Turns {
458 fn place(&self, key: &str) -> Place {
459 let lock = self
460 .0
461 .lock()
462 .unwrap_or_else(|e| e.into_inner())
463 .entry(key.to_string())
464 .or_default()
465 .clone();
466 Place {
467 turns: self.clone(),
468 key: key.to_string(),
469 lock: Some(lock),
470 }
471 }
472
473 #[cfg(test)]
474 fn len(&self) -> usize {
475 self.0.lock().unwrap_or_else(|e| e.into_inner()).len()
476 }
477}
478
479struct Place {
482 turns: Turns,
483 key: String,
484 lock: Option<Arc<tokio::sync::Mutex<()>>>,
485}
486
487impl Place {
488 async fn turn(&self) -> Option<tokio::sync::OwnedMutexGuard<()>> {
490 match &self.lock {
491 Some(lock) => Some(lock.clone().lock_owned().await),
492 None => None,
493 }
494 }
495}
496
497impl Drop for Place {
498 fn drop(&mut self) {
499 let mut line = self.turns.0.lock().unwrap_or_else(|e| e.into_inner());
500 drop(self.lock.take());
501 if line
503 .get(&self.key)
504 .is_some_and(|lock| Arc::strong_count(lock) == 1)
505 {
506 line.remove(&self.key);
507 }
508 }
509}
510
511struct Rendering {
515 _slot: OwnedSemaphorePermit,
516 _turn: Option<tokio::sync::OwnedMutexGuard<()>>,
517 _place: Place,
518}
519
520pub struct Previews {
522 dir: PathBuf,
523 worker: Option<PathBuf>,
525 slots: Semaphore,
526 waiting: AtomicUsize,
528 limits: PreviewLimits,
529 running: Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
530 renders: Arc<Semaphore>,
532 turns: Turns,
534}
535
536impl Previews {
537 pub fn new(vault: &Vault, limits: PreviewLimits) -> Previews {
538 let worker = worker_exe();
541 Previews {
542 dir: vault.root().join("previews"),
543 worker,
544 slots: Semaphore::new(MAX_PARALLEL),
545 waiting: AtomicUsize::new(0),
546 limits,
547 running: Mutex::new(HashMap::new()),
548 renders: Arc::new(Semaphore::new(MAX_RENDERS)),
549 turns: Turns::default(),
550 }
551 }
552
553 pub async fn render<T, F>(&self, key: &str, work: F) -> AppResult<T>
565 where
566 T: Send + 'static,
567 F: FnOnce() -> T + Send + 'static,
568 {
569 let place = self.turns.place(key);
570 let turn = place.turn().await;
571 let slot = self
572 .renders
573 .clone()
574 .acquire_owned()
575 .await
576 .map_err(|e| AppError::Other(anyhow::anyhow!("render queue closed: {e}")))?;
577 let held = Rendering {
578 _slot: slot,
579 _turn: turn,
580 _place: place,
581 };
582 tokio::task::spawn_blocking(move || {
583 let _held = held;
584 work()
585 })
586 .await
587 .map_err(|e| AppError::Other(anyhow::anyhow!("render task failed: {e}")))
588 }
589
590 pub fn dir(&self) -> &Path {
592 &self.dir
593 }
594
595 pub fn queue(&self) -> (usize, usize) {
598 let running = MAX_PARALLEL.saturating_sub(self.slots.available_permits());
599 (running, self.waiting.load(Ordering::Relaxed))
600 }
601
602 async fn slot(&self) -> AppResult<SemaphorePermit<'_>> {
605 struct Waiting<'a>(&'a AtomicUsize);
606 impl Drop for Waiting<'_> {
607 fn drop(&mut self) {
608 self.0.fetch_sub(1, Ordering::Relaxed);
609 }
610 }
611 self.waiting.fetch_add(1, Ordering::Relaxed);
612 let _waiting = Waiting(&self.waiting);
613 self.slots
614 .acquire()
615 .await
616 .map_err(|e| AppError::Other(anyhow::anyhow!("preview queue closed: {e}")))
617 }
618
619 fn paths(&self, sha256: &str) -> AppResult<(PathBuf, PathBuf)> {
620 if sha256.len() != 64 || !sha256.bytes().all(|b| b.is_ascii_hexdigit()) {
621 return Err(AppError::BadRequest("Invalid content hash.".into()));
622 }
623 let base = self.dir.join(&sha256[..2]);
624 Ok((
625 base.join(format!("{sha256}.v{CACHE_VERSION}.tcm")),
626 base.join(format!("{sha256}.v{CACHE_VERSION}.err")),
627 ))
628 }
629
630 pub fn assembly_path(&self, sha256: &str, key: &str) -> AppResult<PathBuf> {
634 if key.is_empty() || !key.bytes().all(|b| b.is_ascii_hexdigit()) {
635 return Err(AppError::BadRequest("Invalid assembly key.".into()));
636 }
637 let (mesh, _) = self.paths(sha256)?;
638 Ok(mesh.with_file_name(format!("{sha256}.asm-{key}.v{CACHE_VERSION}.tcm")))
639 }
640
641 fn signature_path(&self, sha256: &str) -> AppResult<PathBuf> {
643 let (mesh, _) = self.paths(sha256)?;
644 Ok(mesh.with_extension(format!("s{}.json", shape::VERSION)))
645 }
646
647 fn mass_path(&self, sha256: &str) -> AppResult<PathBuf> {
649 let (mesh, _) = self.paths(sha256)?;
650 Ok(mesh.with_extension(format!("m{}.json", massprops::VERSION)))
651 }
652
653 pub async fn mass_geometry(
658 &self,
659 vault: &Vault,
660 sha256: &str,
661 format: PreviewFormat,
662 ) -> AppResult<massprops::Geometry> {
663 if format.is_point_cloud() || format.is_result() {
664 return Err(AppError::BadRequest(
665 "Point clouds and result files have no volume.".into(),
666 ));
667 }
668 let path = self.mass_path(sha256)?;
669 if let Some(found) = read_mass(&path).await {
670 return found;
671 }
672 let mesh_path = self.mesh(vault, sha256, format).await?;
673 self.exclusive(format!("{sha256}.mass"), async {
674 if let Some(found) = read_mass(&path).await {
675 return found;
676 }
677 let _slot = self.slot().await?;
678 let dir = path.parent().unwrap_or(&self.dir);
679 let tmp = dir.join(format!(
680 "{sha256}.{}.mass.tmp",
681 uuid::Uuid::new_v4().simple()
682 ));
683 let job = Job {
684 format: CACHED_MESH,
685 input: mesh_path.clone(),
686 output: None,
687 signature: None,
688 mass: Some(tmp.clone()),
689 time_limit: Duration::from_secs(60),
690 max_input_bytes: u64::MAX,
691 memory_bytes: self.limits.max_memory_bytes,
692 };
693 let outcome = match &self.worker {
694 Some(exe) => run_worker(exe, &job).await,
695 None => run_in_process(&job).await,
696 };
697 match outcome {
698 Ok(()) => {
699 tokio::fs::rename(&tmp, &path).await.map_err(|e| {
700 AppError::Other(anyhow::anyhow!("cannot store the mass properties: {e}"))
701 })?;
702 }
703 Err(message) => {
704 let _ = tokio::fs::remove_file(&tmp).await;
705 let failed = serde_json::to_vec(&massprops::Outcome::Error(message.clone()))
706 .unwrap_or_default();
707 let _ = tokio::fs::write(&path, failed).await;
708 return Err(AppError::BadRequest(message));
709 }
710 }
711 read_mass(&path).await.unwrap_or_else(|| {
712 Err(AppError::Other(anyhow::anyhow!(
713 "the mass properties could not be read back"
714 )))
715 })
716 })
717 .await
718 }
719
720 pub async fn has_mesh(&self, sha256: &str) -> bool {
722 match self.paths(sha256) {
723 Ok((mesh, _)) => tokio::fs::metadata(mesh).await.is_ok(),
724 Err(_) => false,
725 }
726 }
727
728 pub async fn is_converted(&self, sha256: &str) -> bool {
731 match self.paths(sha256) {
732 Ok((mesh, err)) => {
733 tokio::fs::metadata(mesh).await.is_ok() || tokio::fs::metadata(err).await.is_ok()
734 }
735 Err(_) => false,
736 }
737 }
738
739 async fn exclusive<T, F>(&self, key: String, job: F) -> T
741 where
742 F: std::future::Future<Output = T>,
743 {
744 let lock = {
745 let mut running = self.running.lock().unwrap_or_else(|e| e.into_inner());
746 running.entry(key.clone()).or_default().clone()
747 };
748 let guard = lock.lock().await;
749 let result = job.await;
750 drop(guard);
751 let mut running = self.running.lock().unwrap_or_else(|e| e.into_inner());
752 if running.get(&key).is_some_and(|l| Arc::strong_count(l) <= 2) {
754 running.remove(&key);
755 }
756 result
757 }
758
759 pub async fn signature(
763 &self,
764 vault: &Vault,
765 sha256: &str,
766 format: PreviewFormat,
767 ) -> AppResult<shape::Signature> {
768 if format.is_point_cloud() {
769 return Err(AppError::BadRequest(
770 "Point clouds have no shape signature.".into(),
771 ));
772 }
773 let sig_path = self.signature_path(sha256)?;
774 if let Some(found) = read_signature(&sig_path).await {
775 return found;
776 }
777 let mesh_path = self.mesh(vault, sha256, format).await?;
778 self.exclusive(format!("{sha256}.signature"), async {
779 if let Some(found) = read_signature(&sig_path).await {
780 return found;
781 }
782 let _slot = self.slot().await?;
783 let dir = sig_path.parent().unwrap_or(&self.dir);
784 let tmp = dir.join(format!(
785 "{sha256}.{}.sig.tmp",
786 uuid::Uuid::new_v4().simple()
787 ));
788 let job = Job {
789 format: CACHED_MESH,
790 input: mesh_path.clone(),
791 output: None,
792 signature: Some(tmp.clone()),
793 mass: None,
794 time_limit: Duration::from_secs(60),
795 max_input_bytes: u64::MAX,
796 memory_bytes: self.limits.max_memory_bytes,
797 };
798 let outcome = match &self.worker {
799 Some(exe) => run_worker(exe, &job).await,
800 None => run_in_process(&job).await,
801 };
802 match outcome {
803 Ok(()) => {
804 tokio::fs::rename(&tmp, &sig_path).await.map_err(|e| {
805 AppError::Other(anyhow::anyhow!("cannot store the shape signature: {e}"))
806 })?;
807 }
808 Err(message) => {
809 let _ = tokio::fs::remove_file(&tmp).await;
810 let failed = serde_json::to_vec(&shape::Outcome::Error(message.clone()))
811 .unwrap_or_default();
812 let _ = tokio::fs::write(&sig_path, failed).await;
813 return Err(AppError::BadRequest(message));
814 }
815 }
816 read_signature(&sig_path).await.unwrap_or_else(|| {
817 Err(AppError::Other(anyhow::anyhow!(
818 "the shape signature could not be read back"
819 )))
820 })
821 })
822 .await
823 }
824
825 pub async fn mesh(
827 &self,
828 vault: &Vault,
829 sha256: &str,
830 format: PreviewFormat,
831 ) -> AppResult<PathBuf> {
832 let (mesh_path, err_path) = self.paths(sha256)?;
833 self.check_size(vault, sha256, format, &err_path).await?;
834 if let Some(found) = cached(&mesh_path, &err_path).await? {
835 return Ok(found);
836 }
837 let lock = {
839 let mut running = self.running.lock().unwrap_or_else(|e| e.into_inner());
840 running.entry(sha256.to_string()).or_default().clone()
841 };
842 let guard = lock.lock().await;
843 let result = match cached(&mesh_path, &err_path).await {
844 Ok(Some(found)) => Ok(found),
845 Err(e) => Err(e),
846 Ok(None) => self
847 .convert(vault, sha256, format, &mesh_path, &err_path)
848 .await
849 .map(|_| mesh_path.clone()),
850 };
851 drop(guard);
852 self.running
853 .lock()
854 .unwrap_or_else(|e| e.into_inner())
855 .remove(sha256);
856 result
857 }
858
859 async fn check_size(
863 &self,
864 vault: &Vault,
865 sha256: &str,
866 format: PreviewFormat,
867 err_path: &Path,
868 ) -> AppResult<()> {
869 let Ok(size) = tokio::fs::metadata(vault.object_path(sha256)?)
870 .await
871 .map(|m| m.len())
872 else {
873 return Ok(());
874 };
875 let limit = self.limits.max_bytes(format);
876 if size > limit {
877 let message = too_large_message(size, limit);
878 if let Some(dir) = err_path.parent() {
879 let _ = tokio::fs::create_dir_all(dir).await;
880 }
881 let _ = tokio::fs::write(err_path, &message).await;
882 return Err(AppError::BadRequest(message));
883 }
884 if let Ok(old) = tokio::fs::read_to_string(err_path).await {
886 if old.starts_with(TOO_LARGE) {
887 let _ = tokio::fs::remove_file(err_path).await;
888 }
889 }
890 Ok(())
891 }
892
893 pub fn warm(self: &Arc<Self>, vault: &Vault, sha256: &str, format: PreviewFormat) {
896 let previews = self.clone();
897 let vault = vault.clone();
898 let sha256 = sha256.to_string();
899 tokio::spawn(async move {
900 if let Err(e) = previews.mesh(&vault, &sha256, format).await {
901 tracing::debug!(sha256, error = %e.public_message(), "preview not generated");
902 }
903 });
904 }
905
906 async fn convert(
907 &self,
908 vault: &Vault,
909 sha256: &str,
910 format: PreviewFormat,
911 mesh_path: &Path,
912 err_path: &Path,
913 ) -> AppResult<()> {
914 let input = vault.object_path(sha256)?;
915 if !vault.exists(sha256).await {
916 return Err(AppError::not_found(
917 "The file content is missing in the vault.",
918 ));
919 }
920 let dir = mesh_path.parent().unwrap_or(&self.dir).to_path_buf();
921 tokio::fs::create_dir_all(&dir).await.map_err(|e| {
922 AppError::Other(anyhow::anyhow!("cannot create {}: {e}", dir.display()))
923 })?;
924 let _slot = self.slot().await?;
925 let tmp = dir.join(format!("{sha256}.{}.tmp", uuid::Uuid::new_v4().simple()));
926 let sig_tmp = tmp.with_extension("sig.tmp");
928 let job = Job {
929 format: format.as_str(),
930 input,
931 output: Some(tmp.clone()),
932 signature: (!format.is_point_cloud()).then(|| sig_tmp.clone()),
933 mass: None,
934 time_limit: format.time_limit(),
935 max_input_bytes: self.limits.max_bytes(format),
936 memory_bytes: self.limits.max_memory_bytes,
937 };
938 let started = std::time::Instant::now();
939 let outcome = match &self.worker {
940 Some(exe) => run_worker(exe, &job).await,
941 None => run_in_process(&job).await,
942 };
943 match outcome {
944 Ok(()) => {
945 if job.signature.is_some() {
946 let sig_path = self.signature_path(sha256)?;
947 if tokio::fs::rename(&sig_tmp, &sig_path).await.is_err() {
948 let _ = tokio::fs::remove_file(&sig_tmp).await;
949 }
950 }
951 tokio::fs::rename(&tmp, mesh_path).await.map_err(|e| {
952 AppError::Other(anyhow::anyhow!("cannot store the preview: {e}"))
953 })?;
954 tracing::info!(
955 sha256,
956 format = format.as_str(),
957 ms = started.elapsed().as_millis() as u64,
958 "preview generated"
959 );
960 Ok(())
961 }
962 Err(message) => {
963 let _ = tokio::fs::remove_file(&tmp).await;
964 let _ = tokio::fs::remove_file(&sig_tmp).await;
965 let _ = tokio::fs::write(err_path, &message).await;
966 tracing::info!(sha256, format = format.as_str(), %message, "no preview");
967 Err(AppError::BadRequest(message))
968 }
969 }
970 }
971}
972
973async fn cached(mesh_path: &Path, err_path: &Path) -> AppResult<Option<PathBuf>> {
975 if tokio::fs::metadata(mesh_path).await.is_ok() {
976 return Ok(Some(mesh_path.to_path_buf()));
977 }
978 match tokio::fs::read_to_string(err_path).await {
979 Ok(message) => Err(AppError::BadRequest(message)),
980 Err(_) => Ok(None),
981 }
982}
983
984async fn read_signature(path: &Path) -> Option<AppResult<shape::Signature>> {
986 let bytes = tokio::fs::read(path).await.ok()?;
987 match serde_json::from_slice::<shape::Outcome>(&bytes).ok()? {
988 shape::Outcome::Signature(s) if s.is_valid() => Some(Ok(s)),
989 shape::Outcome::Signature(_) => None,
990 shape::Outcome::Error(message) => Some(Err(AppError::BadRequest(message))),
991 }
992}
993
994async fn read_mass(path: &Path) -> Option<AppResult<massprops::Geometry>> {
996 let bytes = tokio::fs::read(path).await.ok()?;
997 match serde_json::from_slice::<massprops::Outcome>(&bytes).ok()? {
998 massprops::Outcome::Geometry(g) if g.is_valid() => Some(Ok(g)),
999 massprops::Outcome::Geometry(_) => None,
1000 massprops::Outcome::Error(message) => Some(Err(AppError::BadRequest(message))),
1001 }
1002}
1003
1004struct Job {
1006 format: &'static str,
1007 input: PathBuf,
1008 output: Option<PathBuf>,
1009 signature: Option<PathBuf>,
1010 mass: Option<PathBuf>,
1011 time_limit: Duration,
1012 max_input_bytes: u64,
1014 memory_bytes: u64,
1016}
1017
1018async fn run_worker(exe: &Path, job: &Job) -> Result<(), String> {
1020 let mut command = tokio::process::Command::new(exe);
1021 command
1022 .arg("preview-mesh")
1023 .arg("--format")
1024 .arg(job.format)
1025 .arg("--input")
1026 .arg(&job.input)
1027 .arg("--max-input-bytes")
1028 .arg(job.max_input_bytes.to_string())
1029 .arg("--max-memory-bytes")
1030 .arg(job.memory_bytes.to_string());
1031 if let Some(output) = &job.output {
1032 command.arg("--output").arg(output);
1033 }
1034 if let Some(signature) = &job.signature {
1035 command.arg("--signature").arg(signature);
1036 }
1037 if let Some(mass) = &job.mass {
1038 command.arg("--mass-properties").arg(mass);
1039 }
1040 let child = command
1041 .env_clear()
1042 .env("RUST_LOG", "error")
1043 .stdin(std::process::Stdio::null())
1044 .stdout(std::process::Stdio::null())
1045 .stderr(std::process::Stdio::piped())
1046 .kill_on_drop(true)
1047 .spawn()
1048 .map_err(|e| format!("The preview converter could not be started: {e}"))?;
1049 match tokio::time::timeout(job.time_limit, child.wait_with_output()).await {
1050 Err(_) => Err(format!(
1051 "The model is too complex to preview (no result within {} s) – download it instead.",
1052 job.time_limit.as_secs()
1053 )),
1054 Ok(Err(e)) => Err(format!("The preview converter failed: {e}")),
1055 Ok(Ok(out)) if out.status.success() => Ok(()),
1056 Ok(Ok(out)) => {
1057 #[cfg(unix)]
1058 let aborted = std::os::unix::process::ExitStatusExt::signal(&out.status) == Some(6);
1059 #[cfg(not(unix))]
1060 let aborted = false;
1061 let stderr = String::from_utf8_lossy(&out.stderr);
1062 Err(describe_failure(aborted, &stderr, job.memory_bytes))
1063 }
1064 }
1065}
1066
1067async fn run_in_process(job: &Job) -> Result<(), String> {
1069 let (format, input, output, signature, mass, max_input) = (
1070 job.format,
1071 job.input.clone(),
1072 job.output.clone(),
1073 job.signature.clone(),
1074 job.mass.clone(),
1075 job.max_input_bytes,
1076 );
1077 let task = tokio::task::spawn_blocking(move || -> Result<(), String> {
1078 run_job(
1079 format,
1080 &input,
1081 output.as_deref(),
1082 signature.as_deref(),
1083 mass.as_deref(),
1084 max_input,
1085 )
1086 .map(drop)
1087 .map_err(|e| e.to_string())
1088 });
1089 match tokio::time::timeout(job.time_limit, task).await {
1090 Err(_) => Err(format!(
1091 "The model is too complex to preview (no result within {} s).",
1092 job.time_limit.as_secs()
1093 )),
1094 Ok(Err(e)) => Err(format!("The preview converter failed: {e}")),
1095 Ok(Ok(result)) => result,
1096 }
1097}
1098
1099#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1101pub enum CacheEntry<'a> {
1102 Current(&'a str),
1104 Stale,
1106 Temporary,
1108 Unknown,
1110}
1111
1112pub fn cache_entry(name: &str) -> CacheEntry<'_> {
1118 let Some((sha256, rest)) = name.split_at_checked(64) else {
1119 return CacheEntry::Unknown;
1120 };
1121 let Some(rest) = rest.strip_prefix('.') else {
1122 return CacheEntry::Unknown;
1123 };
1124 if !sha256
1125 .bytes()
1126 .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
1127 {
1128 return CacheEntry::Unknown;
1129 }
1130 if rest.ends_with(".tmp") {
1131 return CacheEntry::Temporary;
1132 }
1133 if let Some((key, tail)) = rest.strip_prefix("asm-").and_then(|r| r.split_once('.')) {
1135 if !key.is_empty() && key.bytes().all(|b| b.is_ascii_hexdigit()) {
1136 if let Some(n) = tail.strip_prefix('v').and_then(|t| t.strip_suffix(".tcm")) {
1137 if n == CACHE_VERSION.to_string() {
1138 return CacheEntry::Current(sha256);
1139 }
1140 if !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()) {
1141 return CacheEntry::Stale;
1142 }
1143 }
1144 }
1145 }
1146 let mesh = format!("v{CACHE_VERSION}.");
1147 let thumb = format!("thumb.v{}.", crate::thumbnail::CACHE_VERSION);
1148 let current = match rest.strip_prefix(&mesh) {
1149 Some(kind) => {
1150 kind == "tcm"
1151 || kind == "err"
1152 || kind == format!("s{}.json", shape::VERSION)
1153 || kind == format!("m{}.json", massprops::VERSION)
1154 }
1155 None => rest
1156 .strip_prefix(&thumb)
1157 .is_some_and(|ext| matches!(ext, "png" | "bmp" | "jpg" | "none")),
1158 };
1159 if current {
1160 return CacheEntry::Current(sha256);
1161 }
1162 let version = |s: &str| {
1164 s.strip_prefix('v')
1165 .and_then(|s| s.split_once('.'))
1166 .is_some_and(|(n, _)| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
1167 };
1168 if version(rest) || rest.strip_prefix("thumb.").is_some_and(version) {
1169 CacheEntry::Stale
1170 } else {
1171 CacheEntry::Unknown
1172 }
1173}
1174
1175#[cfg(test)]
1176mod tests {
1177 use super::*;
1178
1179 #[test]
1182 fn worker_names() {
1183 for name in [
1184 "/usr/bin/teamcraft",
1185 "teamcraft.exe",
1186 "/Applications/TeamCraft.app/Contents/MacOS/teamcraft-server",
1187 "teamcraft-server.exe",
1188 ] {
1189 assert!(is_worker_name(Path::new(name)), "{name}");
1190 }
1191 for name in [
1192 "teamcraft-desktop",
1193 "TeamCraft",
1194 "local_server-5e3f",
1195 "deps/teamcraft-1a2b",
1196 ] {
1197 assert!(!is_worker_name(Path::new(name)), "{name}");
1198 }
1199 }
1200
1201 #[test]
1202 fn partial_previews_get_no_shape_signature() {
1203 let mut mesh = crate::mesh::Mesh {
1204 positions: vec![0.0, 0.0, 0.0, 4.0, 0.0, 0.0, 0.0, 3.0, 0.0],
1205 indices: vec![0, 1, 2],
1206 ..Default::default()
1207 };
1208 assert!(matches!(
1209 signature_outcome(&mesh),
1210 shape::Outcome::Signature(_)
1211 ));
1212 mesh.faces = Some((5, 6));
1213 match signature_outcome(&mesh) {
1214 shape::Outcome::Error(message) => assert!(message.contains("5 of 6 faces")),
1215 other => panic!("{other:?}"),
1216 }
1217 }
1218
1219 #[test]
1220 fn failures_of_the_conversion_process_are_explained() {
1221 let limit = 1024 * MIB;
1222 let m = describe_failure(true, "", limit);
1223 assert!(m.contains("more memory than allowed (1024 MB)"), "{m}");
1224 let m = describe_failure(false, "memory allocation of 99 bytes failed", limit);
1225 assert!(m.contains("more memory"), "{m}");
1226 let m = describe_failure(true, "Error: broken file\n", 0);
1228 assert_eq!(m, "broken file");
1229 assert_eq!(
1230 describe_failure(false, "x\nError: no triangles\n\n", limit),
1231 "no triangles"
1232 );
1233 assert_eq!(
1234 describe_failure(false, "", limit),
1235 "the converter stopped unexpectedly"
1236 );
1237 }
1238
1239 #[test]
1240 fn caps_by_kind() {
1241 let l = PreviewLimits::default();
1242 assert_eq!(l.max_bytes(PreviewFormat::Vtu), 64 * MIB);
1243 assert_eq!(l.max_bytes(PreviewFormat::Frd), 64 * MIB);
1244 assert_eq!(l.max_bytes(PreviewFormat::Stl), 256 * MIB);
1245 assert_eq!(l.max_bytes(PreviewFormat::Las), 256 * MIB);
1246 assert_eq!(l.max_bytes(PreviewFormat::Step), 256 * MIB);
1247 let dir = std::env::temp_dir().join(format!("tc-cap-{}", uuid::Uuid::new_v4()));
1248 std::fs::create_dir_all(&dir).unwrap();
1249 let file = dir.join("big.vtu");
1250 std::fs::File::create(&file)
1251 .unwrap()
1252 .set_len(5 * MIB)
1253 .unwrap();
1254 let e = convert_within(PreviewFormat::Vtu, &file, MIB).unwrap_err();
1255 assert!(e.to_string().contains("too large for a 3D preview"), "{e}");
1256 assert!(e.to_string().contains("5 MB, at most 1 MB"), "{e}");
1257 std::fs::remove_dir_all(&dir).ok();
1258 }
1259
1260 #[test]
1261 fn formats_by_extension() {
1262 assert_eq!(PreviewFormat::of("bracket.STL"), Some(PreviewFormat::Stl));
1263 assert_eq!(PreviewFormat::of("engine.step"), Some(PreviewFormat::Step));
1264 assert_eq!(
1265 PreviewFormat::of("engine.v2.stp"),
1266 Some(PreviewFormat::Step)
1267 );
1268 assert_eq!(PreviewFormat::of("engine.p21"), Some(PreviewFormat::Step));
1269 assert_eq!(PreviewFormat::of("print.3mf"), Some(PreviewFormat::ThreeMf));
1270 assert_eq!(PreviewFormat::of("scene.GLB"), Some(PreviewFormat::Glb));
1271 assert_eq!(PreviewFormat::of("scan.las"), Some(PreviewFormat::Las));
1272 assert_eq!(
1273 PreviewFormat::of("Bracket.SLDPRT"),
1274 Some(PreviewFormat::SwPart)
1275 );
1276 assert_eq!(
1277 PreviewFormat::of("Rover.sldasm"),
1278 Some(PreviewFormat::SwAssembly)
1279 );
1280 assert_eq!(
1281 PreviewFormat::of("Bracket.ipt"),
1282 Some(PreviewFormat::InvPart)
1283 );
1284 assert_eq!(
1285 PreviewFormat::of("Pump.IAM"),
1286 Some(PreviewFormat::InvAssembly)
1287 );
1288 assert_eq!(
1289 PreviewFormat::of("Frame.CATProduct"),
1290 Some(PreviewFormat::CatiaProduct)
1291 );
1292 assert!(PreviewFormat::CatiaProduct.may_be_assembly());
1293 assert_eq!(
1294 PreviewFormat::of("Frame.asm.12"),
1295 Some(PreviewFormat::CreoAssembly)
1296 );
1297 assert!(PreviewFormat::CreoAssembly.may_be_assembly());
1298 assert_eq!(PreviewFormat::of("Bracket.par"), None, "native CAD");
1299 assert_eq!(
1300 PreviewFormat::of("plate.iges"),
1301 None,
1302 "no B-rep reader for IGES"
1303 );
1304 assert_eq!(PreviewFormat::of("drawing.pdf"), None);
1305 assert_eq!(PreviewFormat::of("stl"), None);
1306 assert_eq!(PreviewFormat::of(".stl"), None);
1307 assert!(PreviewFormat::Step.is_brep() && !PreviewFormat::Stl.is_brep());
1308 assert!(PreviewFormat::Las.is_point_cloud());
1309 assert_eq!(PreviewFormat::of("chair.3dm"), Some(PreviewFormat::Rhino));
1310 assert_eq!(PreviewFormat::of("beam.vtu"), Some(PreviewFormat::Vtu));
1311 assert_eq!(
1312 PreviewFormat::of("beam.VTK"),
1313 Some(PreviewFormat::VtkLegacy)
1314 );
1315 assert_eq!(PreviewFormat::of("skin.vtp"), Some(PreviewFormat::Vtp));
1316 assert_eq!(PreviewFormat::of("job.frd"), Some(PreviewFormat::Frd));
1317 assert_eq!(PreviewFormat::of("grid.vts"), None, "structured grids");
1318 assert!(PreviewFormat::Frd.is_result() && !PreviewFormat::Stl.is_result());
1319 for p in PreviewFormat::ALL {
1320 assert_eq!(PreviewFormat::of_format(p.as_str()), Some(p));
1321 assert!(
1322 crate::cadformat::by_id(p.as_str()).is_some(),
1323 "{} is a registered format",
1324 p.as_str()
1325 );
1326 }
1327 }
1328
1329 #[tokio::test]
1330 async fn conversions_are_cached_including_failures() {
1331 let root = std::env::temp_dir().join(format!("teamcraft-preview-{}", uuid::Uuid::new_v4()));
1332 let vault = Vault::open(&root).await.unwrap();
1333 let previews = Previews::new(&vault, PreviewLimits::default());
1334 assert!(previews.worker.is_none(), "tests convert in-process");
1335 let ok = vault
1336 .store_bytes(b"solid t\nfacet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\nendsolid t\n")
1337 .await
1338 .unwrap();
1339 let path = previews
1340 .mesh(&vault, &ok.sha256, PreviewFormat::Stl)
1341 .await
1342 .unwrap();
1343 let mesh = crate::mesh::Mesh::decode(&std::fs::read(&path).unwrap()).unwrap();
1344 assert_eq!(mesh.triangle_count(), 1);
1345 assert_eq!(
1347 previews
1348 .mesh(&vault, &ok.sha256, PreviewFormat::Stl)
1349 .await
1350 .unwrap(),
1351 path
1352 );
1353 let bad = vault.store_bytes(b"this is not a mesh").await.unwrap();
1354 let first = previews.mesh(&vault, &bad.sha256, PreviewFormat::Stl).await;
1355 assert!(matches!(first, Err(AppError::BadRequest(_))));
1356 let (_, err_path) = previews.paths(&bad.sha256).unwrap();
1357 assert!(err_path.exists(), "the failure is remembered");
1358 assert!(previews
1359 .mesh(&vault, &bad.sha256, PreviewFormat::Stl)
1360 .await
1361 .is_err());
1362 assert!(previews.paths("../../etc/passwd").is_err());
1363
1364 let sig_path = previews.signature_path(&ok.sha256).unwrap();
1366 assert!(sig_path.exists(), "signature written with the preview");
1367 let sig = previews
1368 .signature(&vault, &ok.sha256, PreviewFormat::Stl)
1369 .await
1370 .unwrap();
1371 assert_eq!(sig.triangles, 1);
1372 assert!(!sig.closed);
1373 std::fs::remove_file(&sig_path).unwrap();
1375 assert!(previews.has_mesh(&ok.sha256).await);
1376 assert!(previews.is_converted(&ok.sha256).await);
1378 assert!(previews.is_converted(&bad.sha256).await);
1379 assert!(!previews.is_converted(&"0".repeat(64)).await);
1380 let again = previews
1381 .signature(&vault, &ok.sha256, PreviewFormat::Stl)
1382 .await
1383 .unwrap();
1384 assert_eq!(again, sig);
1385 assert!(previews
1386 .signature(&vault, &bad.sha256, PreviewFormat::Stl)
1387 .await
1388 .is_err());
1389 assert!(previews
1390 .signature(&vault, &ok.sha256, PreviewFormat::Xyz)
1391 .await
1392 .is_err());
1393 let broken = root.join("broken.tcm");
1395 std::fs::write(&broken, b"TCMESH01\xff\xff\xff\xff").unwrap();
1396 assert!(run_job(
1397 CACHED_MESH,
1398 &broken,
1399 None,
1400 Some(&root.join("x.json")),
1401 None,
1402 u64::MAX
1403 )
1404 .is_err());
1405 std::fs::remove_dir_all(&root).ok();
1406 }
1407
1408 #[test]
1409 fn cache_names_are_classified() {
1410 let sha = "ab".repeat(32);
1411 let previews = Previews {
1412 dir: PathBuf::from("/vault/previews"),
1413 worker: None,
1414 slots: Semaphore::new(1),
1415 waiting: AtomicUsize::new(0),
1416 limits: PreviewLimits::default(),
1417 running: Mutex::new(HashMap::new()),
1418 renders: Arc::new(Semaphore::new(1)),
1419 turns: Turns::default(),
1420 };
1421 let (mesh, err) = previews.paths(&sha).unwrap();
1422 let asm = previews.assembly_path(&sha, "0123abcd").unwrap();
1423 let sig = previews.signature_path(&sha).unwrap();
1424 let mass = previews.mass_path(&sha).unwrap();
1425 for path in [&mesh, &err, &sig, &asm, &mass] {
1426 let name = path.file_name().unwrap().to_str().unwrap();
1427 assert_eq!(cache_entry(name), CacheEntry::Current(&sha), "{name}");
1428 }
1429 for ext in ["png", "bmp", "jpg", "none"] {
1430 let name = format!("{sha}.thumb.v{}.{ext}", crate::thumbnail::CACHE_VERSION);
1431 assert_eq!(cache_entry(&name), CacheEntry::Current(&sha), "{name}");
1432 }
1433 let old = CACHE_VERSION + 7;
1434 for name in [
1435 format!("{sha}.v{old}.tcm"),
1436 format!("{sha}.v{old}.err"),
1437 format!("{sha}.v{CACHE_VERSION}.s{}.json", shape::VERSION + 7),
1438 format!("{sha}.v{CACHE_VERSION}.m{}.json", massprops::VERSION + 7),
1439 format!("{sha}.thumb.v{old}.png"),
1440 format!("{sha}.asm-0123abcd.v{old}.tcm"),
1441 ] {
1442 assert_eq!(cache_entry(&name), CacheEntry::Stale, "{name}");
1443 }
1444 for name in [
1445 format!("{sha}.0123abcd.tmp"),
1446 format!("{sha}.0123abcd.sig.tmp"),
1447 format!("{sha}.thumb.v{CACHE_VERSION}.png.4711.tmp"),
1448 ] {
1449 assert_eq!(cache_entry(&name), CacheEntry::Temporary, "{name}");
1450 }
1451 for name in [
1452 "README".to_string(),
1453 format!("{}.v2.tcm", "AB".repeat(32)),
1454 format!("{sha}v2.tcm"),
1455 format!("{sha}.notes.txt"),
1456 format!("{}.v2.tcm", "ab".repeat(31)),
1457 "é".repeat(40),
1458 ] {
1459 assert_eq!(cache_entry(&name), CacheEntry::Unknown, "{name}");
1460 }
1461 }
1462
1463 #[tokio::test]
1464 async fn the_queue_counts_running_and_waiting_conversions() {
1465 let root = std::env::temp_dir().join(format!("teamcraft-preview-{}", uuid::Uuid::new_v4()));
1466 let vault = Vault::open(&root).await.unwrap();
1467 let previews = Arc::new(Previews::new(&vault, PreviewLimits::default()));
1468 assert_eq!(previews.queue(), (0, 0));
1469 let held: Vec<_> = take_every_slot(&previews).await;
1470 assert_eq!(previews.queue(), (MAX_PARALLEL, 0));
1471 let waiter = {
1472 let previews = previews.clone();
1473 tokio::spawn(async move {
1474 let _slot = previews.slot().await.unwrap();
1475 })
1476 };
1477 while previews.queue().1 == 0 {
1478 tokio::task::yield_now().await;
1479 }
1480 assert_eq!(previews.queue(), (MAX_PARALLEL, 1));
1481 waiter.abort();
1483 let _ = waiter.await;
1484 assert_eq!(previews.queue(), (MAX_PARALLEL, 0));
1485 drop(held);
1486 assert_eq!(previews.queue(), (0, 0));
1487 std::fs::remove_dir_all(&root).ok();
1488 }
1489
1490 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1491 async fn renders_are_bounded_and_done_once_per_key() {
1492 let root = std::env::temp_dir().join(format!("teamcraft-preview-{}", uuid::Uuid::new_v4()));
1493 let vault = Vault::open(&root).await.unwrap();
1494 let previews = Arc::new(Previews::new(&vault, PreviewLimits::default()));
1495 let cache = Arc::new(Mutex::new(std::collections::HashSet::<String>::new()));
1496 let (running, peak) = (Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0)));
1497 let mut requests = Vec::new();
1498 for n in 0..18 {
1500 let (previews, cache) = (previews.clone(), cache.clone());
1501 let (running, peak) = (running.clone(), peak.clone());
1502 requests.push(tokio::spawn(async move {
1503 let key = format!("entry-{}", n % 6);
1504 let work_key = key.clone();
1505 previews
1506 .render(&key, move || {
1507 if cache.lock().unwrap().contains(&work_key) {
1508 return false;
1509 }
1510 let now = running.fetch_add(1, Ordering::SeqCst) + 1;
1511 peak.fetch_max(now, Ordering::SeqCst);
1512 std::thread::sleep(Duration::from_millis(30));
1513 cache.lock().unwrap().insert(work_key);
1514 running.fetch_sub(1, Ordering::SeqCst);
1515 true
1516 })
1517 .await
1518 .unwrap()
1519 }));
1520 }
1521 let mut rendered = 0;
1522 for request in requests {
1523 rendered += usize::from(request.await.unwrap());
1524 }
1525 assert_eq!(rendered, 6);
1527 let peak = peak.load(Ordering::SeqCst);
1528 assert!((1..=MAX_RENDERS).contains(&peak), "{peak} at once");
1529 assert_eq!(previews.renders.available_permits(), MAX_RENDERS);
1530 assert_eq!(previews.turns.len(), 0);
1531 std::fs::remove_dir_all(&root).ok();
1532 }
1533
1534 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1535 async fn a_render_keeps_its_slot_and_turn_when_the_request_is_gone() {
1536 let root = std::env::temp_dir().join(format!("teamcraft-preview-{}", uuid::Uuid::new_v4()));
1537 let vault = Vault::open(&root).await.unwrap();
1538 let previews = Arc::new(Previews::new(&vault, PreviewLimits::default()));
1539 let (started_tx, started_rx) = std::sync::mpsc::channel::<()>();
1540 let (finish_tx, finish_rx) = std::sync::mpsc::channel::<()>();
1541 let request = {
1542 let previews = previews.clone();
1543 tokio::spawn(async move {
1544 previews
1545 .render("entry", move || {
1546 started_tx.send(()).unwrap();
1547 finish_rx.recv().unwrap();
1548 })
1549 .await
1550 })
1551 };
1552 tokio::task::spawn_blocking(move || started_rx.recv().unwrap())
1553 .await
1554 .unwrap();
1555 let waiter = {
1557 let previews = previews.clone();
1558 tokio::spawn(async move { previews.render("entry", || ()).await })
1559 };
1560 request.abort();
1561 let _ = request.await;
1562 tokio::time::sleep(Duration::from_millis(50)).await;
1563 assert_eq!(previews.renders.available_permits(), MAX_RENDERS - 1);
1565 assert_eq!(previews.turns.len(), 1);
1566 assert!(!waiter.is_finished());
1567 waiter.abort();
1569 let _ = waiter.await;
1570 assert_eq!(previews.turns.len(), 1);
1571 finish_tx.send(()).unwrap();
1572 for _ in 0..200 {
1573 if previews.turns.len() == 0 {
1574 break;
1575 }
1576 tokio::time::sleep(Duration::from_millis(10)).await;
1577 }
1578 assert_eq!(previews.renders.available_permits(), MAX_RENDERS);
1579 assert_eq!(previews.turns.len(), 0);
1580 std::fs::remove_dir_all(&root).ok();
1581 }
1582
1583 async fn take_every_slot(previews: &Previews) -> Vec<SemaphorePermit<'_>> {
1584 let mut held = Vec::new();
1585 for _ in 0..MAX_PARALLEL {
1586 held.push(previews.slot().await.unwrap());
1587 }
1588 held
1589 }
1590}