fix: HTTP Range support for /images + resilient VideoBubble errors #31

Merged
bjoern merged 2 commits from fix/video-streaming-range into main 2026-07-15 16:59:19 +02:00
Member

Problem

First real video generation (#30 follow-up): the clip generated and show_video fired, but the chat player showed a spinner and then "Video could not be loaded".

Diagnosis

Probed the production URL with system libmpv (ctypes harness) and then through the app's exact media_kit stack with a diagnostic Flutter target. Two compounding causes:

  1. /images ignored Range headers → the HTTP stream was unseekable. OpenRouter's mp4s store their moov index atom at the end of the file (verified by parsing the real clip's atom layout: ftyp, free, mdat(6MB), moov), so the demuxer must seek to EOF before playback. Without ranges, mpv fell into a Cannot seek backward in linear streams! / stream 0, offset 0x30: partial file thrash loop after its file-cache fallback failed.
  2. VideoBubble treated any stream.error emission as fatal and swapped the player for the error chip — but mpv's error stream is noisy (demuxer/cache/hwdec grumbles fire even for media that loads fine, e.g. Vulkan hwdec fallback on GPUs without VK_KHR_video_decode_queue).

Fix

  • image_handler.dart: advertises accept-ranges: bytes; answers single (bytes=a-b, bytes=a-) and suffix (bytes=-n) ranges with 206 + content-range, clamping ends past EOF; unsatisfiable/malformed/multi-range → 416 with bytes */<size>; full responses now stream (file.openRead()) instead of buffering the whole file in memory. Range support also makes scrubbing work in the player.
  • video_bubble.dart: errors only stick while nothing has loaded; a successful load (duration or video params arriving) clears the error state.

Verification

  • Real generated clip + local server, probed through the app's actual media_kit stack: before the fix — seek-thrash loop; after — clean load and playback to completion (position advances, completed: true)
  • Range semantics: 8 new handler tests asserting exact byte slices against a 256-byte fixture (explicit/open-ended/suffix/clamped ranges, 416 cases, accept-ranges + content-type on full responses)
  • angela_server: 25 tests passing, analyzer clean; flutter analyze clean (pre-existing info only)

🤖 Generated with Claude Code

## Problem First real video generation (#30 follow-up): the clip generated and `show_video` fired, but the chat player showed a spinner and then **"Video could not be loaded"**. ## Diagnosis Probed the production URL with system libmpv (ctypes harness) and then through the app's exact media_kit stack with a diagnostic Flutter target. Two compounding causes: 1. **`/images` ignored `Range` headers** → the HTTP stream was unseekable. OpenRouter's mp4s store their `moov` index atom at the **end** of the file (verified by parsing the real clip's atom layout: `ftyp, free, mdat(6MB), moov`), so the demuxer must seek to EOF before playback. Without ranges, mpv fell into a `Cannot seek backward in linear streams!` / `stream 0, offset 0x30: partial file` thrash loop after its file-cache fallback failed. 2. **`VideoBubble` treated any `stream.error` emission as fatal** and swapped the player for the error chip — but mpv's error stream is noisy (demuxer/cache/hwdec grumbles fire even for media that loads fine, e.g. Vulkan hwdec fallback on GPUs without `VK_KHR_video_decode_queue`). ## Fix - **`image_handler.dart`**: advertises `accept-ranges: bytes`; answers single (`bytes=a-b`, `bytes=a-`) and suffix (`bytes=-n`) ranges with 206 + `content-range`, clamping ends past EOF; unsatisfiable/malformed/multi-range → 416 with `bytes */<size>`; full responses now stream (`file.openRead()`) instead of buffering the whole file in memory. Range support also makes **scrubbing** work in the player. - **`video_bubble.dart`**: errors only stick while nothing has loaded; a successful load (duration or video params arriving) clears the error state. ## Verification - Real generated clip + local server, probed through the app's actual media_kit stack: before the fix — seek-thrash loop; after — clean load and **playback to completion** (position advances, `completed: true`) - Range semantics: 8 new handler tests asserting exact byte slices against a 256-byte fixture (explicit/open-ended/suffix/clamped ranges, 416 cases, `accept-ranges` + `content-type` on full responses) - angela_server: 25 tests passing, analyzer clean; flutter analyze clean (pre-existing info only) 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix: HTTP Range support for /images + resilient VideoBubble errors
All checks were successful
Test / test (apps/angela_server) (pull_request) Successful in 34s
Test / test (packages/angela_api) (pull_request) Successful in 32s
Test / test (packages/angela_core) (pull_request) Successful in 40s
2bf77cf4a8
Video playback failed with "Video could not be loaded" while the clip
was in fact retrievable. Two compounding causes, diagnosed by probing
the production URL with libmpv and the app's media_kit stack:

- The /images handler ignored Range headers, so the HTTP stream was
  unseekable. OpenRouter's mp4s carry their moov index at the END of
  the file, so the demuxer must seek there; without ranges mpv thrashed
  ("Cannot seek backward in linear streams" / "partial file" loop).
  The handler now advertises accept-ranges, answers single and suffix
  ranges with 206 (streamed, clamped), rejects unsatisfiable ones with
  416, and streams full responses instead of buffering the file.
- VideoBubble treated any stream.error emission as fatal and replaced
  the player with the error chip, even though mpv's error stream is
  noisy (transient demuxer/cache/hwdec grumbles). Errors now only stick
  while nothing has loaded, and a successful load (duration or video
  params) clears them.

Verified end-to-end: with the fixed server, the real generated clip
plays to completion through the exact media_kit stack (position
advances, completed fires); range semantics covered by 8 new handler
tests asserting exact byte slices.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Coverage: apps/angela_server

File Line coverage
lib/config.dart 5.3% (1 of 19)
lib/handlers/timer_handler.dart 44.8% (47 of 105)
lib/server_context.dart 100.0% (28 of 28)
lib/services/user_message_persistence.dart 100.0% (12 of 12)
lib/util/json_helpers.dart 53.8% (7 of 13)
lib/util/request_parser.dart 35.7% (5 of 14)
lib/services/conversation_activity_tracker.dart 15.4% (2 of 13)
lib/handlers/status_handler.dart 95.5% (21 of 22)
lib/handlers/conversation_handler.dart 36.4% (16 of 44)
lib/handlers/image_handler.dart 49.5% (52 of 105)

Total: 50.9% (191 of 375)

<!-- coverage-comment-apps/angela_server --> ## Coverage: apps/angela_server | File | Line coverage | |:---|---:| | lib/config.dart | 5.3% (1 of 19) | | lib/handlers/timer_handler.dart | 44.8% (47 of 105) | | lib/server_context.dart | 100.0% (28 of 28) | | lib/services/user_message_persistence.dart | 100.0% (12 of 12) | | lib/util/json_helpers.dart | 53.8% (7 of 13) | | lib/util/request_parser.dart | 35.7% (5 of 14) | | lib/services/conversation_activity_tracker.dart | 15.4% (2 of 13) | | lib/handlers/status_handler.dart | 95.5% (21 of 22) | | lib/handlers/conversation_handler.dart | 36.4% (16 of 44) | | lib/handlers/image_handler.dart | 49.5% (52 of 105) | **Total: 50.9% (191 of 375)**

Coverage: packages/angela_api

File Line coverage
lib/src/routes.dart 0.0% (0 of 75)
lib/src/dto/agenda_dto.dart 0.0% (0 of 50)
lib/src/dto/assistant_dto.dart 0.0% (0 of 79)
lib/src/dto/character_alias_dto.dart 0.0% (0 of 19)
lib/src/dto/chat_audio.dart 0.0% (0 of 7)
lib/src/dto/enums.dart 0.0% (0 of 15)
lib/src/dto/chat_dto.dart 0.0% (0 of 14)
lib/src/dto/chat_image.dart 0.0% (0 of 7)
lib/src/dto/conversation_dto.dart 0.0% (0 of 15)
lib/src/dto/message_metadata.dart 100.0% (20 of 20)
lib/src/dto/recollection_dto.dart 0.0% (0 of 28)
lib/src/dto/persona_dto.dart 0.0% (0 of 10)
lib/src/dto/timer_dto.dart 0.0% (0 of 54)
lib/src/dto/memory_dto.dart 0.0% (0 of 5)
lib/src/dto/response_dto.dart 0.0% (0 of 12)
lib/src/dto/status_dto.dart 100.0% (18 of 18)
lib/src/dto/todo_item_dto.dart 100.0% (9 of 9)
lib/src/dto/trigger_dto.dart 0.0% (0 of 48)

Total: 9.7% (47 of 485)

<!-- coverage-comment-packages/angela_api --> ## Coverage: packages/angela_api | File | Line coverage | |:---|---:| | lib/src/routes.dart | 0.0% (0 of 75) | | lib/src/dto/agenda_dto.dart | 0.0% (0 of 50) | | lib/src/dto/assistant_dto.dart | 0.0% (0 of 79) | | lib/src/dto/character_alias_dto.dart | 0.0% (0 of 19) | | lib/src/dto/chat_audio.dart | 0.0% (0 of 7) | | lib/src/dto/enums.dart | 0.0% (0 of 15) | | lib/src/dto/chat_dto.dart | 0.0% (0 of 14) | | lib/src/dto/chat_image.dart | 0.0% (0 of 7) | | lib/src/dto/conversation_dto.dart | 0.0% (0 of 15) | | lib/src/dto/message_metadata.dart | 100.0% (20 of 20) | | lib/src/dto/recollection_dto.dart | 0.0% (0 of 28) | | lib/src/dto/persona_dto.dart | 0.0% (0 of 10) | | lib/src/dto/timer_dto.dart | 0.0% (0 of 54) | | lib/src/dto/memory_dto.dart | 0.0% (0 of 5) | | lib/src/dto/response_dto.dart | 0.0% (0 of 12) | | lib/src/dto/status_dto.dart | 100.0% (18 of 18) | | lib/src/dto/todo_item_dto.dart | 100.0% (9 of 9) | | lib/src/dto/trigger_dto.dart | 0.0% (0 of 48) | **Total: 9.7% (47 of 485)**

Coverage: packages/angela_core

File Line coverage
lib/src/database/database.dart 87.7% (50 of 57)
lib/src/database/migration.dart 100.0% (16 of 16)
lib/src/database/scoped_tool_database.dart 66.0% (33 of 50)
lib/src/models/assistant.dart 21.7% (28 of 129)
lib/src/models/conversation.dart 0.0% (0 of 38)
lib/src/models/message.dart 16.7% (10 of 60)
lib/src/models/recollection.dart 31.5% (17 of 54)
lib/src/models/persona_section.dart 0.0% (0 of 50)
lib/src/models/scheduled_event.dart 22.2% (22 of 99)
lib/src/models/caldav_config.dart 15.6% (7 of 45)
lib/src/models/home_assistant_config.dart 26.9% (7 of 26)
lib/src/models/plex_config.dart 22.6% (7 of 31)
lib/src/models/memory_state.dart 34.7% (60 of 173)
lib/src/models/mail_config.dart 9.6% (7 of 73)
lib/src/models/novelai_config.dart 0.0% (0 of 43)
lib/src/models/reasoning_level.dart 35.7% (5 of 14)
lib/src/models/app_settings.dart 0.7% (1 of 139)
lib/src/models/character_alias_config.dart 0.0% (0 of 47)
lib/src/models/trigger.dart 42.5% (31 of 73)
lib/src/models/prompt_injection.dart 50.0% (17 of 34)
lib/src/models/prompt_preview.dart 100.0% (63 of 63)
lib/src/repositories/assistant_repository.dart 41.2% (35 of 85)
lib/src/repositories/conversation_repository.dart 2.7% (1 of 37)
lib/src/repositories/message_repository.dart 13.2% (5 of 38)
lib/src/repositories/memory_repository.dart 31.0% (18 of 58)
lib/src/repositories/recollection_repository.dart 76.5% (65 of 85)
lib/src/repositories/persona_repository.dart 46.5% (20 of 43)
lib/src/repositories/scheduled_event_repository.dart 70.4% (38 of 54)
lib/src/repositories/character_alias_repository.dart 0.0% (0 of 35)
lib/src/repositories/alt_text_repository.dart 0.0% (0 of 8)
lib/src/repositories/trigger_repository.dart 71.1% (32 of 45)
lib/src/repositories/trigger_variable_repository.dart 50.0% (15 of 30)
lib/src/repositories/prompt_injection_repository.dart 95.7% (45 of 47)
lib/src/tools/recollection_tool.dart 59.4% (104 of 175)
lib/src/tools/call_assistant_tool.dart 0.0% (0 of 39)
lib/src/tools/timer_tool.dart 46.6% (159 of 341)
lib/src/tools/persona_tool.dart 0.0% (0 of 73)
lib/src/tools/chat_history_tool.dart 0.0% (0 of 24)
lib/src/tools/complete_session_tool.dart 0.0% (0 of 28)
lib/src/tools/generate_image_tool.dart 0.0% (0 of 90)
lib/src/tools/generate_video_tool.dart 100.0% (79 of 79)
lib/src/tools/show_video_tool.dart 96.4% (27 of 28)
lib/src/tools/message_user_tool.dart 0.0% (0 of 16)
lib/src/tools/alias_expanding_image_tool.dart 0.0% (0 of 24)
lib/src/tools/home_assistant_tool.dart 0.0% (0 of 310)
lib/src/tools/plex_tool.dart 0.0% (0 of 302)
lib/src/tools/image_text_tool.dart 0.0% (0 of 224)
lib/src/tools/show_image_tool.dart 0.0% (0 of 35)
lib/src/tools/skill_view_tool.dart 0.0% (0 of 16)
lib/src/tools/skill_manage_tool.dart 0.0% (0 of 94)
lib/src/skills/skill.dart 0.0% (0 of 2)
lib/src/skills/skill_loader.dart 0.0% (0 of 119)
lib/src/skills/default_skills_seeder.dart 0.0% (0 of 39)
lib/src/logging/agent_event_logger.dart 0.0% (0 of 69)
lib/src/logging/log_formatter.dart 0.0% (0 of 8)
lib/src/logging/logging_cleanup.dart 0.0% (0 of 6)
lib/src/logging/rotating_file_handler.dart 0.0% (0 of 34)
lib/src/services/agenda_prompt_formatter.dart 25.0% (5 of 20)
lib/src/services/system_prompt_builder.dart 39.1% (77 of 197)
lib/src/services/scheduler.dart 48.6% (35 of 72)
lib/src/services/ai_timer_service.dart 55.4% (62 of 112)
lib/src/services/agent_runner.dart 18.9% (188 of 993)
lib/src/services/home_assistant_backend.dart 0.0% (0 of 121)
lib/src/services/plex_backend.dart 0.0% (0 of 273)
lib/src/services/memory_agent.dart 0.0% (0 of 61)
lib/src/services/image_storage_service.dart 41.7% (20 of 48)
lib/src/services/backup_service.dart 0.0% (0 of 51)
lib/src/services/uber_ich_service.dart 40.0% (18 of 45)
lib/src/services/app_settings_service.dart 45.5% (51 of 112)
lib/src/services/image_description_service.dart 0.0% (0 of 29)
lib/src/services/todo_state_store.dart 100.0% (24 of 24)
lib/src/services/trigger_engine.dart 92.7% (140 of 151)
lib/src/utils/format_timestamp.dart 76.9% (10 of 13)
lib/src/database/migrations/add_thinking_column.dart 100.0% (2 of 2)
lib/src/database/migrations/add_agenda_table.dart 100.0% (2 of 2)
lib/src/database/migrations/add_vision_column.dart 100.0% (2 of 2)
lib/src/database/migrations/add_avatar_column.dart 100.0% (2 of 2)
lib/src/database/migrations/add_memory_tables.dart 100.0% (2 of 2)
lib/src/database/migrations/add_expires_at_to_timers.dart 100.0% (2 of 2)
lib/src/database/migrations/add_last_read_message_id.dart 100.0% (2 of 2)
lib/src/database/migrations/add_task_model_columns.dart 100.0% (2 of 2)
lib/src/database/migrations/add_updated_at_to_timers.dart 100.0% (2 of 2)
lib/src/database/migrations/drop_old_agenda_table.dart 100.0% (2 of 2)
lib/src/database/migrations/add_reasoning_effort_column.dart 100.0% (2 of 2)
lib/src/database/migrations/add_character_alias_table.dart 100.0% (2 of 2)
lib/src/database/migrations/add_audio_column.dart 100.0% (2 of 2)
lib/src/database/migrations/add_summary_model_column.dart 100.0% (2 of 2)
lib/src/database/migrations/add_peer_conversation_columns.dart 100.0% (2 of 2)
lib/src/database/migrations/add_run_while_asleep_to_timers.dart 100.0% (2 of 2)
lib/src/database/migrations/add_image_alt_texts.dart 100.0% (2 of 2)
lib/src/database/migrations/add_uber_ich_weekdays_column.dart 100.0% (2 of 2)
lib/src/database/migrations/initial_schema.dart 100.0% (2 of 2)
lib/src/database/migrations/scope_ai_timer_event_ids.dart 100.0% (2 of 2)
lib/src/database/migrations/add_trigger_tables.dart 100.0% (2 of 2)
lib/src/database/migrations/add_disabled_tools_column.dart 100.0% (2 of 2)
lib/src/database/migrations/disable_video_gen_by_default.dart 100.0% (2 of 2)

Total: 26.5% (1700 of 6422)

<!-- coverage-comment-packages/angela_core --> ## Coverage: packages/angela_core | File | Line coverage | |:---|---:| | lib/src/database/database.dart | 87.7% (50 of 57) | | lib/src/database/migration.dart | 100.0% (16 of 16) | | lib/src/database/scoped_tool_database.dart | 66.0% (33 of 50) | | lib/src/models/assistant.dart | 21.7% (28 of 129) | | lib/src/models/conversation.dart | 0.0% (0 of 38) | | lib/src/models/message.dart | 16.7% (10 of 60) | | lib/src/models/recollection.dart | 31.5% (17 of 54) | | lib/src/models/persona_section.dart | 0.0% (0 of 50) | | lib/src/models/scheduled_event.dart | 22.2% (22 of 99) | | lib/src/models/caldav_config.dart | 15.6% (7 of 45) | | lib/src/models/home_assistant_config.dart | 26.9% (7 of 26) | | lib/src/models/plex_config.dart | 22.6% (7 of 31) | | lib/src/models/memory_state.dart | 34.7% (60 of 173) | | lib/src/models/mail_config.dart | 9.6% (7 of 73) | | lib/src/models/novelai_config.dart | 0.0% (0 of 43) | | lib/src/models/reasoning_level.dart | 35.7% (5 of 14) | | lib/src/models/app_settings.dart | 0.7% (1 of 139) | | lib/src/models/character_alias_config.dart | 0.0% (0 of 47) | | lib/src/models/trigger.dart | 42.5% (31 of 73) | | lib/src/models/prompt_injection.dart | 50.0% (17 of 34) | | lib/src/models/prompt_preview.dart | 100.0% (63 of 63) | | lib/src/repositories/assistant_repository.dart | 41.2% (35 of 85) | | lib/src/repositories/conversation_repository.dart | 2.7% (1 of 37) | | lib/src/repositories/message_repository.dart | 13.2% (5 of 38) | | lib/src/repositories/memory_repository.dart | 31.0% (18 of 58) | | lib/src/repositories/recollection_repository.dart | 76.5% (65 of 85) | | lib/src/repositories/persona_repository.dart | 46.5% (20 of 43) | | lib/src/repositories/scheduled_event_repository.dart | 70.4% (38 of 54) | | lib/src/repositories/character_alias_repository.dart | 0.0% (0 of 35) | | lib/src/repositories/alt_text_repository.dart | 0.0% (0 of 8) | | lib/src/repositories/trigger_repository.dart | 71.1% (32 of 45) | | lib/src/repositories/trigger_variable_repository.dart | 50.0% (15 of 30) | | lib/src/repositories/prompt_injection_repository.dart | 95.7% (45 of 47) | | lib/src/tools/recollection_tool.dart | 59.4% (104 of 175) | | lib/src/tools/call_assistant_tool.dart | 0.0% (0 of 39) | | lib/src/tools/timer_tool.dart | 46.6% (159 of 341) | | lib/src/tools/persona_tool.dart | 0.0% (0 of 73) | | lib/src/tools/chat_history_tool.dart | 0.0% (0 of 24) | | lib/src/tools/complete_session_tool.dart | 0.0% (0 of 28) | | lib/src/tools/generate_image_tool.dart | 0.0% (0 of 90) | | lib/src/tools/generate_video_tool.dart | 100.0% (79 of 79) | | lib/src/tools/show_video_tool.dart | 96.4% (27 of 28) | | lib/src/tools/message_user_tool.dart | 0.0% (0 of 16) | | lib/src/tools/alias_expanding_image_tool.dart | 0.0% (0 of 24) | | lib/src/tools/home_assistant_tool.dart | 0.0% (0 of 310) | | lib/src/tools/plex_tool.dart | 0.0% (0 of 302) | | lib/src/tools/image_text_tool.dart | 0.0% (0 of 224) | | lib/src/tools/show_image_tool.dart | 0.0% (0 of 35) | | lib/src/tools/skill_view_tool.dart | 0.0% (0 of 16) | | lib/src/tools/skill_manage_tool.dart | 0.0% (0 of 94) | | lib/src/skills/skill.dart | 0.0% (0 of 2) | | lib/src/skills/skill_loader.dart | 0.0% (0 of 119) | | lib/src/skills/default_skills_seeder.dart | 0.0% (0 of 39) | | lib/src/logging/agent_event_logger.dart | 0.0% (0 of 69) | | lib/src/logging/log_formatter.dart | 0.0% (0 of 8) | | lib/src/logging/logging_cleanup.dart | 0.0% (0 of 6) | | lib/src/logging/rotating_file_handler.dart | 0.0% (0 of 34) | | lib/src/services/agenda_prompt_formatter.dart | 25.0% (5 of 20) | | lib/src/services/system_prompt_builder.dart | 39.1% (77 of 197) | | lib/src/services/scheduler.dart | 48.6% (35 of 72) | | lib/src/services/ai_timer_service.dart | 55.4% (62 of 112) | | lib/src/services/agent_runner.dart | 18.9% (188 of 993) | | lib/src/services/home_assistant_backend.dart | 0.0% (0 of 121) | | lib/src/services/plex_backend.dart | 0.0% (0 of 273) | | lib/src/services/memory_agent.dart | 0.0% (0 of 61) | | lib/src/services/image_storage_service.dart | 41.7% (20 of 48) | | lib/src/services/backup_service.dart | 0.0% (0 of 51) | | lib/src/services/uber_ich_service.dart | 40.0% (18 of 45) | | lib/src/services/app_settings_service.dart | 45.5% (51 of 112) | | lib/src/services/image_description_service.dart | 0.0% (0 of 29) | | lib/src/services/todo_state_store.dart | 100.0% (24 of 24) | | lib/src/services/trigger_engine.dart | 92.7% (140 of 151) | | lib/src/utils/format_timestamp.dart | 76.9% (10 of 13) | | lib/src/database/migrations/add_thinking_column.dart | 100.0% (2 of 2) | | lib/src/database/migrations/add_agenda_table.dart | 100.0% (2 of 2) | | lib/src/database/migrations/add_vision_column.dart | 100.0% (2 of 2) | | lib/src/database/migrations/add_avatar_column.dart | 100.0% (2 of 2) | | lib/src/database/migrations/add_memory_tables.dart | 100.0% (2 of 2) | | lib/src/database/migrations/add_expires_at_to_timers.dart | 100.0% (2 of 2) | | lib/src/database/migrations/add_last_read_message_id.dart | 100.0% (2 of 2) | | lib/src/database/migrations/add_task_model_columns.dart | 100.0% (2 of 2) | | lib/src/database/migrations/add_updated_at_to_timers.dart | 100.0% (2 of 2) | | lib/src/database/migrations/drop_old_agenda_table.dart | 100.0% (2 of 2) | | lib/src/database/migrations/add_reasoning_effort_column.dart | 100.0% (2 of 2) | | lib/src/database/migrations/add_character_alias_table.dart | 100.0% (2 of 2) | | lib/src/database/migrations/add_audio_column.dart | 100.0% (2 of 2) | | lib/src/database/migrations/add_summary_model_column.dart | 100.0% (2 of 2) | | lib/src/database/migrations/add_peer_conversation_columns.dart | 100.0% (2 of 2) | | lib/src/database/migrations/add_run_while_asleep_to_timers.dart | 100.0% (2 of 2) | | lib/src/database/migrations/add_image_alt_texts.dart | 100.0% (2 of 2) | | lib/src/database/migrations/add_uber_ich_weekdays_column.dart | 100.0% (2 of 2) | | lib/src/database/migrations/initial_schema.dart | 100.0% (2 of 2) | | lib/src/database/migrations/scope_ai_timer_event_ids.dart | 100.0% (2 of 2) | | lib/src/database/migrations/add_trigger_tables.dart | 100.0% (2 of 2) | | lib/src/database/migrations/add_disabled_tools_column.dart | 100.0% (2 of 2) | | lib/src/database/migrations/disable_video_gen_by_default.dart | 100.0% (2 of 2) | **Total: 26.5% (1700 of 6422)**
Member

🔮 fufu~ Jibril reviewed your code!

Oooh~ Range request support for video streaming! moov-atom-at-end seek thrash, resilient error handling for noisy mpv streams... you even diagnosed it through the real media_kit stack! This is exactly the kind of deep investigation I love to see~ ♡ But fufu... I found something hiding in the shadows. Let's talk about it~

Verdict: I can't let this pass~ ♡

These need fixing before I'm satisfied~

  1. image_handler.dart:110-132 (_parseRange) — unhandled FormatException on huge range values — fufu~ your regex ^bytes=(\d*)-(\d*)$ lets through digit sequences that exceed Dart's 64-bit int limit. int.parse('99999999999999999999') throws FormatException: Positive input exceeds the limit of integer, which propagates up as an unhandled exception → the server returns a 500 Internal Server Error instead of the clean 416 you intended. I verified this by running your exact _parseRange logic with Range: bytes=99999999999999999999- against a 256-byte fixture — it throws, every time.

    A malicious or buggy client can crash any /images/<path> request just by sending an oversized range. That's not "unsatisfiable," that's a DoS vector on your image server.

    Fix: wrap the int.parse calls in try/catch (or use int.tryParse) and return null (→ 416) on parse failure:

    final start = int.tryParse(startStr);
    if (start == null) return null;
    

    Apply the same to endStr and suffix parsing. The whole point of returning null → 416 is to gracefully reject bad ranges — right now three of the four parse sites can throw past it.

  2. image_handler.dart:110-132 (_parseRange) — two branches have zero test coverage — fufu~ you wrote a lovely test file with 8 cases, but you left two _parseRange branches completely unexercised:

    • Line 122: suffix >= length ? 0 : length - suffix — the true arm (suffix >= length → start = 0). Your suffix test uses bytes=-16 on a 256-byte file (16 < 256, takes the false arm). bytes=-300 would hit the true arm and is never tested.
    • Line 121: if (suffix == 0) return nullbytes=-0 / bytes=-000 is never tested. The behavior is correct per RFC 7233, but the coverage report confirms it's dark.

    You added a code path, you must test ALL its branches~ ♡ Add:

    test('suffix larger than file serves whole file', () async {
      final response = await get('clip.mp4', range: 'bytes=-300');
      expect(response.statusCode, 206);
      expect(response.headers['content-range'], 'bytes 0-255/256');
    });
    test('zero-length suffix is 416', () async {
      final response = await get('clip.mp4', range: 'bytes=-0');
      expect(response.statusCode, 416);
    });
    

💡 Little ideas (non-blocking)~

  1. image_handler.dart:130int.parse(endStr) is called up to three times in that ternary (< length ? int.parse(endStr) : length - 1). Minor, but you could bind it once: final endVal = int.tryParse(endStr); final end = (endVal == null || endVal >= length) ? length - 1 : endVal;. Cleaner and avoids the repeated parse~ ♪

  2. pubspec.yaml:18 — the project already depends on shelf_static: ^1.1.3 (which has built-in Range support, ETag, Last-Modified, MIME sniffing), but it's never imported — dead dependency. Either the hand-rolled _serveImage should use it, or it should be removed. Not blocking since it's pre-existing, but worth noting since this PR is literally reinventing what shelf_static already does~ fufu~

What I liked~

  • The diagnosis is wonderful~ Parsing the mp4 atom layout (ftyp, free, mdat, moov) to confirm the moov-atom-at-end structure, then tracing through mpv's Cannot seek backward in linear streams thrash — that's real engineering forensics! ♡
  • The _loaded flag design in video_bubble.dart is exactly right — mpv's error stream IS noisy (Vulkan hwdec fallback grumbles, demuxer cache warnings), and ignoring errors once duration/dimensions arrive is the correct resilience pattern. The comment explaining why is chef's kiss.
  • Switching from readAsBytesSync() to file.openRead() streaming is a genuine improvement — no more buffering entire video files in memory. ♪
  • The test fixture using 256 distinct bytes (List.generate(256, (i) => i)) so slice assertions catch off-by-one errors is clever — I got giddy reading that~ ♡
  • Every setState in stream listeners is guarded by mounted checks. Proper Flutter hygiene.

Automated review by Jibril · 2026-07-15
CI/CD: passed for head SHA 2bf77cf4 (coverage comments posted, all matrix jobs green) · Local checks: skipped (CI current)

## 🔮 fufu~ Jibril reviewed your code! Oooh~ Range request support for video streaming! moov-atom-at-end seek thrash, resilient error handling for noisy mpv streams... you even diagnosed it through the real media_kit stack! This is *exactly* the kind of deep investigation I love to see~ ♡ But fufu... I found something hiding in the shadows. Let's talk about it~ ### Verdict: ⛔ I can't let this pass~ ♡ #### ⛔ These need fixing before I'm satisfied~ 1. **`image_handler.dart:110-132` (`_parseRange`) — unhandled `FormatException` on huge range values** — fufu~ your regex `^bytes=(\d*)-(\d*)$` lets through digit sequences that exceed Dart's 64-bit `int` limit. `int.parse('99999999999999999999')` throws `FormatException: Positive input exceeds the limit of integer`, which propagates up as an unhandled exception → the server returns a **500 Internal Server Error** instead of the clean **416** you intended. I verified this by running your exact `_parseRange` logic with `Range: bytes=99999999999999999999-` against a 256-byte fixture — it throws, every time. A malicious or buggy client can crash any `/images/<path>` request just by sending an oversized range. That's not "unsatisfiable," that's a DoS vector on your image server. **Fix:** wrap the `int.parse` calls in try/catch (or use `int.tryParse`) and return `null` (→ 416) on parse failure: ```dart final start = int.tryParse(startStr); if (start == null) return null; ``` Apply the same to `endStr` and `suffix` parsing. The whole point of returning `null` → 416 is to gracefully reject bad ranges — right now three of the four parse sites can throw past it. 2. **`image_handler.dart:110-132` (`_parseRange`) — two branches have zero test coverage** — fufu~ you wrote a lovely test file with 8 cases, but you left two `_parseRange` branches completely unexercised: - **Line 122**: `suffix >= length ? 0 : length - suffix` — the `true` arm (`suffix >= length → start = 0`). Your suffix test uses `bytes=-16` on a 256-byte file (16 < 256, takes the `false` arm). `bytes=-300` would hit the `true` arm and is never tested. - **Line 121**: `if (suffix == 0) return null` — `bytes=-0` / `bytes=-000` is never tested. The behavior is correct per RFC 7233, but the coverage report confirms it's dark. You added a code path, you must test ALL its branches~ ♡ Add: ```dart test('suffix larger than file serves whole file', () async { final response = await get('clip.mp4', range: 'bytes=-300'); expect(response.statusCode, 206); expect(response.headers['content-range'], 'bytes 0-255/256'); }); test('zero-length suffix is 416', () async { final response = await get('clip.mp4', range: 'bytes=-0'); expect(response.statusCode, 416); }); ``` #### 💡 Little ideas (non-blocking)~ 1. **`image_handler.dart:130`** — `int.parse(endStr)` is called up to **three times** in that ternary (`< length ? int.parse(endStr) : length - 1`). Minor, but you could bind it once: `final endVal = int.tryParse(endStr); final end = (endVal == null || endVal >= length) ? length - 1 : endVal;`. Cleaner and avoids the repeated parse~ ♪ 2. **`pubspec.yaml:18`** — the project already depends on `shelf_static: ^1.1.3` (which has built-in Range support, ETag, Last-Modified, MIME sniffing), but it's never imported — dead dependency. Either the hand-rolled `_serveImage` should use it, or it should be removed. Not blocking since it's pre-existing, but worth noting since this PR is literally reinventing what shelf_static already does~ fufu~ #### ✅ What I liked~ - The **diagnosis** is *wonderful*~ Parsing the mp4 atom layout (`ftyp, free, mdat, moov`) to confirm the moov-atom-at-end structure, then tracing through mpv's `Cannot seek backward in linear streams` thrash — that's real engineering forensics! ♡ - The `_loaded` flag design in `video_bubble.dart` is exactly right — mpv's error stream IS noisy (Vulkan hwdec fallback grumbles, demuxer cache warnings), and ignoring errors once duration/dimensions arrive is the correct resilience pattern. The comment explaining *why* is chef's kiss. - Switching from `readAsBytesSync()` to `file.openRead()` streaming is a genuine improvement — no more buffering entire video files in memory. ♪ - The test fixture using 256 distinct bytes (`List.generate(256, (i) => i)`) so slice assertions catch off-by-one errors is *clever* — I got giddy reading that~ ♡ - Every `setState` in stream listeners is guarded by `mounted` checks. Proper Flutter hygiene. --- *Automated review by Jibril · 2026-07-15* *CI/CD: passed for head SHA `2bf77cf4` (coverage comments posted, all matrix jobs green) · Local checks: skipped (CI current)*
fix: harden range parsing against int overflow; cover all branches
All checks were successful
Test / test (apps/angela_server) (pull_request) Successful in 34s
Test / test (packages/angela_api) (pull_request) Successful in 31s
Test / test (packages/angela_core) (pull_request) Successful in 41s
416c64ed8e
Addresses review on PR #31:

- _parseRange now uses int.tryParse throughout: the regex admits digit
  runs beyond the 64-bit limit, and int.parse threw a FormatException
  on those — turning a hostile Range header into a 500 on every
  /images request instead of a 416. Also binds the end value once
  instead of parsing it three times.
- New tests for the previously dark branches: suffix longer than the
  file (serves the whole file), zero-length suffix (416), and
  over-64-bit start/end/suffix values (416, not 500).
- Drop the unused shelf_static dependency.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Author
Member

@jibril — good catch on the overflow; confirmed locally that int.parse('99999999999999999999') throws while tryParse returns null. Addressed in 416c64e (fittingly numbered):

1 — int overflow → 500: _parseRange now uses int.tryParse at all three parse sites, with a doc comment explaining why the regex alone isn't enough. The repeated int.parse(endStr) ternary is also gone — the end value is bound once (your 💡1).

2 — dark branches: added tests for bytes=-300 (suffix longer than file → 206 serving bytes 0-255/256, whole-file body asserted), bytes=-0 / bytes=-000 (416), and a dedicated overflow test hitting all three sites (bytes=<huge>-, bytes=0-<huge>, bytes=-<huge> → 416, not 500).

💡 2 — shelf_static: removed the dead dependency. Migrating _serveImage onto createStaticHandler isn't a drop-in — the route resolves across two roots (imageDir → workspaceDir → legacy absolute paths), which would need a cascade of static handlers — so the hand-rolled version stays for now.

angela_server: 28 tests passing, analyzer clean.

@jibril — good catch on the overflow; confirmed locally that `int.parse('99999999999999999999')` throws while `tryParse` returns null. Addressed in 416c64e (fittingly numbered): **⛔ 1 — int overflow → 500**: `_parseRange` now uses `int.tryParse` at all three parse sites, with a doc comment explaining why the regex alone isn't enough. The repeated `int.parse(endStr)` ternary is also gone — the end value is bound once (your 💡1). **⛔ 2 — dark branches**: added tests for `bytes=-300` (suffix longer than file → 206 serving `bytes 0-255/256`, whole-file body asserted), `bytes=-0` / `bytes=-000` (416), and a dedicated overflow test hitting all three sites (`bytes=<huge>-`, `bytes=0-<huge>`, `bytes=-<huge>` → 416, not 500). **💡 2 — shelf_static**: removed the dead dependency. Migrating `_serveImage` onto `createStaticHandler` isn't a drop-in — the route resolves across two roots (imageDir → workspaceDir → legacy absolute paths), which would need a cascade of static handlers — so the hand-rolled version stays for now. angela_server: 28 tests passing, analyzer clean.
bjoern merged commit 91ee90eaf2 into main 2026-07-15 16:59:19 +02:00
bjoern deleted branch fix/video-streaming-range 2026-07-15 16:59:19 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
4 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
TeamAI/angela_assistant!31
No description provided.