Skip to content

Commit 26ee423

Browse files
authored
fix: support HTTP Range requests on virtual files (#9460) (#9473)
Safari's `<audio>/<video>` elements require a 206 Partial Content response to their initial range probe. Without it, mo.audio renders a disabled player. Chrome and Firefox are more forgiving, so the bug was Safari-only. Parse Range headers in the `/@file/` endpoint and return 206 with `Content-Range/Content-Length`, advertise `Accept-Ranges:` bytes on every response, and 416 for invalid ranges. Full responses still omit `Content-Length` to preserve the h11 fix from #8928. Adds an optional start offset to the chunked virtual-file readers so partial reads don't allocate the full buffer. Closes #9460
1 parent 06594d9 commit 26ee423

6 files changed

Lines changed: 219 additions & 14 deletions

File tree

marimo/_runtime/virtual_file/storage.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,19 @@ def read_chunked(
3838
key: str,
3939
byte_length: int,
4040
chunk_size: int = DEFAULT_CHUNK_SIZE,
41+
start: int = 0,
4142
) -> Iterator[bytes]:
4243
"""Read buffer data by key in chunks.
4344
4445
Yields chunks of bytes, avoiding allocating the full buffer at once.
4546
Useful for streaming large files over HTTP.
4647
48+
Args:
49+
key: storage key
50+
byte_length: total number of bytes to yield (after applying ``start``)
51+
chunk_size: chunk size in bytes
52+
start: offset in bytes to begin reading from (default 0)
53+
4754
Raises:
4855
KeyError: If key not found
4956
"""
@@ -146,6 +153,7 @@ def read_chunked(
146153
key: str,
147154
byte_length: int,
148155
chunk_size: int = DEFAULT_CHUNK_SIZE,
156+
start: int = 0,
149157
) -> Iterator[bytes]:
150158
if is_pyodide():
151159
raise RuntimeError(
@@ -155,7 +163,7 @@ def read_chunked(
155163
view = None
156164
try:
157165
shm = shared_memory.SharedMemory(name=key)
158-
view = shm.buf[:byte_length]
166+
view = shm.buf[start : start + byte_length]
159167
for i in range(0, byte_length, chunk_size):
160168
yield bytes(view[i : i + chunk_size])
161169
except FileNotFoundError as err:
@@ -225,12 +233,13 @@ def read_chunked(
225233
key: str,
226234
byte_length: int,
227235
chunk_size: int = DEFAULT_CHUNK_SIZE,
236+
start: int = 0,
228237
) -> Iterator[bytes]:
229238
if key not in self._storage:
230239
raise KeyError(f"Virtual file not found: {key}")
231240
buffer = self._storage[key]
232-
end = min(byte_length, len(buffer))
233-
for i in range(0, end, chunk_size):
241+
end = min(start + byte_length, len(buffer))
242+
for i in range(start, end, chunk_size):
234243
yield buffer[i : min(i + chunk_size, end)]
235244

236245
def remove(self, key: str) -> None:
@@ -288,6 +297,7 @@ def read_chunked(
288297
filename: str,
289298
byte_length: int,
290299
chunk_size: int = DEFAULT_CHUNK_SIZE,
300+
start: int = 0,
291301
) -> Iterator[bytes]:
292302
"""Read from storage in chunks, with cross-process fallback.
293303
@@ -301,7 +311,9 @@ def read_chunked(
301311
storage = self.storage
302312
if storage is None:
303313
yield from SharedMemoryStorage().read_chunked(
304-
filename, byte_length, chunk_size
314+
filename, byte_length, chunk_size, start
305315
)
306316
else:
307-
yield from storage.read_chunked(filename, byte_length, chunk_size)
317+
yield from storage.read_chunked(
318+
filename, byte_length, chunk_size, start
319+
)

marimo/_runtime/virtual_file/virtual_file.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -310,16 +310,21 @@ def read_virtual_file(filename: str, byte_length: int) -> bytes:
310310

311311

312312
def read_virtual_file_chunked(
313-
filename: str, byte_length: int
313+
filename: str, byte_length: int, start: int = 0
314314
) -> Iterator[bytes]:
315315
"""Read a virtual file in chunks for streaming responses.
316316
317317
Yields chunks of bytes, avoiding holding the entire file in memory
318318
as a single bytes object.
319+
320+
Args:
321+
filename: virtual file name
322+
byte_length: number of bytes to read (after applying ``start``)
323+
start: offset in bytes to begin reading from (for HTTP Range requests)
319324
"""
320325
try:
321326
yield from VirtualFileStorageManager().read_chunked(
322-
filename, byte_length
327+
filename, byte_length, start=start
323328
)
324329
except KeyError as err:
325330
raise HTTPException(

marimo/_server/api/endpoints/assets.py

Lines changed: 68 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -538,17 +538,20 @@ def virtual_file(
538538
detail="Invalid virtual file request",
539539
)
540540

541-
byte_length, filename = filename_and_length.split("-", 1)
542-
if not byte_length.isdigit():
541+
byte_length_str, filename = filename_and_length.split("-", 1)
542+
if not byte_length_str.isdigit():
543543
raise HTTPException(
544544
status_code=404,
545545
detail="Invalid byte length in virtual file request",
546546
)
547+
total_size = int(byte_length_str)
547548

548-
chunks = read_virtual_file_chunked(filename, int(byte_length))
549549
mimetype, _ = mimetypes.guess_type(filename)
550550
headers = {
551551
"Cache-Control": "max-age=86400",
552+
# Advertise range support so Safari (which requires it for media
553+
# playback) will load <audio>/<video> sources. See #9460.
554+
"Accept-Ranges": "bytes",
552555
}
553556
# When ?download=1 is set, force a save dialog. This bypasses cases
554557
# where <a download> is ignored (e.g., sandboxed iframes without
@@ -558,17 +561,76 @@ def virtual_file(
558561

559562
download_filename = request.query_params.get("filename") or filename
560563
headers.update(make_download_headers(download_filename))
561-
# Do NOT set Content-Length here. StreamingResponse with an explicit
562-
# Content-Length causes h11 LocalProtocolError ("Too little data for
563-
# declared Content-Length") for large files. Omitting it lets h11 use
564+
565+
range_header = request.headers.get("range")
566+
if range_header is not None:
567+
parsed = _parse_range_header(range_header, total_size)
568+
if parsed is None:
569+
return Response(
570+
status_code=416,
571+
headers={**headers, "Content-Range": f"bytes */{total_size}"},
572+
)
573+
start, end = parsed
574+
length = end - start + 1
575+
chunks = read_virtual_file_chunked(filename, length, start=start)
576+
partial_headers = {
577+
**headers,
578+
"Content-Range": f"bytes {start}-{end}/{total_size}",
579+
"Content-Length": str(length),
580+
}
581+
return StreamingResponse(
582+
content=chunks,
583+
status_code=206,
584+
media_type=mimetype,
585+
headers=partial_headers,
586+
)
587+
588+
# Do NOT set Content-Length on full responses. StreamingResponse with an
589+
# explicit Content-Length causes h11 LocalProtocolError ("Too little data
590+
# for declared Content-Length") for large files. Omitting it lets h11 use
564591
# chunked transfer encoding instead. See #8917.
592+
chunks = read_virtual_file_chunked(filename, total_size)
565593
return StreamingResponse(
566594
content=chunks,
567595
media_type=mimetype,
568596
headers=headers,
569597
)
570598

571599

600+
_RANGE_RE = re.compile(r"^bytes=(\d*)-(\d*)$", re.IGNORECASE)
601+
602+
603+
def _parse_range_header(
604+
range_header: str, total_size: int
605+
) -> tuple[int, int] | None:
606+
"""Parse a single-range HTTP ``Range`` header.
607+
608+
Returns ``(start, end)`` byte offsets (inclusive) on success, or
609+
``None`` if the range is unsatisfiable. Multi-range requests are
610+
treated as unsatisfiable since marimo only supports single ranges.
611+
"""
612+
match = _RANGE_RE.match(range_header.strip())
613+
if match is None or total_size == 0:
614+
return None
615+
start_str, end_str = match.group(1), match.group(2)
616+
if start_str == "" and end_str == "":
617+
return None
618+
if start_str == "":
619+
# Suffix range: last N bytes.
620+
suffix = int(end_str)
621+
if suffix == 0:
622+
return None
623+
start = max(total_size - suffix, 0)
624+
end = total_size - 1
625+
else:
626+
start = int(start_str)
627+
end = int(end_str) if end_str else total_size - 1
628+
if start >= total_size or end < start:
629+
return None
630+
end = min(end, total_size - 1)
631+
return start, end
632+
633+
572634
@router.get("/public-files-sw.js")
573635
async def public_files_service_worker(request: Request) -> Response:
574636
"""

marimo/_smoke_tests/media.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import marimo
44

5-
__generated_with = "0.17.6"
5+
__generated_with = "0.23.5"
66
app = marimo.App()
77

88

@@ -12,6 +12,7 @@ def _():
1212
import requests
1313
from io import BytesIO
1414
import base64
15+
1516
return BytesIO, base64, mo, requests
1617

1718

@@ -91,6 +92,22 @@ def _(mo):
9192
return
9293

9394

95+
@app.cell
96+
def _(mo):
97+
# Regression test for #9460: mo.audio with a numpy array goes through the
98+
# virtual file endpoint, which must serve HTTP Range requests so Safari's
99+
# <audio> element will play it. Open this notebook in Safari and confirm
100+
# the player is enabled and audible.
101+
import math
102+
103+
import numpy as np
104+
105+
_sr = 44100
106+
_samples = 0.01 * np.sin(math.tau * np.cumsum(np.linspace(660, 110, 100000)) / _sr)
107+
mo.audio(_samples, _sr, normalize=False)
108+
return
109+
110+
94111
@app.cell
95112
def _(mo):
96113
mo.video(

tests/_runtime/test_storage.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,12 @@ def test_read_chunked_chunk_sizes(self) -> None:
5252
assert len(chunks[-1]) <= chunk_size
5353
assert b"".join(chunks) == data
5454

55+
def test_read_chunked_with_start_offset(self) -> None:
56+
storage = InMemoryStorage()
57+
storage.store("test_key", b"hello world")
58+
chunks = list(storage.read_chunked("test_key", 5, start=6))
59+
assert b"".join(chunks) == b"world"
60+
5561

5662
class TestInMemoryStorage:
5763
def test_store_and_read(self) -> None:
@@ -268,6 +274,17 @@ def test_read_chunked_cross_process(self) -> None:
268274
finally:
269275
storage1.shutdown()
270276

277+
def test_read_chunked_with_start_offset(self) -> None:
278+
storage = SharedMemoryStorage()
279+
try:
280+
storage.store("marimo_chunk_offset", b"hello world")
281+
chunks = list(
282+
storage.read_chunked("marimo_chunk_offset", 5, start=6)
283+
)
284+
assert b"".join(chunks) == b"world"
285+
finally:
286+
storage.shutdown()
287+
271288
def test_read_chunked_data_integrity(self) -> None:
272289
"""Test that chunked read produces identical data to regular read."""
273290
storage = SharedMemoryStorage()

tests/_server/api/endpoints/test_assets.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,98 @@ def test_vfile_large_streaming(client: TestClient) -> None:
345345
manager.storage = original_storage
346346

347347

348+
def test_vfile_range_requests(client: TestClient) -> None:
349+
"""Virtual files must support HTTP Range requests so that Safari can
350+
play media (audio/video) — Safari's <audio> element refuses to load
351+
sources whose server doesn't return 206 Partial Content for range
352+
probes.
353+
354+
See https://github.com/marimo-team/marimo/issues/9460
355+
"""
356+
from marimo._runtime.virtual_file.storage import (
357+
InMemoryStorage,
358+
VirtualFileStorageManager,
359+
)
360+
361+
manager = VirtualFileStorageManager()
362+
original_storage = manager.storage
363+
storage = InMemoryStorage()
364+
manager.storage = storage
365+
366+
try:
367+
data = bytes(range(256)) * 8 # 2048 bytes of deterministic content
368+
filename = "test-audio.wav"
369+
storage.store(filename, data)
370+
byte_length = len(data)
371+
url = f"/@file/{byte_length}-{filename}"
372+
373+
# Plain GET advertises Accept-Ranges so clients know they can probe.
374+
response = client.get(url, headers=token_header())
375+
assert response.status_code == 200, response.text
376+
assert response.headers.get("accept-ranges") == "bytes"
377+
assert response.content == data
378+
379+
# Bounded range returns 206 with Content-Range and exact bytes.
380+
response = client.get(
381+
url,
382+
headers={**token_header(), "Range": "bytes=0-99"},
383+
)
384+
assert response.status_code == 206, response.text
385+
assert (
386+
response.headers.get("content-range")
387+
== f"bytes 0-99/{byte_length}"
388+
)
389+
assert response.headers.get("content-length") == "100"
390+
assert response.headers.get("accept-ranges") == "bytes"
391+
assert response.content == data[0:100]
392+
393+
# Open-ended range (start-) serves to the end of the file.
394+
response = client.get(
395+
url,
396+
headers={**token_header(), "Range": "bytes=50-"},
397+
)
398+
assert response.status_code == 206, response.text
399+
end = byte_length - 1
400+
assert (
401+
response.headers.get("content-range")
402+
== f"bytes 50-{end}/{byte_length}"
403+
)
404+
assert response.content == data[50:]
405+
406+
# Suffix range (-N) returns the last N bytes.
407+
response = client.get(
408+
url,
409+
headers={**token_header(), "Range": "bytes=-50"},
410+
)
411+
assert response.status_code == 206, response.text
412+
start = byte_length - 50
413+
assert (
414+
response.headers.get("content-range")
415+
== f"bytes {start}-{end}/{byte_length}"
416+
)
417+
assert response.content == data[-50:]
418+
419+
# Out-of-range start → 416 with Content-Range advertising the size.
420+
response = client.get(
421+
url,
422+
headers={**token_header(), "Range": f"bytes={byte_length}-"},
423+
)
424+
assert response.status_code == 416, response.text
425+
assert (
426+
response.headers.get("content-range") == f"bytes */{byte_length}"
427+
)
428+
429+
# Range unit token is case-insensitive per RFC 9110.
430+
response = client.get(
431+
url,
432+
headers={**token_header(), "Range": "Bytes=0-99"},
433+
)
434+
assert response.status_code == 206, response.text
435+
assert response.content == data[:100]
436+
finally:
437+
manager.storage = original_storage
438+
439+
348440
def test_vfile_download_query_param_sets_content_disposition(
349441
client: TestClient,
350442
) -> None:

0 commit comments

Comments
 (0)