diff --git a/backend/main.py b/backend/main.py index 3d2ec35..d1fbe9d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -222,6 +222,75 @@ async def health(): ) +@app.get("/health/filesystem", response_model=models.FilesystemHealthResponse) +async def filesystem_health(): + """Check filesystem health: directory existence, write permissions, and disk space.""" + import shutil + + dirs_to_check = { + "generations": config.get_generations_dir(), + "profiles": config.get_profiles_dir(), + "data": config.get_data_dir(), + } + + checks: list[models.DirectoryCheck] = [] + all_ok = True + + for _label, dir_path in dirs_to_check.items(): + exists = dir_path.exists() + writable = False + error = None + if exists: + # Probe writability with a temp file + probe = dir_path / ".voicebox_probe" + try: + probe.write_text("ok") + probe.unlink() + writable = True + except PermissionError: + error = "Permission denied" + except OSError as e: + error = str(e) + finally: + try: + probe.unlink(missing_ok=True) + except Exception: + pass + else: + error = "Directory does not exist" + + if not exists or not writable: + all_ok = False + + checks.append( + models.DirectoryCheck( + path=str(dir_path), + exists=exists, + writable=writable, + error=error, + ) + ) + + # Disk space for the data directory + disk_free_mb = None + disk_total_mb = None + try: + usage = shutil.disk_usage(str(config.get_data_dir())) + disk_free_mb = round(usage.free / (1024 * 1024), 1) + disk_total_mb = round(usage.total / (1024 * 1024), 1) + if disk_free_mb < 500: + all_ok = False + except OSError: + all_ok = False + + return models.FilesystemHealthResponse( + healthy=all_ok, + disk_free_mb=disk_free_mb, + disk_total_mb=disk_total_mb, + directories=checks, + ) + + # ============================================ # VOICE PROFILE ENDPOINTS # ============================================ @@ -728,7 +797,30 @@ async def download_chatterbox_background(): audio_path = config.get_generations_dir() / f"{generation_id}.wav" from .utils.audio import save_audio - save_audio(audio, str(audio_path), sample_rate) + import errno + + try: + save_audio(audio, str(audio_path), sample_rate) + except BrokenPipeError: + raise HTTPException( + status_code=500, + detail="Audio save failed: broken pipe (the output stream was closed unexpectedly)", + ) + except OSError as save_err: + err_no = getattr(save_err, "errno", None) or ( + getattr(save_err.__cause__, "errno", None) + if save_err.__cause__ + else None + ) + if err_no == errno.ENOENT: + msg = f"Audio save failed: directory not found — {audio_path.parent}" + elif err_no == errno.EACCES: + msg = f"Audio save failed: permission denied — {audio_path.parent}" + elif err_no == errno.ENOSPC: + msg = "Audio save failed: no disk space remaining" + else: + msg = f"Audio save failed: {save_err}" + raise HTTPException(status_code=500, detail=msg) # Create history entry generation = await history.create_generation( diff --git a/backend/models.py b/backend/models.py index c46ded5..5a0925d 100644 --- a/backend/models.py +++ b/backend/models.py @@ -131,6 +131,22 @@ class HealthResponse(BaseModel): backend_variant: Optional[str] = None # Binary variant (cpu or cuda) +class DirectoryCheck(BaseModel): + """Health status for a single directory.""" + path: str + exists: bool + writable: bool + error: Optional[str] = None + + +class FilesystemHealthResponse(BaseModel): + """Response model for filesystem health check.""" + healthy: bool + disk_free_mb: Optional[float] = None + disk_total_mb: Optional[float] = None + directories: List[DirectoryCheck] + + class ModelStatus(BaseModel): """Response model for model status.""" model_name: str diff --git a/backend/utils/audio.py b/backend/utils/audio.py index 20709d9..ab60d0a 100644 --- a/backend/utils/audio.py +++ b/backend/utils/audio.py @@ -70,14 +70,43 @@ def save_audio( sample_rate: int = 24000, ) -> None: """ - Save audio file. - + Save audio file with atomic write and error handling. + + Writes to a temporary file first, then atomically renames to the + target path. This prevents corrupted/partial WAV files if the + process is interrupted mid-write. + Args: audio: Audio array path: Output path sample_rate: Sample rate + + Raises: + OSError: If file cannot be written """ - sf.write(path, audio, sample_rate) + from pathlib import Path + import os + + temp_path = f"{path}.tmp" + try: + # Ensure parent directory exists + Path(path).parent.mkdir(parents=True, exist_ok=True) + + # Write to temporary file first + sf.write(temp_path, audio, sample_rate) + + # Atomic rename to final path + os.replace(temp_path, path) + + except Exception as e: + # Clean up temp file on failure + try: + if Path(temp_path).exists(): + Path(temp_path).unlink() + except Exception: + pass # Best effort cleanup + + raise OSError(f"Failed to save audio to {path}: {e}") from e def trim_tts_output(