From ecd8bbe61db8b7e899aaa913241b255bf739d4f9 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 13 Apr 2026 15:25:55 +0200 Subject: [PATCH] fix: HEIC vips fallback, DB pool exhaustion, auth session persistence HEIC thumbnails: - Switch fallback from ffmpeg to vips for HEIC files that pillow-heif rejects. ffmpeg decoded gain map tiles instead of the primary image, producing inverted/negative thumbnails. vips uses libheif's item references correctly and extracts the full-resolution primary image. Database pool exhaustion: - Add idle_in_transaction_session_timeout=60s so Postgres auto-kills leaked connections from disconnected thumbnail requests. - Add pool_timeout=10 so new requests fail fast instead of hanging. - Bump pool from 5+5 to 10+10 for thumbnail concurrency headroom. - get_db rolls back on exception before closing. Auth session persistence: - Narrow 401 interceptor exclusion to only /auth/refresh and /auth/login (was excluding all /auth/* including /auth/me, preventing token refresh on boot). - fetchMe only clears tokens on 401/403, not network errors. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/config.py | 4 ++-- backend/app/database.py | 13 ++++++++++++- backend/app/tasks/thumbs.py | 26 +++++++++++++++----------- frontend/src/contexts/AuthContext.tsx | 14 ++++++++++---- 4 files changed, 39 insertions(+), 18 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index 3687364..7268702 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -26,8 +26,8 @@ class PerformanceSettings(BaseModel): """Performance tuning settings""" max_concurrent_thumbnails: int = 10 cache_ttl: int = 3600 - db_pool_size: int = 5 - db_pool_max_overflow: int = 5 + db_pool_size: int = 10 + db_pool_max_overflow: int = 10 db_pool_recycle: int = 3600 class EmbedderSettings(BaseModel): diff --git a/backend/app/database.py b/backend/app/database.py index 670571b..679b20d 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -76,6 +76,10 @@ else: max_overflow=settings.performance.db_pool_max_overflow, pool_recycle=settings.performance.db_pool_recycle, pool_pre_ping=True, + pool_timeout=10, # fail fast if pool exhausted (default 30) + # Kill connections idle in a transaction for >60s. Prevents leaked + # sessions from thumbnail requests that disconnect mid-flight. + connect_args={"server_settings": {"idle_in_transaction_session_timeout": "60000"}}, ) # Create async session factory @@ -89,10 +93,17 @@ AsyncSessionLocal = async_sessionmaker( Base = declarative_base() async def get_db() -> AsyncSession: - """Dependency to get database session""" + """Dependency to get database session. + + Rolls back any uncommitted transaction before closing so a client + disconnect doesn't leave idle-in-transaction connections in the pool. + """ async with AsyncSessionLocal() as session: try: yield session + except Exception: + await session.rollback() + raise finally: await session.close() diff --git a/backend/app/tasks/thumbs.py b/backend/app/tasks/thumbs.py index 85f3d6a..6626a0e 100644 --- a/backend/app/tasks/thumbs.py +++ b/backend/app/tasks/thumbs.py @@ -114,23 +114,27 @@ def process_heic_image(filepath: str) -> Image.Image: img = img.convert('RGB') return img except Exception as e: - logger.warning(f"pillow-heif failed for {filepath}: {e} — trying ffmpeg") + logger.warning(f"pillow-heif failed for {filepath}: {e} — trying vips") - # ffmpeg fallback: decode HEIC to PNG in memory. - import subprocess - from io import BytesIO + # vips fallback: handles tiled Apple HEIC files (bursts, HDR gain + # maps, depth maps) that pillow-heif/libheif rejects due to too many + # auxiliary image references. + import subprocess, tempfile try: + with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp: + tmp_path = tmp.name result = subprocess.run( - ['ffmpeg', '-i', filepath, '-frames:v', '1', - '-f', 'image2pipe', '-vcodec', 'png', 'pipe:1'], - capture_output=True, timeout=30, stdin=subprocess.DEVNULL, + ['vips', 'heifload', filepath, tmp_path], + capture_output=True, timeout=60, stdin=subprocess.DEVNULL, ) - if result.returncode == 0 and result.stdout: - img = Image.open(BytesIO(result.stdout)).convert('RGB') + if result.returncode == 0: + img = Image.open(tmp_path).convert('RGB') + os.unlink(tmp_path) return img - logger.error(f"ffmpeg HEIC decode failed for {filepath}: {result.stderr.decode()[-200:]}") + logger.error(f"vips HEIC decode failed for {filepath}: {result.stderr.decode()[-200:]}") + os.unlink(tmp_path) except Exception as e2: - logger.error(f"ffmpeg fallback failed for {filepath}: {e2}") + logger.error(f"vips fallback failed for {filepath}: {e2}") raise RuntimeError(f"Cannot decode HEIC: {filepath}") def process_video_thumbnail(filepath: str) -> Image.Image: diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index 5edc837..8291587 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -72,9 +72,14 @@ export function AuthProvider({ children }: { children: ReactNode }) { try { const res = await api.get('/auth/me') setUser(res.data) - } catch { - clearTokens() - setUser(null) + } catch (err: any) { + // Only clear tokens on auth failure (401/403), not network errors. + if (err?.response?.status === 401 || err?.response?.status === 403) { + clearTokens() + setUser(null) + } + // Network errors: leave tokens in place, user stays on login screen + // but can retry without re-entering credentials. } }, []) @@ -136,7 +141,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { if ( error.response?.status === 401 && !original._retry && - !original.url?.includes('/auth/') + !original.url?.includes('/auth/refresh') && + !original.url?.includes('/auth/login') ) { original._retry = true const rt = getStoredRefreshToken()