From 1fb3f57e81239a75eb8f873b392e11534c041fdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=A0=EC=9A=B0=EC=A7=84?= Date: Tue, 8 Apr 2025 16:20:37 +0900 Subject: [PATCH] Fixed #36281 -- Used async-safe write in ASGIHandler.read_body(). Thanks Carlton Gibson for reviews. --- django/core/handlers/asgi.py | 11 ++++++- tests/asgi/tests.py | 63 ++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/django/core/handlers/asgi.py b/django/core/handlers/asgi.py index b57bec90d0..0d305c9a87 100644 --- a/django/core/handlers/asgi.py +++ b/django/core/handlers/asgi.py @@ -263,7 +263,16 @@ class ASGIHandler(base.BaseHandler): raise RequestAborted() # Add a body chunk from the message, if provided. if "body" in message: - body_file.write(message["body"]) + on_disk = getattr(body_file, "_rolled", False) + if on_disk: + async_write = sync_to_async( + body_file.write, + thread_sensitive=False, + ) + await async_write(message["body"]) + else: + body_file.write(message["body"]) + # Quit out if that's the end. if not message.get("more_body", False): break diff --git a/tests/asgi/tests.py b/tests/asgi/tests.py index 0b1d3cd608..94ec5dc560 100644 --- a/tests/asgi/tests.py +++ b/tests/asgi/tests.py @@ -1,8 +1,10 @@ import asyncio import sys +import tempfile import threading import time from pathlib import Path +from unittest.mock import patch from asgiref.sync import sync_to_async from asgiref.testing import ApplicationCommunicator @@ -659,3 +661,64 @@ class ASGITest(SimpleTestCase): # 'last\n' isn't sent. with self.assertRaises(asyncio.TimeoutError): await communicator.receive_output(timeout=0.2) + + async def test_read_body_thread(self): + """Write runs on correct thread depending on rollover.""" + handler = ASGIHandler() + loop_thread = threading.current_thread() + + called_threads = [] + + def write_wrapper(data): + called_threads.append(threading.current_thread()) + return original_write(data) + + # In-memory write (no rollover expected). + in_memory_chunks = [ + {"type": "http.request", "body": b"small", "more_body": False} + ] + + async def receive(): + return in_memory_chunks.pop(0) + + with tempfile.SpooledTemporaryFile(max_size=1024, mode="w+b") as temp_file: + original_write = temp_file.write + with ( + patch( + "django.core.handlers.asgi.tempfile.SpooledTemporaryFile", + return_value=temp_file, + ), + patch.object(temp_file, "write", side_effect=write_wrapper), + ): + await handler.read_body(receive) + # Write was called in the event loop thread. + self.assertIn(loop_thread, called_threads) + + # Clear thread log before next test. + called_threads.clear() + + # Rollover to disk (write should occur in a threadpool thread). + rolled_chunks = [ + {"type": "http.request", "body": b"A" * 16, "more_body": True}, + {"type": "http.request", "body": b"B" * 16, "more_body": False}, + ] + + async def receive_rolled(): + return rolled_chunks.pop(0) + + with ( + override_settings(FILE_UPLOAD_MAX_MEMORY_SIZE=10), + tempfile.SpooledTemporaryFile(max_size=10, mode="w+b") as temp_file, + ): + original_write = temp_file.write + # roll_over force in handlers. + with ( + patch( + "django.core.handlers.asgi.tempfile.SpooledTemporaryFile", + return_value=temp_file, + ), + patch.object(temp_file, "write", side_effect=write_wrapper), + ): + await handler.read_body(receive_rolled) + # The second write should have rolled over to disk. + self.assertTrue(any(t != loop_thread for t in called_threads))