fix(uploads): retain filesystem ownership across request cancellation (#7170)

* fix(uploads): retain filesystem ownership across cancellation

* fix(uploads): preserve cancellation across cleanup failures
This commit is contained in:
SumEn 2026-10-20 23:17:00 +08:00 committed by GitHub
parent 19e861d0c3
commit f4eaa51a73
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 678 additions and 84 deletions

View File

@ -5,6 +5,7 @@ import os
import stat import stat
import tempfile import tempfile
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from contextlib import suppress
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import BinaryIO from typing import BinaryIO
@ -190,7 +191,8 @@ def _cleanup_uploaded_paths(paths: list[os.PathLike[str] | str]) -> None:
except FileNotFoundError: except FileNotFoundError:
pass pass
except Exception: except Exception:
logger.warning("Failed to clean up upload path after rejected request: %s", path, exc_info=True) with suppress(Exception):
logger.warning("Failed to clean up upload path after rejected request: %s", path, exc_info=True)
def _pure_destination(uploads_dir: os.PathLike[str] | str, display_filename: str) -> Path: def _pure_destination(uploads_dir: os.PathLike[str] | str, display_filename: str) -> Path:
@ -320,7 +322,8 @@ def _remove_staged_file(staged_path: os.PathLike[str] | str) -> None:
except FileNotFoundError: except FileNotFoundError:
pass pass
except OSError: except OSError:
logger.warning("Failed to remove staged upload file: %s", staged_path, exc_info=True) with suppress(Exception):
logger.warning("Failed to remove staged upload file: %s", staged_path, exc_info=True)
def _write_upload_chunk(upload_temp: _UploadTempFile, chunk: bytes) -> None: def _write_upload_chunk(upload_temp: _UploadTempFile, chunk: bytes) -> None:
@ -330,6 +333,9 @@ def _write_upload_chunk(upload_temp: _UploadTempFile, chunk: bytes) -> None:
def _abort_upload_temp(upload_temp: _UploadTempFile) -> None: def _abort_upload_temp(upload_temp: _UploadTempFile) -> None:
try: try:
upload_temp.handle.close() upload_temp.handle.close()
except Exception:
with suppress(Exception):
logger.warning("Failed to close staged upload file: %s", upload_temp.temp_path, exc_info=True)
finally: finally:
# Best-effort, not ``os.unlink``: an abandoned duplication worker can # Best-effort, not ``os.unlink``: an abandoned duplication worker can
# still hold this inode open (Windows refuses to remove it then), and # still hold this inode open (Windows refuses to remove it then), and

View File

@ -35,6 +35,7 @@ import shutil
import tempfile import tempfile
import uuid import uuid
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from contextlib import suppress
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
@ -42,6 +43,7 @@ from fastapi import HTTPException
from deerflow.config.app_config import AppConfig from deerflow.config.app_config import AppConfig
from deerflow.uploads.companions import register_companion, unregister_companion from deerflow.uploads.companions import register_companion, unregister_companion
from deerflow.utils.atomic_write import cleanup_failed_atomic_write
from deerflow.utils.file_io import await_drained, run_file_io from deerflow.utils.file_io import await_drained, run_file_io
if TYPE_CHECKING: if TYPE_CHECKING:
@ -60,7 +62,8 @@ def _close_fd(fd: int | None) -> None:
try: try:
os.close(fd) os.close(fd)
except OSError: except OSError:
logger.warning("Failed to close upload conversion descriptor", exc_info=True) with suppress(Exception):
logger.warning("Failed to close upload conversion descriptor", exc_info=True)
def _close_abandoned_fd(duplication: asyncio.Future) -> None: def _close_abandoned_fd(duplication: asyncio.Future) -> None:
@ -160,7 +163,11 @@ class ThreadUploadIngestionService:
self._total_size = 0 self._total_size = 0
async def __aenter__(self) -> ThreadUploadIngestionService: async def __aenter__(self) -> ThreadUploadIngestionService:
await self.open() try:
await self.open()
except BaseException:
await self.aclose()
raise
return self return self
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None: async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
@ -193,18 +200,23 @@ class ThreadUploadIngestionService:
sandbox_provider = uploads.get_sandbox_provider() sandbox_provider = uploads.get_sandbox_provider()
self._sync_to_sandbox = not uploads._uses_thread_data_mounts(sandbox_provider) self._sync_to_sandbox = not uploads._uses_thread_data_mounts(sandbox_provider)
if self._sync_to_sandbox: if self._sync_to_sandbox:
self._sandbox_lease = await uploads.try_acquire_sandbox_for_request(
self._request, async def acquire_lease() -> None:
sandbox_provider, self._sandbox_lease = await uploads.try_acquire_sandbox_for_request(
self._thread_id, self._request,
user_id=self._user_id, sandbox_provider,
app_config=self._config, self._thread_id,
owner_prefix="gateway:upload", user_id=self._user_id,
release_on_last=True, app_config=self._config,
) owner_prefix="gateway:upload",
self._sandbox = self._sandbox_lease.sandbox release_on_last=True,
if not self._sandbox_lease.denied and self._sandbox is None: )
raise HTTPException(status_code=500, detail="Failed to acquire sandbox") self._sandbox = self._sandbox_lease.sandbox
if not self._sandbox_lease.denied and self._sandbox is None:
raise HTTPException(status_code=500, detail="Failed to acquire sandbox")
# Retain the returned holder even if acquisition's awaiter is gone.
await await_drained(acquire_lease())
self._auto_convert = uploads._auto_convert_documents_enabled(self._config) self._auto_convert = uploads._auto_convert_documents_enabled(self._config)
async def _link_commit_with_retry(self, uploads: Any, staged_path: Path, claimed_name: str) -> tuple[str, Path]: async def _link_commit_with_retry(self, uploads: Any, staged_path: Path, claimed_name: str) -> tuple[str, Path]:
@ -220,14 +232,37 @@ class ThreadUploadIngestionService:
the link makes the final name visible, fully formed. the link makes the final name visible, fully formed.
""" """
assert self._uploads_dir is not None, "open() must run before committing destinations" assert self._uploads_dir is not None, "open() must run before committing destinations"
name = claimed_name
while True: async def publish() -> tuple[str, Path]:
try: name = claimed_name
return name, await run_file_io(uploads._link_staged_no_overwrite, staged_path, self._uploads_dir, name) while True:
except FileExistsError: try:
name = uploads.claim_unique_filename(name, self._seen_filenames) path = await run_file_io(uploads._link_staged_no_overwrite, staged_path, self._uploads_dir, name)
self._written_paths.append(path)
return name, path
except FileExistsError:
name = uploads.claim_unique_filename(name, self._seen_filenames)
# Publication and recording its cleanup owner are one admitted step.
return await await_drained(publish())
async def ingest_chunks(self, chunks: AsyncIterator[bytes], *, display_name: str) -> dict[str, Any]: async def ingest_chunks(self, chunks: AsyncIterator[bytes], *, display_name: str) -> dict[str, Any]:
"""Ingest a cancellable stream, reclaiming session files on cancellation.
Only admitted filesystem steps and their ownership handoffs are drained;
waiting for the next incoming chunk remains immediately cancellable.
"""
try:
return await self._ingest_chunks(chunks, display_name=display_name)
except asyncio.CancelledError:
try:
await self.cleanup_written()
except Exception:
with suppress(Exception):
logger.warning("Failed to reclaim cancelled upload session files", exc_info=True)
raise
async def _ingest_chunks(self, chunks: AsyncIterator[bytes], *, display_name: str) -> dict[str, Any]:
"""Ingest one file from a chunk stream; return its wire metadata dict. """Ingest one file from a chunk stream; return its wire metadata dict.
Owns the per-file pipeline exactly as the ordinary endpoint defines Owns the per-file pipeline exactly as the ordinary endpoint defines
@ -252,8 +287,42 @@ class ThreadUploadIngestionService:
upload_temp = None upload_temp = None
convert_source_fd: int | None = None convert_source_fd: int | None = None
deferred_staged_path: Path | None = None deferred_staged_path: Path | None = None
try:
async def prepare() -> None:
nonlocal upload_temp
upload_temp = await run_file_io(uploads._prepare_upload_destination, self._uploads_dir, safe_filename) upload_temp = await run_file_io(uploads._prepare_upload_destination, self._uploads_dir, safe_filename)
async def publish() -> Path:
nonlocal safe_filename, upload_temp, deferred_staged_path
while True:
try:
path = await run_file_io(
uploads._commit_upload_temp_no_overwrite,
upload_temp,
self._uploads_dir,
safe_filename,
unlink_staged=convert_source_fd is None,
)
break
except FileExistsError:
safe_filename = uploads.claim_unique_filename(safe_filename, self._seen_filenames)
self._written_paths.append(path)
if convert_source_fd is not None:
deferred_staged_path = upload_temp.temp_path
upload_temp = None
return path
async def abort() -> None:
nonlocal convert_source_fd
_close_fd(convert_source_fd)
convert_source_fd = None
if upload_temp is not None:
await run_file_io(uploads._abort_upload_temp, upload_temp)
if deferred_staged_path is not None:
await run_file_io(uploads._remove_staged_file, deferred_staged_path)
try:
await await_drained(prepare())
async for chunk in chunks: async for chunk in chunks:
file_size += len(chunk) file_size += len(chunk)
self._total_size += len(chunk) self._total_size += len(chunk)
@ -261,7 +330,7 @@ class ThreadUploadIngestionService:
raise HTTPException(status_code=413, detail=f"File too large: {safe_filename}") raise HTTPException(status_code=413, detail=f"File too large: {safe_filename}")
if self._total_size > self._limits.max_total_size: if self._total_size > self._limits.max_total_size:
raise HTTPException(status_code=413, detail="Total upload size too large") raise HTTPException(status_code=413, detail="Total upload size too large")
await run_file_io(uploads._write_upload_chunk, upload_temp, chunk) await await_drained(run_file_io(uploads._write_upload_chunk, upload_temp, chunk))
if self._auto_convert and Path(safe_filename).suffix.lower() in uploads.CONVERTIBLE_EXTENSIONS: if self._auto_convert and Path(safe_filename).suffix.lower() in uploads.CONVERTIBLE_EXTENSIONS:
# Conversion must read the bytes this request staged. Once the # Conversion must read the bytes this request staged. Once the
# name is committed a sandbox process can replace it with a # name is committed a sandbox process can replace it with a
@ -278,37 +347,17 @@ class ThreadUploadIngestionService:
# Publishing is therefore split from removing the staged name: # Publishing is therefore split from removing the staged name:
# this request keeps ownership of the staged path and removes it # this request keeps ownership of the staged path and removes it
# once the descriptor is released below. # once the descriptor is released below.
while True: file_path = await await_drained(publish())
try:
file_path = await run_file_io(
uploads._commit_upload_temp_no_overwrite,
upload_temp,
self._uploads_dir,
safe_filename,
unlink_staged=convert_source_fd is None,
)
break
except FileExistsError:
safe_filename = uploads.claim_unique_filename(safe_filename, self._seen_filenames)
if convert_source_fd is not None:
deferred_staged_path = upload_temp.temp_path
upload_temp = None
except uploads.UnsafeUploadPathError as exc: except uploads.UnsafeUploadPathError as exc:
_close_fd(convert_source_fd) await await_drained(abort())
if upload_temp is not None:
await run_file_io(uploads._abort_upload_temp, upload_temp)
raise UnsafeUploadDestinationError(safe_filename) from exc raise UnsafeUploadDestinationError(safe_filename) from exc
except BaseException: except BaseException:
# BaseException, not Exception: a cancellation between the # BaseException, not Exception: a cancellation between the
# duplication and the commit would otherwise leave the descriptor # duplication and the commit would otherwise leave the descriptor
# open, and nothing downstream owns it yet. # open, and nothing downstream owns it yet.
_close_fd(convert_source_fd) await await_drained(abort())
convert_source_fd = None
if upload_temp is not None:
await run_file_io(uploads._abort_upload_temp, upload_temp)
raise raise
self._written_paths.append(file_path)
virtual_path = uploads.upload_virtual_path(safe_filename) virtual_path = uploads.upload_virtual_path(safe_filename)
if self._sync_to_sandbox: if self._sync_to_sandbox:
self._sync_targets.append((file_path, virtual_path)) self._sync_targets.append((file_path, virtual_path))
@ -340,8 +389,22 @@ class ThreadUploadIngestionService:
# the descriptor is already owned here — leaking it would also hold # the descriptor is already owned here — leaking it would also hold
# the unlinked staged bytes until the process exits. # the unlinked staged bytes until the process exits.
private_dir: Path | None = None private_dir: Path | None = None
try:
async def prepare_conversion() -> None:
nonlocal private_dir
private_dir = Path(await run_file_io(tempfile.mkdtemp, "-deerflow-convert")) private_dir = Path(await run_file_io(tempfile.mkdtemp, "-deerflow-convert"))
def cleanup_conversion() -> None:
# Release the pinned descriptor before removing either name
# on Windows, and cover allocation/publication cancellations.
cleanup_failed_atomic_write(md_staging, convert_source_fd)
if deferred_staged_path is not None:
uploads._remove_staged_file(deferred_staged_path)
if private_dir is not None:
shutil.rmtree(private_dir, ignore_errors=True)
try:
await await_drained(prepare_conversion())
conversion_source = private_dir / safe_filename conversion_source = private_dir / safe_filename
# Hand the descriptor over before the call: the copy closes it # Hand the descriptor over before the call: the copy closes it
# even when it fails, so this scope must not close it again and # even when it fails, so this scope must not close it again and
@ -354,36 +417,23 @@ class ThreadUploadIngestionService:
staged_fd, convert_source_fd = convert_source_fd, None staged_fd, convert_source_fd = convert_source_fd, None
await await_drained(run_file_io(_copy_fd_to_path, staged_fd, conversion_source)) await await_drained(run_file_io(_copy_fd_to_path, staged_fd, conversion_source))
md_staged = await uploads.convert_file_to_markdown(conversion_source, output_path=md_staging) md_staged = await uploads.convert_file_to_markdown(conversion_source, output_path=md_staging)
except Exception: if not md_staged:
self._seen_filenames.discard(unique_md_name)
else:
unique_md_name, md_path = await self._link_commit_with_retry(uploads, Path(md_staged), unique_md_name)
md_virtual_path = uploads.upload_virtual_path(md_path.name)
if self._sync_to_sandbox:
self._sync_targets.append((md_path, md_virtual_path))
file_info["markdown_file"] = md_path.name
self._companion_pairs.append((file_path, md_path))
file_info["markdown_path"] = str(self._uploads_dir / md_path.name)
file_info["markdown_virtual_path"] = md_virtual_path
file_info["markdown_artifact_url"] = uploads.upload_artifact_url(self._thread_id, md_path.name)
except BaseException:
self._seen_filenames.discard(unique_md_name) self._seen_filenames.discard(unique_md_name)
await run_file_io(md_staging.unlink, True)
raise raise
finally: finally:
_close_fd(convert_source_fd) await await_drained(run_file_io(cleanup_conversion))
if deferred_staged_path is not None:
# The descriptor that pinned the staged inode is released
# by now, so the deferred staged-name removal can land.
await run_file_io(uploads._remove_staged_file, deferred_staged_path)
if private_dir is not None:
await run_file_io(shutil.rmtree, private_dir, True)
if not md_staged:
# Conversion failed and wrote nothing (or a partial staged
# file, removed here): release the claim; holding it would
# rename a later same-stem upload against a name nothing
# occupies.
self._seen_filenames.discard(unique_md_name)
await run_file_io(md_staging.unlink, True)
else:
unique_md_name, md_path = await self._link_commit_with_retry(uploads, Path(md_staged), unique_md_name)
self._written_paths.append(md_path)
md_virtual_path = uploads.upload_virtual_path(md_path.name)
if self._sync_to_sandbox:
self._sync_targets.append((md_path, md_virtual_path))
file_info["markdown_file"] = md_path.name
self._companion_pairs.append((file_path, md_path))
file_info["markdown_path"] = str(self._uploads_dir / md_path.name)
file_info["markdown_virtual_path"] = md_virtual_path
file_info["markdown_artifact_url"] = uploads.upload_artifact_url(self._thread_id, md_path.name)
return file_info return file_info
async def finalize(self) -> None: async def finalize(self) -> None:
@ -394,6 +444,9 @@ class ThreadUploadIngestionService:
stay in place; the caller does not clean them up, matching ordinary stay in place; the caller does not clean them up, matching ordinary
upload behavior for sync-phase errors). upload behavior for sync-phase errors).
""" """
await await_drained(self._finalize())
async def _finalize(self) -> None:
uploads = _uploads() uploads = _uploads()
# Uploaded files are created with 0o600 permissions (owner read/write # Uploaded files are created with 0o600 permissions (owner read/write
# only). In Docker sandbox deployments the gateway writes as root but # only). In Docker sandbox deployments the gateway writes as root but
@ -413,12 +466,19 @@ class ThreadUploadIngestionService:
async def cleanup_written(self) -> None: async def cleanup_written(self) -> None:
"""Remove every file this session wrote (ordinary rejected-request cleanup).""" """Remove every file this session wrote (ordinary rejected-request cleanup)."""
await await_drained(self._cleanup_written())
async def _cleanup_written(self) -> None:
if not self._written_paths: if not self._written_paths:
return return
uploads = _uploads() uploads = _uploads()
await run_file_io(uploads._cleanup_uploaded_paths, self._written_paths) await run_file_io(uploads._cleanup_uploaded_paths, self._written_paths)
for original, _ in self._companion_pairs: for original, _ in self._companion_pairs:
await run_file_io(unregister_companion, original) try:
await run_file_io(unregister_companion, original)
except Exception:
with suppress(Exception):
logger.warning("Failed to remove upload companion record for %s", original, exc_info=True)
self._companion_pairs = [] self._companion_pairs = []
self._written_paths = [] self._written_paths = []
@ -427,10 +487,11 @@ class ThreadUploadIngestionService:
if self._sandbox_lease is None: if self._sandbox_lease is None:
return return
try: try:
await self._sandbox_lease.release() await await_drained(self._sandbox_lease.release())
except Exception: except Exception:
logger.warning( with suppress(Exception):
"Failed to release sandbox request lease after upload sync: %s", logger.warning(
self._sandbox_lease.sandbox_id, "Failed to release sandbox request lease after upload sync: %s",
exc_info=True, self._sandbox_lease.sandbox_id,
) exc_info=True,
)

View File

@ -24,6 +24,11 @@ POST /api/threads/{thread_id}/uploads
网关会在应用层限制上传规模,默认最多 10 个文件、单文件 50 MiB、单次请求总计 100 MiB。可通过 `config.yaml` 的 `uploads.max_files`、`uploads.max_file_size`、`uploads.max_total_size` 调整;前端会读取同一组限制并在选择文件时提示,超过限制时后端返回 `413 Payload Too Large`。 网关会在应用层限制上传规模,默认最多 10 个文件、单文件 50 MiB、单次请求总计 100 MiB。可通过 `config.yaml` 的 `uploads.max_files`、`uploads.max_file_size`、`uploads.max_total_size` 调整;前端会读取同一组限制并在选择文件时提示,超过限制时后端返回 `413 Payload Too Large`。
文件接收或转换期间取消请求,会清理本次请求已创建的文件,不删除其他请求的文件。
等待下一段上传数据时可直接取消;已经启动的文件操作或转换需要先完成并收回资源,
因此取消可能等待这些操作结束。开始最终权限设置和沙箱同步后,主机文件按现有
同步失败规则保留,重试前可先检查上传列表;同步完成或失败后才会释放请求的沙箱租约。
文件名匹配 `.upload-*.part`(例如 `.upload-notes.part`)时,网关会返回 `400 Bad Request`,提示改名后重新上传。这是系统保留的临时文件命名规则;文件名按去掉目录后的 basename 判断,HTTP 检查在 Linux 上也同时识别 `/` 和 `\` 两种路径分隔符,例如 `folder\.upload-notes.part`。网关会先检查整批文件,再开始写入聊天的上传目录或获取沙箱,因此保留名称排在批次末尾也不会留下部分上传。聊天界面收到该错误后会提示用户,并停止本次消息发送。`.upload-notes.txt`、`notes.part` 和 `.env` 仍可上传。 文件名匹配 `.upload-*.part`(例如 `.upload-notes.part`)时,网关会返回 `400 Bad Request`,提示改名后重新上传。这是系统保留的临时文件命名规则;文件名按去掉目录后的 basename 判断,HTTP 检查在 Linux 上也同时识别 `/` 和 `\` 两种路径分隔符,例如 `folder\.upload-notes.part`。网关会先检查整批文件,再开始写入聊天的上传目录或获取沙箱,因此保留名称排在批次末尾也不会留下部分上传。聊天界面收到该错误后会提示用户,并停止本次消息发送。`.upload-notes.txt`、`notes.part` 和 `.env` 仍可上传。
检查也会拒绝 Windows 去掉末尾点号或空格、忽略大小写后会变成保留名称的别名,例如 `.upload-notes.part.`、`.upload-notes.part `、`.UPLOAD-NOTES.PART` 和 `.Upload-NoTeS.Part .`。这项检查在所有主机上生效,HTTP 上传会在整批文件写入前返回 `400`,提示改名。 检查也会拒绝 Windows 去掉末尾点号或空格、忽略大小写后会变成保留名称的别名,例如 `.upload-notes.part.`、`.upload-notes.part `、`.UPLOAD-NOTES.PART` 和 `.Upload-NoTeS.Part .`。这项检查在所有主机上生效,HTTP 上传会在整批文件写入前返回 `400`,提示改名。

View File

@ -7,9 +7,11 @@ umask; replacements preserve existing permission bits before publication. Never
read the process umask by changing it in this threaded runtime. Behavioral tests read the process umask by changing it in this threaded runtime. Behavioral tests
for both tool imports live in `tests/test_browser_outputs_atomic.py`. for both tool imports live in `tests/test_browser_outputs_atomic.py`.
`cleanup_failed_atomic_write` shares best-effort cleanup across this publisher, `cleanup_failed_atomic_write` shares best-effort cleanup across this publisher,
`skills/projection.py`'s private manifest writer, and project document staging. `skills/projection.py`'s private manifest writer, project document staging, and
Gateway upload staging.
Use it on publication failure or after deciding to discard an owned Use it on publication failure or after deciding to discard an owned
temporary; pass a raw descriptor only until `fdopen` succeeds. Shelf discards temporary; pass a raw descriptor only until `fdopen` succeeds, or an explicitly
transferred duplicate descriptor owned by the cleanup caller. Shelf discards
must preserve both a rejected project's 404 and a dedup hit's 200 response. must preserve both a rejected project's 404 and a dedup hit's 200 response.
Callers retain their own serialization, permissions and publication policy. Callers retain their own serialization, permissions and publication policy.
Failure cleanup must preserve the original publication exception, including Failure cleanup must preserve the original publication exception, including

View File

@ -17,6 +17,7 @@ def cleanup_failed_atomic_write(temporary: Path, fd: int | None = None) -> None:
Pass *fd* only while the raw descriptor is still owned by the caller; Pass *fd* only while the raw descriptor is still owned by the caller;
successful ``fdopen`` transfers that ownership to the file object. successful ``fdopen`` transfers that ownership to the file object.
An explicitly transferred duplicate descriptor is also caller-owned.
Also use this to discard staging after a publication decision. Filesystem Also use this to discard staging after a publication decision. Filesystem
and logging-handler errors remain independent best-effort steps. and logging-handler errors remain independent best-effort steps.
""" """

View File

@ -0,0 +1,519 @@
"""Cancellation at the real upload consumer must not abandon filesystem owners."""
import asyncio
import hashlib
import shutil
import threading
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from _router_auth_helpers import call_unwrapped, make_authed_test_app
from httpx import ASGITransport, AsyncClient
from app.gateway import upload_ingestion
from app.gateway.routers import uploads
from deerflow.utils.file_io import run_file_io
class _Upload:
def __init__(self, name, payload):
self.filename = name
self._chunks = [payload, b""]
async def read(self, size):
return self._chunks.pop(0)
@pytest.mark.parametrize("stage", ["prepare", "write", "commit", "directory", "companion", "sync"])
@pytest.mark.parametrize("cancelled", [False, True])
def test_upload_worker_ownership_survives_request_cancellation(tmp_path, monkeypatch, stage, cancelled):
"""Pause native work at each ownership handoff; retain uncancelled controls."""
directory = tmp_path / "uploads"
directory.mkdir()
existing = directory / "keep.txt"
existing.write_bytes(b"another request")
remote = tmp_path / "remote"
remote.mkdir()
entered, release, settled = threading.Event(), threading.Event(), threading.Event()
temporaries, conversion_dirs, worker_errors = [], [], []
released_before_worker = []
convert = stage in {"directory", "companion"}
filename = "report.pdf" if convert else "report.txt"
provider = SimpleNamespace(uses_thread_data_mounts=stage != "sync")
monkeypatch.setattr(uploads, "ensure_uploads_dir", lambda *args, **kwargs: directory)
monkeypatch.setattr(uploads, "get_sandbox_provider", lambda: provider)
monkeypatch.setattr(uploads, "_auto_convert_documents_enabled", lambda _: convert)
def sync_file(name, content):
(remote / Path(name).name).write_bytes(content)
async def release_lease():
released_before_worker.append(entered.is_set() and not settled.is_set())
lease = SimpleNamespace(sandbox=SimpleNamespace(update_file=sync_file), sandbox_id="synthetic", denied=False, release=release_lease)
monkeypatch.setattr(uploads, "try_acquire_sandbox_for_request", AsyncMock(return_value=lease))
async def convert_file(source, output_path):
assert source.read_bytes() == b"owned bytes"
await run_file_io(output_path.write_text, "converted bytes", encoding="utf-8")
return output_path
monkeypatch.setattr(uploads, "convert_file_to_markdown", convert_file)
prepare = uploads._prepare_upload_destination
def remember_temp(*args, **kwargs):
temporary = prepare(*args, **kwargs)
temporaries.append(temporary)
return temporary
monkeypatch.setattr(uploads, "_prepare_upload_destination", remember_temp)
def pause(call, *, after=False):
def worker(*args, **kwargs):
try:
value = call(*args, **kwargs) if after else None
entered.set()
assert release.wait(10), "test did not release the native worker"
return value if after else call(*args, **kwargs)
except BaseException as error:
worker_errors.append(error)
raise
finally:
settled.set()
return worker
if stage == "prepare":
monkeypatch.setattr(uploads, "_prepare_upload_destination", pause(remember_temp, after=True))
elif stage == "write":
monkeypatch.setattr(uploads, "_write_upload_chunk", pause(uploads._write_upload_chunk))
elif stage == "commit":
# The native hard link already exists, but its returned owner has not
# reached the coroutine that records it for rejected-request cleanup.
monkeypatch.setattr(uploads, "_commit_upload_temp_no_overwrite", pause(uploads._commit_upload_temp_no_overwrite, after=True))
elif stage == "directory":
mkdtemp = upload_ingestion.tempfile.mkdtemp
def make_directory(*args, **kwargs):
path = Path(mkdtemp(*args, **kwargs, dir=tmp_path))
conversion_dirs.append(path)
return str(path)
monkeypatch.setattr(upload_ingestion.tempfile, "mkdtemp", pause(make_directory, after=True))
elif stage == "companion":
link = uploads._link_staged_no_overwrite
def publish(*args, **kwargs):
if args[2].endswith(".md"):
return pause(link, after=True)(*args, **kwargs)
return link(*args, **kwargs)
monkeypatch.setattr(uploads, "_link_staged_no_overwrite", publish)
else:
monkeypatch.setattr(uploads, "_sync_upload_to_sandbox", pause(uploads._sync_upload_to_sandbox))
async def scenario():
task = asyncio.create_task(call_unwrapped(uploads.upload_files, "thread-owner", request=MagicMock(), files=[_Upload(filename, b"owned bytes")], config=SimpleNamespace()))
try:
assert await asyncio.to_thread(entered.wait, 10), "owning upload did not reach the worker"
if cancelled:
task.cancel()
# Give both the owning coroutine and any shield/drain task
# their ready turns, then deliver a second host cancellation.
await asyncio.sleep(0)
await asyncio.sleep(0)
task.cancel()
await asyncio.sleep(0)
release.set()
if cancelled:
with pytest.raises(asyncio.CancelledError):
await task
else:
result = await task
assert result.success
assert result.files[0].filename == filename
assert await asyncio.to_thread(settled.wait, 10)
assert existing.read_bytes() == b"another request"
assert all(temporary.handle.closed for temporary in temporaries), "lost staged handle"
assert all(not temporary.temp_path.exists() for temporary in temporaries), "lost staged name"
assert all(not path.exists() for path in conversion_dirs), "lost private conversion directory"
assert worker_errors == [], "cleanup raced a still-running native worker"
assert not any(directory.glob(".upload-*.part")), "hidden staged companion was abandoned"
if cancelled and stage != "sync":
assert list(directory.iterdir()) == [existing], "cancelled ingestion left published files"
else:
assert (directory / filename).read_bytes() == b"owned bytes"
if convert:
assert (directory / "report.md").read_text() == "converted bytes"
if stage == "sync":
assert released_before_worker == [False], "sandbox lease released before sync worker ended"
assert (remote / filename).read_bytes() == b"owned bytes"
finally:
release.set()
await asyncio.gather(task, return_exceptions=True)
assert await asyncio.to_thread(settled.wait, 10)
# The red baseline intentionally leaks resources. Reclaim only
# the test's captures after all consumer-boundary assertions.
for temporary in temporaries:
if not temporary.handle.closed:
temporary.handle.close()
temporary.temp_path.unlink(missing_ok=True)
for path in conversion_dirs:
shutil.rmtree(path, ignore_errors=True)
asyncio.run(scenario())
@pytest.fixture
def local_uploads(tmp_path, monkeypatch):
directory = tmp_path / "uploads"
directory.mkdir()
provider = SimpleNamespace(uses_thread_data_mounts=True)
monkeypatch.setattr(uploads, "ensure_uploads_dir", lambda *args, **kwargs: directory)
monkeypatch.setattr(uploads, "get_sandbox_provider", lambda: provider)
monkeypatch.setattr(uploads, "_auto_convert_documents_enabled", lambda _: False)
return directory, provider
@pytest.mark.parametrize("stage", ["prepare", "commit"])
def test_multipart_http_cancellation_reclaims_native_files(local_uploads, monkeypatch, stage):
directory, _ = local_uploads
app = make_authed_test_app()
app.include_router(uploads.router)
app.dependency_overrides[uploads.get_config] = lambda: SimpleNamespace()
entered, release = threading.Event(), threading.Event()
captured = []
name = "_prepare_upload_destination" if stage == "prepare" else "_commit_upload_temp_no_overwrite"
native = getattr(uploads, name)
def pause_after_native(*args, **kwargs):
result = native(*args, **kwargs)
captured.append(result)
entered.set()
assert release.wait(10)
return result
monkeypatch.setattr(uploads, name, pause_after_native)
async def scenario():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
task = asyncio.create_task(client.post("/api/threads/http-owner/uploads", files={"files": ("payload.txt", b"http bytes")}))
try:
assert await asyncio.to_thread(entered.wait, 10)
task.cancel()
await asyncio.sleep(0)
release.set()
with pytest.raises(asyncio.CancelledError):
await task
assert list(directory.iterdir()) == []
if stage == "prepare":
assert captured[0].handle.closed
finally:
release.set()
await asyncio.gather(task, return_exceptions=True)
asyncio.run(scenario())
def test_waiting_for_network_chunks_is_cancellable_and_retry_is_clean(local_uploads):
directory, _ = local_uploads
survivor = directory / "keep.txt"
survivor.write_bytes(b"someone else's file")
async def scenario():
waiting, network = asyncio.Event(), asyncio.Event()
class PendingUpload:
filename = "second.txt"
async def read(self, size):
waiting.set()
await network.wait()
return b""
task = asyncio.create_task(call_unwrapped(uploads.upload_files, "network-owner", request=MagicMock(), files=[_Upload("first.txt", b"first bytes"), PendingUpload()], config=SimpleNamespace()))
try:
await asyncio.wait_for(waiting.wait(), 10)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await asyncio.wait_for(task, 2)
assert not network.is_set(), "cancellation must not wait for more request bytes"
assert list(directory.iterdir()) == [survivor]
retry = await call_unwrapped(uploads.upload_files, "network-owner", request=MagicMock(), files=[_Upload("first.txt", b"retry bytes")], config=SimpleNamespace())
assert retry.files[0].filename == "first.txt"
assert (directory / "first.txt").read_bytes() == b"retry bytes"
assert survivor.read_bytes() == b"someone else's file"
finally:
network.set()
await asyncio.gather(task, return_exceptions=True)
asyncio.run(scenario())
def test_cancelled_write_failure_preserves_cancellation_and_cleans_staging(local_uploads, monkeypatch):
directory, _ = local_uploads
entered, release, settled = threading.Event(), threading.Event(), threading.Event()
def fail_write(*args):
entered.set()
try:
assert release.wait(10)
raise OSError("synthetic disk write failure")
finally:
settled.set()
monkeypatch.setattr(uploads, "_write_upload_chunk", fail_write)
async def scenario():
task = asyncio.create_task(call_unwrapped(uploads.upload_files, "failed-owner", request=MagicMock(), files=[_Upload("fail.txt", b"bytes")], config=SimpleNamespace()))
try:
assert await asyncio.to_thread(entered.wait, 10)
task.cancel()
await asyncio.sleep(0)
task.cancel()
release.set()
with pytest.raises(asyncio.CancelledError):
await task
assert settled.is_set()
assert list(directory.iterdir()) == []
finally:
release.set()
await asyncio.gather(task, return_exceptions=True)
asyncio.run(scenario())
@pytest.mark.parametrize("failure", ["cancel", "missing_client"])
def test_context_entry_releases_a_late_or_unusable_lease(local_uploads, monkeypatch, failure):
_, provider = local_uploads
provider.uses_thread_data_mounts = False
async def scenario():
entered, release = asyncio.Event(), asyncio.Event()
holder = SimpleNamespace(sandbox=object() if failure == "cancel" else None, sandbox_id="synthetic", denied=False, release=AsyncMock())
async def acquire(*args, **kwargs):
entered.set()
await release.wait()
return holder
monkeypatch.setattr(uploads, "try_acquire_sandbox_for_request", acquire)
async def enter():
async with upload_ingestion.ThreadUploadIngestionService(request=None, thread_id="lease-owner", user_id="u", app_config=SimpleNamespace()):
pytest.fail("failed/cancelled entry must not yield")
task = asyncio.create_task(enter())
try:
await entered.wait()
if failure == "cancel":
task.cancel()
await asyncio.sleep(0)
task.cancel()
release.set()
if failure == "cancel":
with pytest.raises(asyncio.CancelledError):
await task
else:
with pytest.raises(uploads.HTTPException) as error:
await task
assert error.value.status_code == 500
holder.release.assert_awaited_once()
finally:
release.set()
await asyncio.gather(task, return_exceptions=True)
asyncio.run(scenario())
def test_request_close_drains_release_under_repeated_cancellation():
async def scenario():
entered, release, closed = asyncio.Event(), asyncio.Event(), asyncio.Event()
async def release_lease():
entered.set()
await release.wait()
closed.set()
service = upload_ingestion.ThreadUploadIngestionService(request=None, thread_id="close-owner", user_id="u", app_config=SimpleNamespace())
service._sandbox_lease = SimpleNamespace(release=release_lease)
task = asyncio.create_task(service.aclose())
try:
await entered.wait()
task.cancel()
await asyncio.sleep(0)
task.cancel()
release.set()
with pytest.raises(asyncio.CancelledError):
await task
assert closed.is_set(), "request holder escaped its release"
finally:
release.set()
await asyncio.gather(task, return_exceptions=True)
asyncio.run(scenario())
@pytest.mark.parametrize("failure", ["close", "staging_unlink", "published_unlink", "companion_unlink"])
@pytest.mark.parametrize("broken_logging", [False, True])
@pytest.mark.parametrize("outcome", ["cancel", "cancel_repeated", "too_large"])
def test_rejected_request_preserves_outcome_when_cleanup_fails(local_uploads, monkeypatch, failure, broken_logging, outcome):
"""Exercise cleanup failures through the real multi-file upload consumer."""
directory, _ = local_uploads
survivor = directory / "keep.txt"
survivor.write_bytes(b"another request")
monkeypatch.setattr(uploads, "_auto_convert_documents_enabled", lambda _: True)
monkeypatch.setattr(uploads, "_get_upload_limits", lambda _: SimpleNamespace(max_files=10, max_file_size=20, max_total_size=100))
captured, record_attempts, diagnostics = [], [], []
cleanup_entered, cleanup_release = threading.Event(), threading.Event()
prepare = uploads._prepare_upload_destination
native_unlink, native_path_unlink = uploads.os.unlink, Path.unlink
first_record = hashlib.sha256(b"first.pdf").hexdigest() + ".json"
second_record = hashlib.sha256(b"second.pdf").hexdigest() + ".json"
def fail_cleanup(message):
if outcome == "cancel_repeated":
cleanup_entered.set()
assert cleanup_release.wait(10), "test did not release the cleanup worker"
raise OSError(message)
class FailingClose:
def __init__(self, handle):
self.handle = handle
def __getattr__(self, name):
return getattr(self.handle, name)
def close(self):
self.handle.close()
fail_cleanup("synthetic staged close/flush failure")
def capture_pending(directory, name):
staged = prepare(directory, name)
if name == "pending.txt":
captured.append(staged)
if failure == "close":
staged.handle = FailingClose(staged.handle)
return staged
def unlink(path, *args, **kwargs):
if failure == "staging_unlink" and captured and Path(path) == captured[0].temp_path:
fail_cleanup("synthetic staging unlink failure")
if failure == "published_unlink" and Path(path) == directory / "second.pdf":
fail_cleanup("synthetic published unlink failure")
return native_unlink(path, *args, **kwargs)
def unlink_record(path, *args, **kwargs):
if path.parent == directory.parent / "upload-companions":
record_attempts.append(path.name)
if failure == "companion_unlink" and path.name == first_record:
fail_cleanup("synthetic companion unlink failure")
return native_path_unlink(path, *args, **kwargs)
def warn(message, *args, **kwargs):
diagnostics.append(message)
if broken_logging:
raise RuntimeError("synthetic logging handler failure")
async def convert(source, output_path):
await run_file_io(output_path.write_bytes, b"converted bytes")
return output_path
monkeypatch.setattr(uploads, "_prepare_upload_destination", capture_pending)
monkeypatch.setattr(uploads, "convert_file_to_markdown", convert)
monkeypatch.setattr(uploads.os, "unlink", unlink)
monkeypatch.setattr(Path, "unlink", unlink_record)
monkeypatch.setattr(uploads.logger, "warning", warn)
monkeypatch.setattr(upload_ingestion.logger, "warning", warn)
error_log = MagicMock()
monkeypatch.setattr(uploads.logger, "error", error_log)
async def scenario():
waiting, network = asyncio.Event(), asyncio.Event()
class PendingUpload:
filename = "pending.txt"
async def read(self, size):
waiting.set()
if outcome == "too_large":
return b"x" * 30
await network.wait()
return b""
files = [_Upload("first.pdf", b"first"), _Upload("second.pdf", b"second"), PendingUpload()]
task = asyncio.create_task(call_unwrapped(uploads.upload_files, "cleanup-owner", request=MagicMock(), files=files, config=SimpleNamespace()))
try:
await asyncio.wait_for(waiting.wait(), 10)
if outcome != "too_large":
task.cancel("client disconnected")
if outcome == "cancel_repeated":
assert await asyncio.to_thread(cleanup_entered.wait, 10)
task.cancel("host shutdown")
await asyncio.sleep(0)
task.cancel("repeated host shutdown")
cleanup_release.set()
with pytest.raises(asyncio.CancelledError):
await task
assert task.cancelled()
else:
with pytest.raises(uploads.HTTPException) as error:
await task
assert error.value.status_code == 413
error_log.assert_not_called()
assert diagnostics, "cleanup failure was not reported"
assert record_attempts == [first_record, second_record], "one failed cleanup skipped the remaining companion"
assert captured[0].handle.closed
expected = {survivor}
if failure == "staging_unlink":
expected.add(captured[0].temp_path)
if failure == "published_unlink":
expected.add(directory / "second.pdf")
assert set(directory.iterdir()) == expected
assert survivor.read_bytes() == b"another request"
finally:
cleanup_release.set()
network.set()
await asyncio.gather(task, return_exceptions=True)
for staged in captured:
if not staged.handle.closed:
staged.handle.close()
if staged.temp_path.exists():
native_unlink(staged.temp_path)
asyncio.run(scenario())
def test_failed_lease_release_logging_preserves_request_cancellation(local_uploads, monkeypatch):
directory, provider = local_uploads
provider.uses_thread_data_mounts = False
lease = SimpleNamespace(sandbox=object(), sandbox_id="synthetic", denied=False, release=AsyncMock(side_effect=OSError("synthetic lease release failure")))
monkeypatch.setattr(uploads, "try_acquire_sandbox_for_request", AsyncMock(return_value=lease))
monkeypatch.setattr(upload_ingestion.logger, "warning", MagicMock(side_effect=RuntimeError("synthetic logging handler failure")))
async def scenario():
waiting, network = asyncio.Event(), asyncio.Event()
class PendingUpload:
filename = "pending.txt"
async def read(self, size):
waiting.set()
await network.wait()
return b""
task = asyncio.create_task(call_unwrapped(uploads.upload_files, "lease-owner", request=MagicMock(), files=[PendingUpload()], config=SimpleNamespace()))
try:
await asyncio.wait_for(waiting.wait(), 10)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
lease.release.assert_awaited_once()
assert list(directory.iterdir()) == []
finally:
network.set()
await asyncio.gather(task, return_exceptions=True)
asyncio.run(scenario())