Merge pull request #242 from DioCrafts/claude/resolve-thumbnail-conflict-CEQgq

Add timeout protection for thumbnail generation operations
This commit is contained in:
Dionisio Pozo
2026-03-28 19:43:13 +01:00
committed by GitHub
4 changed files with 112 additions and 67 deletions
+12 -4
View File
@@ -34,15 +34,18 @@ pub struct TimeoutConfig {
pub lock_acquisition_ms: u64,
/// Timeout for network operations (ms)
pub network_operation_ms: u64,
/// Timeout for thumbnail generation (ms)
pub thumbnail_generation_ms: u64,
}
impl Default for TimeoutConfig {
fn default() -> Self {
Self {
file_operation_ms: 10000, // 10 seconds
dir_operation_ms: 30000, // 30 seconds
lock_acquisition_ms: 5000, // 5 seconds
network_operation_ms: 15000, // 15 seconds
file_operation_ms: 10000, // 10 seconds
dir_operation_ms: 30000, // 30 seconds
lock_acquisition_ms: 5000, // 5 seconds
network_operation_ms: 15000, // 15 seconds
thumbnail_generation_ms: 30000, // 30 seconds
}
}
}
@@ -82,6 +85,11 @@ impl TimeoutConfig {
pub fn network_timeout(&self) -> Duration {
Duration::from_millis(self.network_operation_ms)
}
/// Gets a Duration for thumbnail generation operations
pub fn thumbnail_timeout(&self) -> Duration {
Duration::from_millis(self.thumbnail_generation_ms)
}
}
/// Configuration for large resource handling
+2 -1
View File
@@ -119,12 +119,13 @@ impl AppServiceFactory {
}));
tracing::info!("FileContentCache initialized: max 10MB/file, 512MB total, 10k entries");
// Thumbnail service for thumbnail generation
// Thumbnail service for thumbnail generation with timeout protection
let thumbnail_service = Arc::new(
crate::infrastructure::services::thumbnail_service::ThumbnailService::new(
&self.storage_path,
5000, // max 5000 thumbnails in cache
100 * 1024 * 1024, // max 100MB cache
Some(self.config.timeouts.thumbnail_timeout()),
),
);
// Initialize thumbnail directories
@@ -12,11 +12,14 @@ use image::imageops::FilterType;
* - JPEG output (lossy q=80) for compact thumbnails
* - Lock-free moka cache with weight-based eviction
* - Lazy generation on first request if not pre-generated
* - Timeout protection for large image processing
*/
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use tokio::fs;
use tokio::sync::Semaphore;
use tokio::time::timeout;
use crate::application::ports::thumbnail_ports::{
ThumbnailPort, ThumbnailSize as PortThumbnailSize, ThumbnailStatsDto,
@@ -90,6 +93,9 @@ pub struct ThumbnailService {
/// Without this, 50 simultaneous uploads would decode 50 bitmaps
/// (~96 MB each for 6000×4000) = 4.8 GB peak.
decode_semaphore: Arc<Semaphore>,
/// Timeout for thumbnail generation operations to prevent hanging on large images.
/// Defaults to 30 seconds.
generation_timeout: Duration,
}
impl ThumbnailService {
@@ -99,7 +105,13 @@ impl ThumbnailService {
/// * `storage_root` - Root path of file storage
/// * `max_cache_entries` - (ignored — moka uses weight-based eviction)
/// * `max_cache_bytes` - Maximum total bytes to cache
pub fn new(storage_root: &Path, max_cache_entries: usize, max_cache_bytes: usize) -> Self {
/// * `generation_timeout` - Timeout for thumbnail generation operations
pub fn new(
storage_root: &Path,
max_cache_entries: usize,
max_cache_bytes: usize,
generation_timeout: Option<Duration>,
) -> Self {
let thumbnails_root = storage_root.join(".thumbnails");
// Ignore max_cache_entries — weight-based eviction is more accurate
@@ -123,6 +135,7 @@ impl ThumbnailService {
cache,
max_cache_bytes: max_cache_bytes as u64,
decode_semaphore: Arc::new(Semaphore::new(DEFAULT_MAX_CONCURRENT_DECODES)),
generation_timeout: generation_timeout.unwrap_or(Duration::from_secs(30)),
}
}
@@ -351,6 +364,7 @@ impl ThumbnailService {
/// capped at `MAX_DECODE_PIXELS` to reject pathologically large images.
/// After decoding, the encoded image buffer is explicitly dropped before
/// processing to minimize peak memory usage.
/// A timeout prevents the operation from hanging indefinitely on large images.
async fn generate_thumbnail(
&self,
original_path: &Path,
@@ -358,6 +372,7 @@ impl ThumbnailService {
) -> Result<Bytes, ThumbnailError> {
let path = original_path.to_path_buf();
let max_dim = size.max_dimension();
let timeout_duration = self.generation_timeout;
// Acquire semaphore permit — bounds peak RAM from concurrent decodes
let _permit = self
@@ -366,73 +381,83 @@ impl ThumbnailService {
.await
.map_err(|_| ThumbnailError::TaskError("Decode semaphore closed".into()))?;
// Run image processing in blocking thread pool
let result = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, ThumbnailError> {
// Single read: load file once into memory, then work from the buffer
let data =
std::fs::read(&path).map_err(|e| ThumbnailError::ImageError(e.to_string()))?;
// Run image processing in blocking thread pool with timeout
let spawn_result =
tokio::task::spawn_blocking(move || -> Result<Vec<u8>, ThumbnailError> {
// Single read: load file once into memory, then work from the buffer
let data =
std::fs::read(&path).map_err(|e| ThumbnailError::ImageError(e.to_string()))?;
// Safety check: read dimensions from in-memory buffer (no 2nd I/O)
let (w, h) = image::ImageReader::new(std::io::Cursor::new(&data))
.with_guessed_format()
.map_err(|e| ThumbnailError::ImageError(e.to_string()))?
.into_dimensions()
.map_err(|e| ThumbnailError::ImageError(e.to_string()))?;
if (w as u64) * (h as u64) > MAX_DECODE_PIXELS {
return Err(ThumbnailError::ImageError(format!(
"Image too large for thumbnail: {w}×{h} ({} MP, max {MAX_DECODE_PIXELS})",
w as u64 * h as u64 / 1_000_000
)));
}
// Safety check: read dimensions from in-memory buffer (no 2nd I/O)
let (w, h) = image::ImageReader::new(std::io::Cursor::new(&data))
.with_guessed_format()
.map_err(|e| ThumbnailError::ImageError(e.to_string()))?
.into_dimensions()
.map_err(|e| ThumbnailError::ImageError(e.to_string()))?;
if (w as u64) * (h as u64) > MAX_DECODE_PIXELS {
return Err(ThumbnailError::ImageError(format!(
"Image too large for thumbnail: {w}×{h} ({} MP, max {MAX_DECODE_PIXELS})",
w as u64 * h as u64 / 1_000_000
)));
}
// Full decode from the same in-memory buffer (no 2nd disk read)
let img = image::load_from_memory(&data)
.map_err(|e| ThumbnailError::ImageError(e.to_string()))?;
// Full decode from the same in-memory buffer (no 2nd disk read)
let img = image::load_from_memory(&data)
.map_err(|e| ThumbnailError::ImageError(e.to_string()))?;
// Apply EXIF orientation so thumbnails display correctly
let img = {
use crate::infrastructure::services::exif_service::{
ExifService, apply_orientation,
// Apply EXIF orientation so thumbnails display correctly
let img = {
use crate::infrastructure::services::exif_service::{
ExifService, apply_orientation,
};
let orientation = ExifService::extract(&data)
.and_then(|m| m.orientation)
.unwrap_or(1);
// Free the encoded image data now that image is decoded and EXIF extracted
drop(data);
apply_orientation(img, orientation)
};
let orientation = ExifService::extract(&data)
.and_then(|m| m.orientation)
.unwrap_or(1);
// Free the encoded image data now that image is decoded and EXIF extracted
drop(data);
apply_orientation(img, orientation)
};
// Calculate new dimensions preserving aspect ratio
let (orig_width, orig_height) = (img.width(), img.height());
let (new_width, new_height) = if orig_width > orig_height {
let ratio = max_dim as f32 / orig_width as f32;
(max_dim, (orig_height as f32 * ratio) as u32)
} else {
let ratio = max_dim as f32 / orig_height as f32;
((orig_width as f32 * ratio) as u32, max_dim)
};
// Calculate new dimensions preserving aspect ratio
let (orig_width, orig_height) = (img.width(), img.height());
let (new_width, new_height) = if orig_width > orig_height {
let ratio = max_dim as f32 / orig_width as f32;
(max_dim, (orig_height as f32 * ratio) as u32)
} else {
let ratio = max_dim as f32 / orig_height as f32;
((orig_width as f32 * ratio) as u32, max_dim)
};
// Adaptive filter: faster filters for smaller sizes where
// quality difference vs Lanczos3 is imperceptible
let filter = match size {
ThumbnailSize::Icon => FilterType::Triangle, // 150px — max speed
ThumbnailSize::Preview => FilterType::CatmullRom, // 400px — good balance
ThumbnailSize::Large => FilterType::CatmullRom, // 800px — sufficient quality
};
let thumbnail = img.resize(new_width, new_height, filter);
// Adaptive filter: faster filters for smaller sizes where
// quality difference vs Lanczos3 is imperceptible
let filter = match size {
ThumbnailSize::Icon => FilterType::Triangle, // 150px — max speed
ThumbnailSize::Preview => FilterType::CatmullRom, // 400px — good balance
ThumbnailSize::Large => FilterType::CatmullRom, // 800px — sufficient quality
};
let thumbnail = img.resize(new_width, new_height, filter);
// Encode as JPEG (lossy q=80) — explicit quality control,
// ~2× smaller than image-webp's Rust encoder at same visual quality
let rgb = thumbnail.to_rgb8();
let mut buffer = Vec::new();
let encoder = JpegEncoder::new_with_quality(&mut buffer, 80);
rgb.write_with_encoder(encoder)
.map_err(|e| ThumbnailError::ImageError(e.to_string()))?;
// Encode as JPEG (lossy q=80) — explicit quality control,
// ~2× smaller than image-webp's Rust encoder at same visual quality
let rgb = thumbnail.to_rgb8();
let mut buffer = Vec::new();
let encoder = JpegEncoder::new_with_quality(&mut buffer, 80);
rgb.write_with_encoder(encoder)
.map_err(|e| ThumbnailError::ImageError(e.to_string()))?;
Ok(buffer)
})
.await
.map_err(|e| ThumbnailError::TaskError(e.to_string()))?;
Ok(buffer)
});
// Apply timeout to prevent hanging on large images
let result = timeout(timeout_duration, spawn_result)
.await
.map_err(|_| {
ThumbnailError::TaskError(format!(
"Thumbnail generation timed out after {:?}",
timeout_duration
))
})?
.map_err(|e| ThumbnailError::TaskError(e.to_string()))?;
result.map(Bytes::from)
}
@@ -1,4 +1,5 @@
use std::sync::Arc;
use std::time::Duration;
use super::thumbnail_service::{ThumbnailService, ThumbnailSize};
@@ -29,7 +30,12 @@ async fn generate_thumbnail_from_blob_path() {
let blob_path = blob_dir.join("ab1234567890.blob");
std::fs::write(&blob_path, tiny_png()).expect("write test blob");
let svc = Arc::new(ThumbnailService::new(storage_root, 100, 10 * 1024 * 1024));
let svc = Arc::new(ThumbnailService::new(
storage_root,
100,
10 * 1024 * 1024,
Some(Duration::from_secs(30)),
));
svc.initialize().await.expect("init thumbnail dirs");
// The key assertion: the service can read from a blob path (not a logical path)
@@ -51,7 +57,12 @@ async fn generate_thumbnail_from_blob_path() {
#[tokio::test]
async fn generate_thumbnail_nonexistent_path_returns_error() {
let tmp = tempfile::tempdir().expect("create temp dir");
let svc = Arc::new(ThumbnailService::new(tmp.path(), 100, 10 * 1024 * 1024));
let svc = Arc::new(ThumbnailService::new(
tmp.path(),
100,
10 * 1024 * 1024,
Some(Duration::from_secs(30)),
));
svc.initialize().await.expect("init thumbnail dirs");
let bad_path = tmp.path().join("does-not-exist.png");