Skip to content

Commit bcae561

Browse files
[PR #10113/01302134 backport][3.11] Restore 304 performance after fixing FileResponse replace race (#10117)
1 parent 78473b9 commit bcae561

File tree

2 files changed

+92
-72
lines changed

2 files changed

+92
-72
lines changed

CHANGES/10113.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
10101.bugfix.rst

aiohttp/web_fileresponse.py

Lines changed: 91 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import pathlib
55
import sys
66
from contextlib import suppress
7+
from enum import Enum, auto
78
from mimetypes import MimeTypes
89
from stat import S_ISREG
910
from types import MappingProxyType
@@ -69,6 +70,16 @@
6970
}
7071
)
7172

73+
74+
class _FileResponseResult(Enum):
75+
"""The result of the file response."""
76+
77+
SEND_FILE = auto() # Ie a regular file to send
78+
NOT_ACCEPTABLE = auto() # Ie a socket, or non-regular file
79+
PRE_CONDITION_FAILED = auto() # Ie If-Match or If-None-Match failed
80+
NOT_MODIFIED = auto() # 304 Not Modified
81+
82+
7283
# Add custom pairs and clear the encodings map so guess_type ignores them.
7384
CONTENT_TYPES.encodings_map.clear()
7485
for content_type, extension in ADDITIONAL_CONTENT_TYPES.items():
@@ -166,17 +177,65 @@ async def _precondition_failed(
166177
self.content_length = 0
167178
return await super().prepare(request)
168179

169-
def _open_file_path_stat_encoding(
170-
self, accept_encoding: str
171-
) -> Tuple[Optional[io.BufferedReader], os.stat_result, Optional[str]]:
172-
"""Return the io object, stat result, and encoding.
180+
def _make_response(
181+
self, request: "BaseRequest", accept_encoding: str
182+
) -> Tuple[
183+
_FileResponseResult, Optional[io.BufferedReader], os.stat_result, Optional[str]
184+
]:
185+
"""Return the response result, io object, stat result, and encoding.
173186
174187
If an uncompressed file is returned, the encoding is set to
175188
:py:data:`None`.
176189
177190
This method should be called from a thread executor
178191
since it calls os.stat which may block.
179192
"""
193+
file_path, st, file_encoding = self._get_file_path_stat_encoding(
194+
accept_encoding
195+
)
196+
if not file_path:
197+
return _FileResponseResult.NOT_ACCEPTABLE, None, st, None
198+
199+
etag_value = f"{st.st_mtime_ns:x}-{st.st_size:x}"
200+
201+
# https://www.rfc-editor.org/rfc/rfc9110#section-13.1.1-2
202+
if (ifmatch := request.if_match) is not None and not self._etag_match(
203+
etag_value, ifmatch, weak=False
204+
):
205+
return _FileResponseResult.PRE_CONDITION_FAILED, None, st, file_encoding
206+
207+
if (
208+
(unmodsince := request.if_unmodified_since) is not None
209+
and ifmatch is None
210+
and st.st_mtime > unmodsince.timestamp()
211+
):
212+
return _FileResponseResult.PRE_CONDITION_FAILED, None, st, file_encoding
213+
214+
# https://www.rfc-editor.org/rfc/rfc9110#section-13.1.2-2
215+
if (ifnonematch := request.if_none_match) is not None and self._etag_match(
216+
etag_value, ifnonematch, weak=True
217+
):
218+
return _FileResponseResult.NOT_MODIFIED, None, st, file_encoding
219+
220+
if (
221+
(modsince := request.if_modified_since) is not None
222+
and ifnonematch is None
223+
and st.st_mtime <= modsince.timestamp()
224+
):
225+
return _FileResponseResult.NOT_MODIFIED, None, st, file_encoding
226+
227+
fobj = file_path.open("rb")
228+
with suppress(OSError):
229+
# fstat() may not be available on all platforms
230+
# Once we open the file, we want the fstat() to ensure
231+
# the file has not changed between the first stat()
232+
# and the open().
233+
st = os.stat(fobj.fileno())
234+
return _FileResponseResult.SEND_FILE, fobj, st, file_encoding
235+
236+
def _get_file_path_stat_encoding(
237+
self, accept_encoding: str
238+
) -> Tuple[Optional[pathlib.Path], os.stat_result, Optional[str]]:
180239
file_path = self._path
181240
for file_extension, file_encoding in ENCODING_EXTENSIONS.items():
182241
if file_encoding not in accept_encoding:
@@ -187,36 +246,22 @@ def _open_file_path_stat_encoding(
187246
# Do not follow symlinks and ignore any non-regular files.
188247
st = compressed_path.lstat()
189248
if S_ISREG(st.st_mode):
190-
fobj = compressed_path.open("rb")
191-
with suppress(OSError):
192-
# fstat() may not be available on all platforms
193-
# Once we open the file, we want the fstat() to ensure
194-
# the file has not changed between the first stat()
195-
# and the open().
196-
st = os.stat(fobj.fileno())
197-
return fobj, st, file_encoding
249+
return compressed_path, st, file_encoding
198250

199251
# Fallback to the uncompressed file
200252
st = file_path.stat()
201253
if not S_ISREG(st.st_mode):
202254
return None, st, None
203-
fobj = file_path.open("rb")
204-
with suppress(OSError):
205-
# fstat() may not be available on all platforms
206-
# Once we open the file, we want the fstat() to ensure
207-
# the file has not changed between the first stat()
208-
# and the open().
209-
st = os.stat(fobj.fileno())
210-
return fobj, st, None
255+
return file_path, st, None
211256

212257
async def prepare(self, request: "BaseRequest") -> Optional[AbstractStreamWriter]:
213258
loop = asyncio.get_running_loop()
214259
# Encoding comparisons should be case-insensitive
215260
# https://www.rfc-editor.org/rfc/rfc9110#section-8.4.1
216261
accept_encoding = request.headers.get(hdrs.ACCEPT_ENCODING, "").lower()
217262
try:
218-
fobj, st, file_encoding = await loop.run_in_executor(
219-
None, self._open_file_path_stat_encoding, accept_encoding
263+
response_result, fobj, st, file_encoding = await loop.run_in_executor(
264+
None, self._make_response, request, accept_encoding
220265
)
221266
except PermissionError:
222267
self.set_status(HTTPForbidden.status_code)
@@ -227,24 +272,32 @@ async def prepare(self, request: "BaseRequest") -> Optional[AbstractStreamWriter
227272
self.set_status(HTTPNotFound.status_code)
228273
return await super().prepare(request)
229274

230-
try:
231-
# Forbid special files like sockets, pipes, devices, etc.
232-
if not fobj or not S_ISREG(st.st_mode):
233-
self.set_status(HTTPForbidden.status_code)
234-
return await super().prepare(request)
275+
# Forbid special files like sockets, pipes, devices, etc.
276+
if response_result is _FileResponseResult.NOT_ACCEPTABLE:
277+
self.set_status(HTTPForbidden.status_code)
278+
return await super().prepare(request)
279+
280+
if response_result is _FileResponseResult.PRE_CONDITION_FAILED:
281+
return await self._precondition_failed(request)
235282

283+
if response_result is _FileResponseResult.NOT_MODIFIED:
284+
etag_value = f"{st.st_mtime_ns:x}-{st.st_size:x}"
285+
last_modified = st.st_mtime
286+
return await self._not_modified(request, etag_value, last_modified)
287+
288+
assert fobj is not None
289+
try:
236290
return await self._prepare_open_file(request, fobj, st, file_encoding)
237291
finally:
238-
if fobj:
239-
# We do not await here because we do not want to wait
240-
# for the executor to finish before returning the response
241-
# so the connection can begin servicing another request
242-
# as soon as possible.
243-
close_future = loop.run_in_executor(None, fobj.close)
244-
# Hold a strong reference to the future to prevent it from being
245-
# garbage collected before it completes.
246-
_CLOSE_FUTURES.add(close_future)
247-
close_future.add_done_callback(_CLOSE_FUTURES.remove)
292+
# We do not await here because we do not want to wait
293+
# for the executor to finish before returning the response
294+
# so the connection can begin servicing another request
295+
# as soon as possible.
296+
close_future = loop.run_in_executor(None, fobj.close)
297+
# Hold a strong reference to the future to prevent it from being
298+
# garbage collected before it completes.
299+
_CLOSE_FUTURES.add(close_future)
300+
close_future.add_done_callback(_CLOSE_FUTURES.remove)
248301

249302
async def _prepare_open_file(
250303
self,
@@ -253,43 +306,9 @@ async def _prepare_open_file(
253306
st: os.stat_result,
254307
file_encoding: Optional[str],
255308
) -> Optional[AbstractStreamWriter]:
256-
etag_value = f"{st.st_mtime_ns:x}-{st.st_size:x}"
257-
last_modified = st.st_mtime
258-
259-
# https://www.rfc-editor.org/rfc/rfc9110#section-13.1.1-2
260-
ifmatch = request.if_match
261-
if ifmatch is not None and not self._etag_match(
262-
etag_value, ifmatch, weak=False
263-
):
264-
return await self._precondition_failed(request)
265-
266-
unmodsince = request.if_unmodified_since
267-
if (
268-
unmodsince is not None
269-
and ifmatch is None
270-
and st.st_mtime > unmodsince.timestamp()
271-
):
272-
return await self._precondition_failed(request)
273-
274-
# https://www.rfc-editor.org/rfc/rfc9110#section-13.1.2-2
275-
ifnonematch = request.if_none_match
276-
if ifnonematch is not None and self._etag_match(
277-
etag_value, ifnonematch, weak=True
278-
):
279-
return await self._not_modified(request, etag_value, last_modified)
280-
281-
modsince = request.if_modified_since
282-
if (
283-
modsince is not None
284-
and ifnonematch is None
285-
and st.st_mtime <= modsince.timestamp()
286-
):
287-
return await self._not_modified(request, etag_value, last_modified)
288-
289309
status = self._status
290310
file_size = st.st_size
291311
count = file_size
292-
293312
start = None
294313

295314
ifrange = request.if_range
@@ -378,7 +397,7 @@ async def _prepare_open_file(
378397
# compress.
379398
self._compression = False
380399

381-
self.etag = etag_value # type: ignore[assignment]
400+
self.etag = f"{st.st_mtime_ns:x}-{st.st_size:x}" # type: ignore[assignment]
382401
self.last_modified = st.st_mtime # type: ignore[assignment]
383402
self.content_length = count
384403

0 commit comments

Comments
 (0)