Skip to content

Stop file transfers when HTTP clients disconnect - #3523

Open
Kludex wants to merge 3 commits into
mainfrom
codex/file-response-disconnect
Open

Stop file transfers when HTTP clients disconnect#3523
Kludex wants to merge 3 commits into
mainfrom
codex/file-response-disconnect

Conversation

@Kludex

@Kludex Kludex commented Sep 6, 2026

Copy link
Copy Markdown
Owner

FileResponse can continue reading an entire file after the client disconnects on ASGI versions before 2.4. Run its existing file handler alongside an http.disconnect listener and shield file closure from cancellation, so aborted full-file and range transfers stop and close the file before background cleanup.

Alternative to #3390, following the focused approach in libratechw's proposal: preserve StreamingResponse, handler signatures, and the direct HEAD, pathsend, WebSocket denial, and ASGI 2.4+ paths.

Validation: scripts/test (1,295 passed, 100% coverage), scripts/build, and the four existing file-response benchmark cases passed; regression tests cover both asyncio and Trio, and fail against both the original implementation and an unshielded aclosing() variant.

Review in cubic

AI Disclaimer

This PR was developed with the assistance of either Claude or Codex. I've reviewed and verified the changes.

@Kludex
Kludex deployed to cloudflare September 6, 2026 15:30 — with GitHub Actions Active
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 6, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-06T15:32:28.378546Z 9277bec PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

@codspeed-hq

codspeed-hq Bot commented Sep 6, 2026

Copy link
Copy Markdown

Merging this PR will improve performance by 16.23%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 1 improved benchmark
✅ 241 untouched benchmarks

Performance Changes

Mode Benchmark BASE HEAD Efficiency
Simulation test_multipart[boundary-like-file] 9.2 ms 7.9 ms +16.23%

Tip

Curious why performance improved? Comment @codspeedbot explain why performance improved on this PR, or directly use the CodSpeed MCP with your agent.


Comparing codex/file-response-disconnect (0a781cb) with main (3ebcaf3)

Open in CodSpeed

Comment thread starlette/responses.py
Comment on lines +408 to +416
@asynccontextmanager
async def _open_file(self) -> AsyncIterator[anyio.AsyncFile[bytes]]:
file = await anyio.open_file(self.path, mode="rb")
try:
yield file
finally:
# Closing must finish even when the transfer is cancelled.
with anyio.CancelScope(shield=True):
await file.aclose()

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@agronholm Isn't there something builtin in anyio that I can use instead of my own wrapper?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This particular issue was fixed in AnyIO v4.15.0.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you sure? My coding agent seems to think only TemporaryDirectory was fixed. 🤔

@agronholm agronholm Sep 6, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right. Somehow I was under the impression that it was a broader fix for open files, but it's not. The same issue is present in AsyncFile too. I'll get this fixed for the next patch release.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Kludex
Kludex deployed to cloudflare September 6, 2026 15:42 — with GitHub Actions Active

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread benchmarks/file_response_benchmark.py Outdated
Comment thread benchmarks/file_response_benchmark.py Outdated
@Kludex
Kludex deployed to cloudflare September 6, 2026 15:43 — with GitHub Actions Active

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 3 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="benchmarks/file_response_benchmark.py">

<violation number="1">
P3: This change removes the `spec_version` parametrization (`["2.3", "2.5"]`), so the file-response benchmarks now only measure the ASGI 2.5 direct path. The ASGI <2.4 disconnect-listener path added by this PR (a concurrent task group with a blocking `receive()` listener per request) is no longer benchmarked, so a regression in that new hot path would go undetected.</violation>
</file>

<file name="tests/test_responses.py">

<violation number="1" location="tests/test_responses.py:463">
P3: The upper bound `submitted < 3 * FileResponse.chunk_size` is a race heuristic: how many chunks are sent before the disconnect listener cancels `stream_file` depends on event-loop scheduling. On a slower scheduler or under Trio's checkpointing, more chunks could be delivered before the cancel lands, making this assertion flaky. The meaningful invariant is that the transfer stops after the disconnect is observed, not a specific chunk count.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread tests/test_responses.py
with anyio.fail_after(5):
await FileResponse(path, background=BackgroundTask(cleanup))(scope, receive, send)

assert FileResponse.chunk_size <= submitted < 3 * FileResponse.chunk_size

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The upper bound submitted < 3 * FileResponse.chunk_size is a race heuristic: how many chunks are sent before the disconnect listener cancels stream_file depends on event-loop scheduling. On a slower scheduler or under Trio's checkpointing, more chunks could be delivered before the cancel lands, making this assertion flaky. The meaningful invariant is that the transfer stops after the disconnect is observed, not a specific chunk count.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tests/test_responses.py, line 463:

<comment>The upper bound `submitted < 3 * FileResponse.chunk_size` is a race heuristic: how many chunks are sent before the disconnect listener cancels `stream_file` depends on event-loop scheduling. On a slower scheduler or under Trio's checkpointing, more chunks could be delivered before the cancel lands, making this assertion flaky. The meaningful invariant is that the transfer stops after the disconnect is observed, not a specific chunk count.</comment>

<file context>
@@ -395,6 +396,130 @@ async def send(message: Message) -> None:
+    with anyio.fail_after(5):
+        await FileResponse(path, background=BackgroundTask(cleanup))(scope, receive, send)
+
+    assert FileResponse.chunk_size <= submitted < 3 * FileResponse.chunk_size
+    assert background_ran
+
</file context>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants