fix(artifacts): serve inline binary artifacts via FileResponse for Range support (#4281)

* 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.
This commit is contained in:
Daoyuan Li
2026-07-19 07:31:29 +08:00
committed by GitHub
parent a028dfd5fb
commit 75fa028e89
3 changed files with 104 additions and 14 deletions
+26 -7
View File
@@ -123,11 +123,14 @@ def _read_artifact_payload(actual_path: Path, path: str, download: bool) -> tupl
"""Worker-thread body for the regular branch of ``get_artifact``.
Stat probes, MIME sniffing (``mimetypes`` lazily stats the system MIME
database on first use), and full-file reads are all blocking filesystem IO.
Returns a ``(kind, mime_type, payload)`` plan the handler turns into a
response on the loop: ``("file", mime, None)`` (let ``FileResponse`` stream
it), ``("text", mime, str)``, or ``("bytes", mime, bytes)``. Behavior/error
codes match the previous inline logic.
database on first use), and text reads are blocking filesystem IO. Returns
a ``(kind, mime_type, payload)`` plan the handler turns into a response on
the loop: ``("file", mime, None)`` (attachment / forced-download active
content, streamed by ``FileResponse``), ``("inline_file", mime, None)``
(inline binary preview — also streamed by ``FileResponse`` so the client
can issue byte-``Range`` requests, e.g. to seek within audio/video
artifacts instead of always replaying from byte 0), or ``("text", mime,
str)``. Behavior/error codes match the previous inline logic.
"""
if not actual_path.exists():
raise HTTPException(status_code=404, detail=f"Artifact not found: {path}")
@@ -141,7 +144,10 @@ def _read_artifact_payload(actual_path: Path, path: str, download: bool) -> tupl
return ("text", mime_type, actual_path.read_text(encoding="utf-8"))
if is_text_file_by_content(actual_path):
return ("text", mime_type, actual_path.read_text(encoding="utf-8"))
return ("bytes", mime_type, actual_path.read_bytes())
# Binary inline preview (images, audio, video, PDFs, ...): stream via
# FileResponse instead of buffering the whole file in memory, so it also
# gets FileResponse's built-in byte-Range handling (see get_artifact).
return ("inline_file", mime_type, None)
@router.get(
@@ -235,7 +241,20 @@ async def get_artifact(thread_id: str, path: str, request: Request, download: bo
# execution in the application origin when users open generated artifacts.
return FileResponse(path=actual_path, filename=actual_path.name, media_type=mime_type, headers=_build_attachment_headers(actual_path.name))
if kind == "inline_file":
# FileResponse (unlike a fully-buffered Response) honors byte-Range
# requests. Browsers issue these when seeking an <audio>/<video>
# element backed by a remote URL; serving the same bytes through a
# plain Response ignores Range headers and always replays from byte
# 0, which is why dragging an audio/video artifact's progress bar
# reset playback to the start instead of jumping to the new position.
return FileResponse(
path=actual_path,
media_type=mime_type,
headers={"Content-Disposition": _build_content_disposition("inline", actual_path.name)},
)
if kind == "text":
return PlainTextResponse(content=payload, media_type=mime_type)
return Response(content=payload, media_type=mime_type, headers={"Content-Disposition": _build_content_disposition("inline", actual_path.name)})
raise AssertionError(f"Unhandled artifact response kind: {kind!r}")
@@ -1,11 +1,22 @@
"""Regression anchor: serving artifacts must not block the event loop.
``get_artifact`` probes the artifact path (``exists`` / ``is_file``), reads
text/binary content (``read_text`` / ``read_bytes``), sniffs text-ness
(``is_text_file_by_content``), and extracts ``.skill`` archive members — all
blocking filesystem IO. The handler offloads each branch's IO via
``asyncio.to_thread``; if any regresses back onto the event loop, the strict
Blockbuster gate raises ``BlockingError`` and these tests fail.
text content (``read_text``), sniffs text-ness (``is_text_file_by_content``),
and extracts ``.skill`` archive members — all blocking filesystem IO. The
handler offloads each branch's IO via ``asyncio.to_thread``; if any regresses
back onto the event loop, the strict Blockbuster gate raises ``BlockingError``
and these tests fail.
Binary (non-text, non-active-content) artifacts still run the same
``exists`` / ``is_file`` / ``mimetypes.guess_type`` / ``is_text_file_by_content``
probes as the text branch, offloaded the same way via ``asyncio.to_thread``.
What differs is the payload: instead of a read, the handler returns a
``FileResponse`` that defers its own file IO to ASGI response time (streamed
via ``anyio.open_file`` when the response is actually sent, which this test
never triggers) — so awaiting ``get_artifact`` itself for a binary artifact
does no full-file read; ``FileResponse`` streams the bytes at ASGI send
time, off the loop, which is why the gate has nothing to catch there
either way.
The ``@require_permission`` decorator is bypassed via ``__wrapped__`` so the
anchor exercises the handler's own filesystem IO, not the authz layer. Imports
@@ -19,6 +30,7 @@ import zipfile
from pathlib import Path
import pytest
from starlette.responses import FileResponse
from app.gateway.path_utils import resolve_thread_virtual_path
from app.gateway.routers.artifacts import get_artifact
@@ -56,13 +68,18 @@ async def test_get_artifact_text_does_not_block_event_loop(tmp_path: Path, monke
async def test_get_artifact_binary_does_not_block_event_loop(tmp_path: Path, monkeypatch) -> None:
vpath = "mnt/user-data/outputs/blob.bin"
target = await _seed(tmp_path, monkeypatch, "t1", vpath)
payload = b"\x00\x01\x02PNGDATA" # null byte -> binary branch (read_bytes)
payload = b"\x00\x01\x02PNGDATA" # null byte -> binary branch (inline FileResponse)
await asyncio.to_thread(target.write_bytes, payload)
resp = await _get_artifact("t1", vpath, request=None, download=False)
# Binary artifacts are streamed via FileResponse (so browsers can issue
# byte-Range requests) instead of being read into memory up front, so the
# file bytes are only touched later during ASGI send, never here.
assert isinstance(resp, FileResponse)
assert resp.status_code == 200
assert resp.body == payload
assert Path(resp.path) == target
assert resp.headers.get("content-disposition", "").startswith("inline;")
async def test_get_artifact_skill_archive_member_does_not_block_event_loop(tmp_path: Path, monkeypatch) -> None:
+54
View File
@@ -90,6 +90,60 @@ def test_get_artifact_download_false_does_not_force_attachment(tmp_path, monkeyp
assert "content-disposition" not in response.headers
def test_get_artifact_binary_preview_is_inline_file_response(tmp_path, monkeypatch) -> None:
# Binary (non-text, non-active-content) artifacts must go through the
# "inline_file" plan so get_artifact serves them via FileResponse.
artifact_path = tmp_path / "clip.mp3"
artifact_path.write_bytes(b"\x00\x01ID3fakeaudiobytes")
monkeypatch.setattr(artifacts_router, "resolve_thread_virtual_path", lambda _thread_id, _path, user_id=None: artifact_path)
response = asyncio.run(call_unwrapped(artifacts_router.get_artifact, "thread-1", "mnt/user-data/outputs/clip.mp3", _make_request()))
assert isinstance(response, FileResponse)
assert response.media_type == "audio/mpeg"
assert response.headers.get("content-disposition", "").startswith("inline;")
def test_get_artifact_binary_preview_supports_range_requests(tmp_path, monkeypatch) -> None:
# Regression test for #3240: dragging an audio/video artifact's seek bar
# reset playback to the start because the binary-preview branch used to
# buffer the whole file into a plain Response, which ignores byte-Range
# requests entirely (always 200 + full body, never 206). Browsers fall
# back to restarting playback from byte 0 when a seek's Range request
# doesn't come back as 206. FileResponse (used for the "file" plan
# already) handles Range/If-Range natively, so switching the binary
# branch to FileResponse fixes seeking for free.
# Cycle through all 256 byte values (incl. \x00) so is_text_file_by_content
# correctly sniffs this as binary, same as a real audio file would be -- an
# all-printable-ASCII payload would (correctly) be sniffed as text and miss
# the branch this test targets.
payload = bytes(i % 256 for i in range(1_000_000))
artifact_path = tmp_path / "clip.mp3"
artifact_path.write_bytes(payload)
monkeypatch.setattr(artifacts_router, "resolve_thread_virtual_path", lambda _thread_id, _path, user_id=None: artifact_path)
app = make_authed_test_app()
app.include_router(artifacts_router.router)
with TestClient(app) as client:
full = client.get("/api/threads/thread-1/artifacts/mnt/user-data/outputs/clip.mp3")
seek = client.get(
"/api/threads/thread-1/artifacts/mnt/user-data/outputs/clip.mp3",
headers={"Range": "bytes=500000-"},
)
assert full.status_code == 200
assert full.headers.get("accept-ranges") == "bytes"
assert full.content == payload
assert seek.status_code == 206
assert seek.headers.get("content-range") == f"bytes 500000-999999/{len(payload)}"
assert seek.content == payload[500000:]
assert seek.headers.get("content-disposition", "").startswith("inline;")
def test_get_artifact_download_true_forces_attachment_for_skill_archive(tmp_path, monkeypatch) -> None:
skill_path = tmp_path / "sample.skill"
with zipfile.ZipFile(skill_path, "w") as zip_ref: