fix(artifacts): bind ranged reads to validated generation (#7978)

* fix(artifacts): bind ranged reads to validated generation

* fix(artifacts): drain pinned reads without Windows hard links

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
yu1128-L 2026-11-09 21:18:57 +08:00 committed by GitHub
parent 31d97fc85b
commit 7a8051c0d9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 331 additions and 17 deletions

View File

@ -3367,8 +3367,9 @@ external image URLs keep their existing behavior.
If the sample cuts a CRLF line ending in half, the preview keeps the earlier complete rows and omits the incomplete final record, including when its last field is quoted.
Text artifacts are streamed with HTTP byte-range support. The Web UI initially
loads at most 1 MiB, shows the preview size when a file is larger, and waits for
Text artifacts are streamed with HTTP byte-range support. A resumed download
validates its ETag against the file version that supplies its response bytes.
The Web UI initially loads at most 1 MiB, shows the preview size for larger files, and waits for
an explicit **Load full file** action before fetching the remainder or mounting
the full code editor. Editing becomes available once the complete file has loaded;
unsaved-change detection uses the full content, including when reverting edits

View File

@ -1,5 +1,11 @@
# Router contracts
Artifact range validators and bytes come from one opened file. POSIX streams
the descriptor. Windows copies only the selected response bytes into a
private temporary file and closes the public handle before sending headers.
Preserve the ZIP single-link checks and drain preparation before cancellation
can release an artifact response's resources.
Trash listing resolves the live retention configuration off-loop before its
best-effort sweep. Configuration failures skip automatic cleanup and preserve
the listing; never substitute a default policy for an unreadable one. Resolve

View File

@ -4,17 +4,21 @@ import hashlib
import logging
import mimetypes
import os
import secrets
import stat
import tempfile
import threading
import zipfile
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
from typing import BinaryIO
from urllib.parse import quote
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import FileResponse, Response
from pydantic import BaseModel, Field
from starlette.datastructures import MutableHeaders
from app.gateway.authz import SandboxRequestLease, require_permission, try_acquire_sandbox_for_request
from app.gateway.deps import get_run_manager
@ -41,6 +45,8 @@ _SKILL_ARCHIVE_READ_CHUNK_SIZE = 64 * 1024
MAX_EDITABLE_ARTIFACT_BYTES = 2 * 1024 * 1024
MAX_VIEWED_IMAGE_BLOB_BYTES = 20 * 1024 * 1024
_ARTIFACT_EDIT_TEMP_PREFIX = ".artifact-edit-"
_ARTIFACT_COPY_BEFORE_STREAM = os.name == "nt"
_artifact_digest_source = threading.local()
class ArtifactUpdateRequest(BaseModel):
@ -334,7 +340,7 @@ def _read_artifact_payload(actual_path: Path, path: str, download: bool) -> tupl
return ("inline_file", mime_type)
def _sha256_of_file(path: Path) -> str:
def _sha256_of_file(path: Path, *, pinned: tuple[BinaryIO, os.stat_result] | None = None) -> str:
"""Return the hex SHA-256 digest of *path* without loading it whole.
Computing the digest on the Gateway lets the browser skip its own
@ -346,45 +352,217 @@ def _sha256_of_file(path: Path) -> str:
requests a browser issues while scrubbing/paginating a preview do not each
re-hash a potentially huge artifact from scratch (raised in PR review).
"""
stat = path.stat()
file_stat = path.stat() if pinned is None else pinned[1]
# Sandbox syncs and other writers can atomically replace a same-size file
# while preserving its mtime. Identity separates those generations; ctime
# also invalidates in-place writes where the filesystem exposes change time.
return _sha256_of_file_cached(
key = (
str(path),
stat.st_dev,
stat.st_ino,
stat.st_ctime_ns,
stat.st_mtime_ns,
stat.st_size,
file_stat.st_dev,
file_stat.st_ino,
file_stat.st_ctime_ns,
file_stat.st_mtime_ns,
file_stat.st_size,
)
if pinned is None:
return _sha256_of_file_cached(*key)
previous = getattr(_artifact_digest_source, "pinned", None)
_artifact_digest_source.pinned = (key, pinned[0])
try:
return _sha256_of_file_cached(*key)
finally:
_artifact_digest_source.pinned = previous
@functools.lru_cache(maxsize=256)
def _sha256_of_file_cached(path: str, device: int, inode: int, ctime_ns: int, mtime_ns: int, size: int) -> str:
"""Cached SHA-256 of *path*; metadata arguments separate file generations."""
digest = hashlib.sha256()
with open(path, "rb") as handle:
for chunk in iter(lambda: handle.read(1 << 20), b""):
digest.update(chunk)
return digest.hexdigest()
pinned = getattr(_artifact_digest_source, "pinned", None)
# A nested lookup for a different file must hash its own bytes.
if pinned is None or pinned[0] != (path, device, inode, ctime_ns, mtime_ns, size):
with open(path, "rb") as handle:
return hashlib.file_digest(handle, "sha256").hexdigest()
handle = pinned[1]
previous_offset = handle.tell()
try:
handle.seek(0)
return hashlib.file_digest(handle, "sha256").hexdigest()
finally:
handle.seek(previous_offset)
def _artifact_etag(path: Path) -> str:
"""Keep content revisions for editable files and metadata-only tags for large files."""
"""Keep content revisions for editable files and metadata tags for large files."""
file_stat = path.stat()
if file_stat.st_size <= MAX_EDITABLE_ARTIFACT_BYTES:
return f'"{_sha256_of_file(path)}"'
# Populate the shared cache only with metadata from the hashed handle.
with path.open("rb") as handle:
return _artifact_etag_from_handle(path, handle, os.fstat(handle.fileno()))
return _artifact_stat_etag(file_stat)
def _artifact_stat_etag(file_stat: os.stat_result) -> str:
# Do not read oversized artifacts just to validate a small byte range.
# The prefix keeps this opaque validator distinct from an editable SHA-256.
identity = f"{file_stat.st_dev}:{file_stat.st_ino}:{file_stat.st_ctime_ns}:{file_stat.st_mtime_ns}:{file_stat.st_size}"
return f'"stat-{hashlib.sha256(identity.encode("ascii")).hexdigest()}"'
def _artifact_etag_from_handle(path: Path, handle: BinaryIO, file_stat: os.stat_result) -> str:
"""Build the validator from the same file generation the response will read."""
if file_stat.st_size <= MAX_EDITABLE_ARTIFACT_BYTES:
digest = _sha256_of_file(path, pinned=(handle, file_stat))
return f'"{digest}"'
return _artifact_stat_etag(file_stat)
def _prepare_artifact_stream(path: Path) -> tuple[BinaryIO, os.stat_result, str]:
"""Pin one file generation before response headers authorize a byte range."""
handle = path.open("rb")
try:
file_stat = os.fstat(handle.fileno())
if not stat.S_ISREG(file_stat.st_mode):
raise RuntimeError(f"File at path {path} is not a file.")
return handle, file_stat, _artifact_etag_from_handle(path, handle, file_stat)
except BaseException:
_close_artifact_handle(handle)
raise
def _copy_artifact_ranges(handle: BinaryIO, ranges: list[tuple[int, int]]) -> BinaryIO:
"""Pack the selected ranges into a private temporary file in send order."""
snapshot = tempfile.TemporaryFile(mode="w+b")
try:
for start, end in ranges:
handle.seek(start)
remaining = end - start
while remaining:
chunk = handle.read(min(1 << 20, remaining))
if not chunk:
raise OSError("Artifact was truncated while preparing the response")
snapshot.write(chunk)
remaining -= len(chunk)
snapshot.seek(0)
return snapshot
except BaseException:
_close_artifact_handle(snapshot)
raise
def _close_artifact_handle(handle: BinaryIO) -> None:
try:
handle.close()
except OSError:
logger.warning("Could not close artifact response handle", exc_info=True)
class _ArtifactFileResponse(FileResponse):
"""Require an ETag to authorize conditional ranges of replaceable artifacts."""
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self._artifact_handle: BinaryIO | None = None
async def __call__(self, scope, receive, send) -> None:
async def prepare() -> None:
self._artifact_handle, self.stat_result, etag = await asyncio.to_thread(_prepare_artifact_stream, Path(self.path))
self.headers["etag"] = etag
self.set_stat_headers(self.stat_result)
try:
await await_drained(prepare())
await super().__call__(scope, receive, send)
finally:
if self._artifact_handle is not None:
handle, self._artifact_handle = self._artifact_handle, None
await await_drained(asyncio.to_thread(_close_artifact_handle, handle))
async def _snapshot_windows_body(self, ranges: list[tuple[int, int]], send_header_only: bool) -> bool:
if not _ARTIFACT_COPY_BEFORE_STREAM:
return False
source = self._artifact_handle
assert source is not None
async def snapshot() -> None:
# Windows replacement needs the public handle closed. A private
# copy also leaves the artifact's link count unchanged for ZIP.
self._artifact_handle = None if send_header_only else await asyncio.to_thread(_copy_artifact_ranges, source, ranges)
await asyncio.to_thread(_close_artifact_handle, source)
await await_drained(snapshot())
return True
async def _handle_simple(self, send, send_header_only: bool, send_pathsend: bool) -> None:
assert self.stat_result is not None
await self._snapshot_windows_body([(0, self.stat_result.st_size)], send_header_only)
await send({"type": "http.response.start", "status": self.status_code, "headers": self.raw_headers})
if send_header_only:
await send({"type": "http.response.body", "body": b"", "more_body": False})
return
handle = self._artifact_handle
assert handle is not None
await await_drained(asyncio.to_thread(handle.seek, 0))
more_body = True
while more_body:
chunk = await await_drained(asyncio.to_thread(handle.read, self.chunk_size))
more_body = len(chunk) == self.chunk_size
await send({"type": "http.response.body", "body": chunk, "more_body": more_body})
async def _handle_single_range(self, send, start: int, end: int, file_size: int, send_header_only: bool) -> None:
snapshot = await self._snapshot_windows_body([(start, end)], send_header_only)
headers = MutableHeaders(raw=list(self.raw_headers))
headers["content-range"] = f"bytes {start}-{end - 1}/{file_size}"
headers["content-length"] = str(end - start)
await send({"type": "http.response.start", "status": 206, "headers": headers.raw})
if send_header_only:
await send({"type": "http.response.body", "body": b"", "more_body": False})
return
handle = self._artifact_handle
assert handle is not None
if not snapshot:
await await_drained(asyncio.to_thread(handle.seek, start))
more_body = True
while more_body:
chunk = await await_drained(asyncio.to_thread(handle.read, min(self.chunk_size, end - start)))
start += len(chunk)
more_body = len(chunk) == self.chunk_size and start < end
await send({"type": "http.response.body", "body": chunk, "more_body": more_body})
async def _handle_multiple_ranges(self, send, ranges: list[tuple[int, int]], file_size: int, send_header_only: bool) -> None:
snapshot = await self._snapshot_windows_body(ranges, send_header_only)
boundary = secrets.token_hex(13)
content_length, header_generator = self.generate_multipart(ranges, boundary, file_size, self.headers["content-type"])
headers = MutableHeaders(raw=list(self.raw_headers))
headers["content-type"] = f"multipart/byteranges; boundary={boundary}"
headers["content-length"] = str(content_length)
await send({"type": "http.response.start", "status": 206, "headers": headers.raw})
if send_header_only:
await send({"type": "http.response.body", "body": b"", "more_body": False})
return
handle = self._artifact_handle
assert handle is not None
for start, end in ranges:
await send({"type": "http.response.body", "body": header_generator(start, end), "more_body": True})
if not snapshot:
await await_drained(asyncio.to_thread(handle.seek, start))
while start < end:
chunk = await await_drained(asyncio.to_thread(handle.read, min(self.chunk_size, end - start)))
if not chunk:
raise OSError("Artifact was truncated while streaming a byte range")
start += len(chunk)
await send({"type": "http.response.body", "body": chunk, "more_body": True})
await send({"type": "http.response.body", "body": b"\r\n", "more_body": True})
await send({"type": "http.response.body", "body": f"--{boundary}--".encode("latin-1"), "more_body": False})
def _should_use_range(self, http_if_range: str) -> bool:
# Sandbox syncs can preserve mtimes across replacements, so Last-Modified
# is not a strong validator. Date-form If-Range must return the full file.

View File

@ -18,6 +18,7 @@ from starlette.requests import Request
from starlette.responses import FileResponse
import app.gateway.routers.artifacts as artifacts_router
from app.gateway.artifact_archive import build_artifact_archive
from app.gateway.internal_auth import INTERNAL_OWNER_USER_ID_HEADER_NAME, INTERNAL_SYSTEM_ROLE
from deerflow.config.paths import Paths, make_safe_user_id
from deerflow.sandbox.lease import get_sandbox_lease_manager
@ -1041,6 +1042,134 @@ def test_replaced_artifact_rejects_previous_if_range_validator(tmp_path, monkeyp
assert response.headers["etag"] == f'"{hashlib.sha256(b"new report").hexdigest()}"'
@pytest.mark.parametrize("ranges", ["bytes=4-9", "bytes=0-1,4-5"], ids=["single", "multipart"])
@pytest.mark.parametrize(
"copy_before_stream",
[pytest.param(False, marks=pytest.mark.skipif(os.name == "nt", reason="Windows requires a private response copy")), True],
ids=["descriptor", "private-copy"],
)
def test_if_range_stream_keeps_the_validated_artifact_generation(tmp_path, monkeypatch, ranges, copy_before_stream) -> None:
outputs = tmp_path / "outputs"
outputs.mkdir()
artifact_path = outputs / "note.txt"
original = b"old lesson"
artifact_path.write_bytes(original)
artifacts_router._sha256_of_file_cached.cache_clear()
monkeypatch.setattr(artifacts_router, "_ARTIFACT_COPY_BEFORE_STREAM", copy_before_stream)
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)
url = "/api/threads/thread-1/artifacts/mnt/user-data/outputs/note.txt"
replace_after_headers = False
replacement_confirmed = False
async def replace_between_headers_and_body(scope, receive, send):
async def send_and_replace(message):
nonlocal replacement_confirmed
await send(message)
if replace_after_headers and message["type"] == "http.response.start":
archive = await asyncio.to_thread(build_artifact_archive, outputs, ["mnt/user-data/outputs/note.txt"], user_data_dir=tmp_path)
with archive.file, zipfile.ZipFile(archive.file) as zipped:
assert zipped.read("note.txt") == original
_replace_preserving_artifact_mtime(artifact_path, b"new slides")
replacement_confirmed = True
await app(scope, receive, send_and_replace)
with TestClient(replace_between_headers_and_body) as client:
first = client.get(url)
replace_after_headers = True
resumed = client.get(url, headers={"Range": ranges, "If-Range": first.headers["etag"]})
assert replacement_confirmed
assert artifact_path.read_bytes() == b"new slides"
assert resumed.status_code == 206
assert resumed.headers["etag"] == first.headers["etag"]
assert int(resumed.headers["content-length"]) == len(resumed.content)
if ranges == "bytes=4-9":
assert resumed.content == b"lesson"
assert resumed.headers["content-range"] == "bytes 4-9/10"
else:
media_type, _, boundary = resumed.headers["content-type"].partition("; boundary=")
assert media_type == "multipart/byteranges" and boundary
parts = []
for start, end in [(0, 2), (4, 6)]:
part_header = f"--{boundary}\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Range: bytes {start}-{end - 1}/10\r\n\r\n"
parts.append(part_header.encode("ascii") + original[start:end] + b"\r\n")
assert resumed.content == b"".join(parts) + f"--{boundary}--".encode("ascii")
assert list(outputs.iterdir()) == [artifact_path]
@pytest.mark.parametrize("stage", ["_prepare_artifact_stream", "_copy_artifact_ranges"])
def test_cancelled_artifact_response_drains_file_ownership(tmp_path, monkeypatch, stage) -> None:
artifact_path = tmp_path / "note.txt"
artifact_path.write_bytes(b"original")
monkeypatch.setattr(artifacts_router, "_ARTIFACT_COPY_BEFORE_STREAM", True)
worker = getattr(artifacts_router, stage)
release_worker = threading.Event()
handles = []
async def exercise():
loop = asyncio.get_running_loop()
started = asyncio.Event()
finished = asyncio.Event()
def gated_worker(*args):
result = worker(*args)
handles.append(result[0] if stage == "_prepare_artifact_stream" else result)
loop.call_soon_threadsafe(started.set)
try:
assert release_worker.wait(5)
return result
finally:
loop.call_soon_threadsafe(finished.set)
async def send(_message):
pytest.fail("Cancellation before preparation completes must send no headers or body")
monkeypatch.setattr(artifacts_router, stage, gated_worker)
response = artifacts_router._ArtifactFileResponse(artifact_path)
scope = {"type": "http", "method": "GET", "headers": [(b"range", b"bytes=1-2")]}
task = asyncio.create_task(response(scope, AsyncMock(), send))
try:
await asyncio.wait_for(started.wait(), timeout=5)
task.cancel()
await asyncio.sleep(0)
assert not task.done()
finally:
release_worker.set()
if started.is_set():
await asyncio.wait_for(finished.wait(), timeout=5)
await asyncio.gather(task, return_exceptions=True)
assert task.cancelled()
assert len(handles) == 1 and handles[0].closed
assert artifact_path.stat().st_nlink == 1
asyncio.run(exercise())
def test_pinned_digest_cannot_populate_another_artifacts_cache_key(tmp_path, monkeypatch) -> None:
first = tmp_path / "first.txt"
other = tmp_path / "other.txt"
first.write_bytes(b"first file")
other.write_bytes(b"other file")
cached = artifacts_router._sha256_of_file_cached
cached.cache_clear()
def nested_lookup(path, *metadata):
if path == str(first):
assert artifacts_router._sha256_of_file(other) == hashlib.sha256(b"other file").hexdigest()
return cached(path, *metadata)
monkeypatch.setattr(artifacts_router, "_sha256_of_file_cached", nested_lookup)
with first.open("rb") as handle:
handle.seek(2)
assert artifacts_router._artifact_etag_from_handle(first, handle, os.fstat(handle.fileno())) == f'"{hashlib.sha256(b"first file").hexdigest()}"'
assert handle.tell() == 2
assert artifacts_router._sha256_of_file(other) == hashlib.sha256(b"other file").hexdigest()
@pytest.mark.parametrize("changed_field", ["st_dev", "st_ino", "st_ctime_ns"])
def test_artifact_digest_invalidates_when_file_identity_changes(tmp_path, monkeypatch, changed_field) -> None:
artifact_path = tmp_path / "note.txt"