Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,21 @@ All notable changes to Voicebox will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Fixed
- **Generation Error Handling** - Improved error handling for file system and connection errors ([#168](https://github.com/jamiepine/voicebox/issues/168), [#140](https://github.com/jamiepine/voicebox/issues/140))
- Implemented atomic file writes to prevent corrupted audio files
- Added specific error messages for different errno codes (ENOENT, EACCES, ENOSPC, EPIPE)
- Handle broken pipe errors gracefully when clients disconnect
- Verify directory writability before saving files

### Added
- **Filesystem Health Check** - New `/health/filesystem` endpoint to diagnose file system issues
- Reports directory status and write permissions
- Shows available disk space
- Helps troubleshoot generation failures
Comment on lines +8 to +21
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Merge the duplicate ## [Unreleased] sections.

There are now two ## [Unreleased] sections (lines 8–21 and line 69). Keep a Changelog allows only one; automated tools (e.g. release-please, standard-version) will parse only the first one and ignore the second, so the audio-export fix and Makefile entries at line 69 would be silently dropped at release time. Merge both sets of entries into a single ## [Unreleased] block.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CHANGELOG.md` around lines 8 - 21, There are duplicate "## [Unreleased]"
sections; merge the second section's entries (e.g., the audio-export fix and
Makefile entries) into the first "## [Unreleased]" block so all changes appear
under a single header, remove the extra "## [Unreleased]" header, and ensure the
combined subsections (Fixed, Added, etc.) are properly merged and deduplicated
while preserving Markdown list formatting and link references.


## [0.1.0] - 2026-01-25

### Added
Expand Down
132 changes: 125 additions & 7 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,75 @@ async def health():
)


@app.get("/health/filesystem")
async def check_filesystem():
"""
Check if file system is accessible and writable.

Returns health status of data directories and write permissions.
Useful for diagnosing issues #168 and #140.
"""
try:
# Check data directory
data_dir = config.get_data_dir()
if not data_dir.exists():
return {
"status": "error",
"message": "Data directory does not exist",
"data_dir": str(data_dir.absolute()),
}

# Check generations directory
generations_dir = config.get_generations_dir()
if not generations_dir.exists():
return {
"status": "error",
"message": "Generations directory does not exist",
"generations_dir": str(generations_dir.absolute()),
}

# Check write permissions
if not os.access(generations_dir, os.W_OK):
return {
"status": "error",
"message": "No write permission for generations directory",
"generations_dir": str(generations_dir.absolute()),
"writable": False,
}

# Try writing a test file
test_file = generations_dir / ".write_test"
try:
test_file.write_text("test")
test_file.unlink()
except Exception as e:
return {
"status": "error",
"message": f"Cannot write to generations directory: {str(e)}",
"generations_dir": str(generations_dir.absolute()),
"writable": False,
}

# Check disk space
import shutil
disk_usage = shutil.disk_usage(generations_dir)
free_space_gb = disk_usage.free / (1024 ** 3)

return {
"status": "ok",
"data_dir": str(data_dir.absolute()),
"generations_dir": str(generations_dir.absolute()),
"writable": True,
"free_space_gb": round(free_space_gb, 2),
"total_space_gb": round(disk_usage.total / (1024 ** 3), 2),
}
except Exception as e:
return {
"status": "error",
"message": f"Unexpected error: {str(e)}",
}


# ============================================
# VOICE PROFILE ENDPOINTS
# ============================================
Expand Down Expand Up @@ -655,11 +724,43 @@ async def download_model_background():
# Calculate duration
duration = len(audio) / sample_rate

# Save audio
audio_path = config.get_generations_dir() / f"{generation_id}.wav"
# Save audio with better error handling
try:
generations_dir = config.get_generations_dir()

# Verify directory exists and is writable
if not generations_dir.exists():
raise OSError(
f"Generations directory does not exist: {generations_dir}"
)

if not os.access(generations_dir, os.W_OK):
raise OSError(
f"No write permission for generations directory: {generations_dir}"
)

audio_path = generations_dir / f"{generation_id}.wav"

from .utils.audio import save_audio
save_audio(audio, str(audio_path), sample_rate)

from .utils.audio import save_audio
save_audio(audio, str(audio_path), sample_rate)
except OSError as e:
# Specific error handling for file system errors
task_manager.complete_generation(generation_id)

error_msg = f"Failed to save audio file: {str(e)}"
errno_num = getattr(e, 'errno', None)

if errno_num == 2: # ENOENT - No such file or directory
error_msg = f"Directory error: {str(e)}. Generations directory: {generations_dir}"
elif errno_num == 13: # EACCES - Permission denied
error_msg = f"Permission denied when saving audio. Check write permissions for: {generations_dir}"
elif errno_num == 28: # ENOSPC - No space left on device
error_msg = "No space left on device. Please free up disk space."
elif errno_num == 32: # EPIPE - Broken pipe
error_msg = "Connection interrupted during file save. Please retry."

raise HTTPException(status_code=500, detail=error_msg)

# Create history entry
generation = await history.create_generation(
Expand All @@ -672,15 +773,32 @@ async def download_model_background():
db=db,
instruct=data.instruct,
)

# Mark generation as complete
task_manager.complete_generation(generation_id)

return generation


except BrokenPipeError as e:
# Client disconnected during generation
task_manager.complete_generation(generation_id)
print(f"[WARNING] Client disconnected during generation {generation_id}: {e}")
raise HTTPException(
status_code=499, # Client Closed Request (non-standard but widely used)
detail="Client disconnected during generation. Please retry."
)
Comment on lines +782 to +789
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

HTTP 499 is a non-standard, nginx-specific status code.

Some reverse proxies, load balancers, and HTTP clients may not recognise 499 and could map it to a generic 5xx error, obscuring the intended "client closed request" semantics. Consider using 503 Service Unavailable with a Retry-After header, or 408 Request Timeout, both of which are standardised and widely understood.

🧰 Tools
🪛 Ruff (0.15.2)

[warning] 786-789: Within an except clause, raise exceptions with raise ... from err or raise ... from None to distinguish them from errors in exception handling

(B904)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/main.py` around lines 782 - 789, The except BrokenPipeError handler
currently raises HTTPException with non-standard status 499; change it to use a
standard status (e.g., 503 Service Unavailable or 408 Request Timeout) and
include appropriate headers (for 503 add a Retry-After header) while still
calling task_manager.complete_generation(generation_id) and logging the error;
update the raise HTTPException call to use the chosen standard status_code and
include headers={"Retry-After": "<seconds>"} when using 503 and keep the same
detail message so clients and intermediaries correctly interpret the
client-closed condition.

except ValueError as e:
task_manager.complete_generation(generation_id)
raise HTTPException(status_code=400, detail=str(e))
except OSError as e:
# OSError already handled above in save_audio block, but catch any others
task_manager.complete_generation(generation_id)
if getattr(e, 'errno', None) == 32: # EPIPE
raise HTTPException(
status_code=500,
detail="Broken pipe error - connection was interrupted. Please retry. If issue persists, restart the server."
)
raise HTTPException(status_code=500, detail=f"System error: {str(e)}")
except Exception as e:
task_manager.complete_generation(generation_id)
raise HTTPException(status_code=500, detail=str(e))
Expand Down
Loading