* fix(artifacts): serve inline binary artifacts via FileResponse for Range support
Audio/video/image artifacts that are previewed inline (not downloaded, not
active content) were served by reading the whole file into memory and
returning it through a plain Response. That response never sets
Accept-Ranges and ignores any Range header the browser sends, so seeking
an <audio>/<video> element backed by this endpoint always gets the full
200 response back instead of a 206 partial response for the requested byte
range -- which is why dragging the seek bar on an audio artifact restarts
playback from the beginning instead of jumping to the new position.
Route this case through FileResponse instead (as the active-content/
download branch already does), which handles Range/If-Range natively.
Verified with a real Range request against the endpoint: initial load now
reports Accept-Ranges: bytes, and a ranged GET returns 206 with the correct
Content-Range and only the requested slice of bytes.
Fixes#3240
* fix(artifacts): address review nits on the inline FileResponse branch
Two non-blocking nits from review:
- Drop the redundant filename= kwarg on the inline_file FileResponse
call. Content-Disposition is already set explicitly on this branch,
and FileResponse only uses filename to setdefault that same header
-- which is a no-op once it's already present. Harmless today, but
removes the latent risk that a future Starlette version turning that
setdefault into a hard set would silently flip inline preview to
attachment.
- Reword the blocking-IO regression-anchor docstring: it claimed
awaiting get_artifact for a binary artifact does "zero filesystem
IO", but _read_artifact_payload still runs exists/is_file/
mimetypes.guess_type/is_text_file_by_content (an 8 KB read) for
binary files too, just offloaded via asyncio.to_thread like the text
branch. The gate has nothing to catch because that IO is off the
event loop, not because there's none -- reworded to say no full-file
read happens, matching the accurate framing already used one
paragraph up.
tests/test_artifacts_router.py, tests/blocking_io/test_artifacts_router.py
(19 tests) are green, and ruff check/format are clean on both changed
files.
get_artifact ran its filesystem work directly on the event loop: virtual-path
resolution (os.path.abspath via .resolve()), exists/is_file probes, MIME sniffing
(mimetypes lazily stats the system MIME DB on first use), full-file
read_text/read_bytes, is_text_file_by_content (open+read), and .skill ZIP
open+extract. So serving any artifact blocked the loop for the whole read;
`make detect-blocking-io` flagged it. Same class as #3457 / #3529.
Offload each branch's IO via asyncio.to_thread: one sync helper per branch
(_load_skill_archive_member, _read_artifact_payload) folds stat + MIME + read /
extract into a single worker hop and returns a small (kind, mime, payload) plan
the handler turns into the response on the loop. FileResponse (download / active
content) keeps streaming the file itself. Behavior, branching, error codes, and
security boundaries are unchanged.
Add tests/blocking_io/test_artifacts_router.py anchor (text / binary / .skill
member), verified red->green under the strict Blockbuster gate. The gate also
caught a blocking call the static scan missed: resolve_thread_virtual_path's
.resolve() (os.path.abspath), now offloaded too.
Co-authored-by: ly-wang19 <ly-wang19@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>