From f4dc0deedbae67a94b0ab86b6a0e748249f26d83 Mon Sep 17 00:00:00 2001 From: availov <51930102+availov@users.noreply.github.com> Date: Mon, 6 Apr 2026 23:24:33 +0300 Subject: [PATCH 1/3] Fix spurious "Future exception was never retrieved" warning on disconnect during backpressure (#12307) --- CHANGES/12281.bugfix.rst | 1 + CONTRIBUTORS.txt | 1 + aiohttp/base_protocol.py | 2 +- tests/test_web_server.py | 53 ++++++++++++++++++++++++++++++++++++++-- 4 files changed, 54 insertions(+), 3 deletions(-) create mode 100644 CHANGES/12281.bugfix.rst diff --git a/CHANGES/12281.bugfix.rst b/CHANGES/12281.bugfix.rst new file mode 100644 index 00000000000..63521a73b1c --- /dev/null +++ b/CHANGES/12281.bugfix.rst @@ -0,0 +1 @@ +Fixed spurious ``Future exception was never retrieved`` warning on disconnect during back-pressure -- by :user:`availov`. diff --git a/CONTRIBUTORS.txt b/CONTRIBUTORS.txt index 4a3934e7df7..e61c5e8e328 100644 --- a/CONTRIBUTORS.txt +++ b/CONTRIBUTORS.txt @@ -420,6 +420,7 @@ Yegor Roganov Yifei Kong Young-Ho Cha Yuriy Shatrov +Yury Novikov Yury Pliner Yury Selivanov Yusuke Tsutsumi diff --git a/aiohttp/base_protocol.py b/aiohttp/base_protocol.py index 7f01830f4e9..d7d83425b88 100644 --- a/aiohttp/base_protocol.py +++ b/aiohttp/base_protocol.py @@ -97,4 +97,4 @@ async def _drain_helper(self) -> None: if waiter is None: waiter = self._loop.create_future() self._drain_waiter = waiter - await asyncio.shield(waiter) + await waiter diff --git a/tests/test_web_server.py b/tests/test_web_server.py index 2cd364e0317..63f27f01b25 100644 --- a/tests/test_web_server.py +++ b/tests/test_web_server.py @@ -1,14 +1,15 @@ import asyncio +import gc import socket from contextlib import suppress -from typing import NoReturn +from typing import Any, NoReturn from unittest import mock import pytest from aiohttp import client, web from aiohttp.http_exceptions import BadHttpMethod, BadStatusLine -from aiohttp.pytest_plugin import AiohttpClient, AiohttpRawServer +from aiohttp.pytest_plugin import AiohttpClient, AiohttpRawServer, AiohttpServer async def test_simple_server( @@ -454,3 +455,51 @@ async def on_request(request: web.Request) -> web.Response: assert done_event.is_set() finally: await asyncio.gather(runner.shutdown(), site.stop()) + + +async def test_no_future_warning_on_disconnect_during_backpressure( + aiohttp_server: AiohttpServer, +) -> None: + loop = asyncio.get_running_loop() + exc_handler_calls: list[dict[str, Any]] = [] + original_handler = loop.get_exception_handler() + loop.set_exception_handler(lambda _loop, ctx: exc_handler_calls.append(ctx)) + protocol = None + + async def handler(request: web.Request) -> NoReturn: + nonlocal protocol + protocol = request.protocol + resp = web.StreamResponse() + await resp.prepare(request) + while True: + await resp.write(b"x" * 65536) + + app = web.Application() + app.router.add_route("GET", "/", handler) + # aiohttp_server enables handler_cancellation by default so the handler + # task is cancelled when connection_lost() fires. + server = await aiohttp_server(app) + + # Open a raw asyncio connection so we control exactly when the client + # side closes. + reader, writer = await asyncio.open_connection(server.host, server.port) + writer.write(b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n") + await writer.drain() + + try: + # Poll until the server protocol reports that writing is paused. + async def wait_for_backpressure() -> None: + while protocol is None or not protocol.writing_paused: + await asyncio.sleep(0.01) + + await asyncio.wait_for(wait_for_backpressure(), timeout=5.0) + + writer.close() + await asyncio.sleep(0.1) + + gc.collect() + await asyncio.sleep(0) + finally: + loop.set_exception_handler(original_handler) + + assert not exc_handler_calls From 522439ace1c695c5b73d4c27f2d67bba37277fa7 Mon Sep 17 00:00:00 2001 From: bahtyar <34988899+Bahtya@users.noreply.github.com> Date: Tue, 7 Apr 2026 05:55:47 +0800 Subject: [PATCH 2/3] Fix GunicornWebWorker failing to reset SIGCHLD handler (#12328) --- aiohttp/worker.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/aiohttp/worker.py b/aiohttp/worker.py index cfe876f1f2c..05d516061b1 100644 --- a/aiohttp/worker.py +++ b/aiohttp/worker.py @@ -179,8 +179,12 @@ def init_signals(self) -> None: # by interrupting system calls signal.siginterrupt(signal.SIGTERM, False) signal.siginterrupt(signal.SIGUSR1, False) - # Reset signals so Gunicorn doesn't swallow subprocess return codes - # See: https://github.com/aio-libs/aiohttp/issues/6130 + + # Reset SIGCHLD to default so Gunicorn doesn't swallow subprocess + # return codes. Without this, workers inherit the master arbiter's + # SIGCHLD handler, causing spurious "Worker exited" errors when + # application code spawns subprocesses. + signal.signal(signal.SIGCHLD, signal.SIG_DFL) def handle_quit(self, sig: int, frame: FrameType | None) -> None: self.alive = False From 47558a30c88e31cc3b5ff3b57f6127e7984c80bc Mon Sep 17 00:00:00 2001 From: digiscrypt Date: Tue, 7 Apr 2026 04:28:39 +0530 Subject: [PATCH 3/3] Harden cookie file permissions in CookieJar.save() (#12312) --- CHANGES/12312.bugfix.rst | 1 + aiohttp/cookiejar.py | 12 +++++++++++- tests/test_cookiejar.py | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 CHANGES/12312.bugfix.rst diff --git a/CHANGES/12312.bugfix.rst b/CHANGES/12312.bugfix.rst new file mode 100644 index 00000000000..a7d240ad79c --- /dev/null +++ b/CHANGES/12312.bugfix.rst @@ -0,0 +1 @@ +``Cookiejar.save()`` now uses ``0x600`` permissions to better protect them from being read by other users -- by :user:`digiscrypt`. diff --git a/aiohttp/cookiejar.py b/aiohttp/cookiejar.py index 5f5a5ada199..89c35c176e6 100644 --- a/aiohttp/cookiejar.py +++ b/aiohttp/cookiejar.py @@ -4,6 +4,7 @@ import heapq import itertools import json +import os import pathlib import re import time @@ -137,7 +138,16 @@ def save(self, file_path: PathLike) -> None: if attr_val: morsel_data[attr] = attr_val data[key][name] = morsel_data - with file_path.open(mode="w", encoding="utf-8") as f: + + # Cookie persistence may include authentication/session tokens. + # Use 0o600 at creation time to avoid umask-dependent overexposure + # and enforce least-privilege access to sensitive credential data. + with open( + file_path, + mode="w", + encoding="utf-8", + opener=lambda path, flags: os.open(path, flags, 0o600), + ) as f: json.dump(data, f, indent=2) def load(self, file_path: PathLike) -> None: diff --git a/tests/test_cookiejar.py b/tests/test_cookiejar.py index 9620794c682..6f1e30d9cfd 100644 --- a/tests/test_cookiejar.py +++ b/tests/test_cookiejar.py @@ -2,6 +2,8 @@ import heapq import itertools import logging +import os +import stat from http.cookies import BaseCookie, Morsel, SimpleCookie from operator import not_ from pathlib import Path @@ -1624,6 +1626,40 @@ def test_save_load_json_secure_cookies(tmp_path: Path) -> None: assert cookie["domain"] == "example.com" +@pytest.mark.skipif( + os.name != "posix", reason="POSIX permission bits are required for this test" +) +def test_save_creates_private_cookie_file(tmp_path: Path) -> None: + file_path = tmp_path / "private-cookies.json" + jar = CookieJar() + jar.update_cookies_from_headers( + ["token=abc123; Path=/"], URL("https://example.com/") + ) + + jar.save(file_path=file_path) + + assert file_path.exists() + assert stat.S_IMODE(file_path.stat().st_mode) == 0o600 + + +@pytest.mark.skipif( + os.name != "posix", reason="POSIX permission bits are required for this test" +) +def test_save_preserves_existing_cookie_file_permissions(tmp_path: Path) -> None: + file_path = tmp_path / "existing-cookies.json" + file_path.write_text("{}", encoding="utf-8") + file_path.chmod(0o644) + + jar = CookieJar() + jar.update_cookies_from_headers( + ["token=abc123; Path=/"], URL("https://example.com/") + ) + + jar.save(file_path=file_path) + + assert stat.S_IMODE(file_path.stat().st_mode) == 0o644 + + async def test_cookie_jar_unsafe_property() -> None: jar_safe = CookieJar() assert jar_safe.unsafe is False