Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package python-awscrt for openSUSE:Factory checked in at 2026-04-09 17:48:21 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/python-awscrt (Old) and /work/SRC/openSUSE:Factory/.python-awscrt.new.21863 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-awscrt" Thu Apr 9 17:48:21 2026 rev:4 rq:1345527 version:0.32.0 Changes: -------- --- /work/SRC/openSUSE:Factory/python-awscrt/python-awscrt.changes 2026-03-17 19:05:07.722241081 +0100 +++ /work/SRC/openSUSE:Factory/.python-awscrt.new.21863/python-awscrt.changes 2026-04-09 17:48:23.226336175 +0200 @@ -1,0 +2,8 @@ +Thu Apr 9 11:53:10 UTC 2026 - John Paul Adrian Glaubitz <[email protected]> + +- Update to version 0.32.0 + * Support free-thread build by @TingDaoK in (#725) + * Add support for notifying end of remote stream by @TingDaoK in (#727) +- Refresh skip-test-requiring-network.patch and add more tests to skip + +------------------------------------------------------------------- Old: ---- awscrt-0.31.3.tar.gz New: ---- awscrt-0.32.0.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ python-awscrt.spec ++++++ --- /var/tmp/diff_new_pack.lIjOeY/_old 2026-04-09 17:48:24.086371934 +0200 +++ /var/tmp/diff_new_pack.lIjOeY/_new 2026-04-09 17:48:24.086371934 +0200 @@ -18,16 +18,14 @@ %{?sle15_python_module_pythons} Name: python-awscrt -Version: 0.31.3 +Version: 0.32.0 Release: 0 Summary: A common runtime for AWS Python projects License: Apache-2.0 URL: https://github.com/awslabs/aws-crt-python Source: %{url}/archive/v%{version}.tar.gz#/awscrt-%{version}.tar.gz - # PATCH-FIX-OPENSUSE skip-test-requiring-network.patch [email protected] -- one test requires internet connection, skip it Patch: skip-test-requiring-network.patch - BuildRequires: python-rpm-macros BuildRequires: cmake(aws-c-auth) BuildRequires: cmake(aws-c-cal) ++++++ awscrt-0.31.3.tar.gz -> awscrt-0.32.0.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/aws-crt-python-0.31.3/.github/workflows/ci.yml new/aws-crt-python-0.32.0/.github/workflows/ci.yml --- old/aws-crt-python-0.31.3/.github/workflows/ci.yml 2026-03-06 22:06:47.000000000 +0100 +++ new/aws-crt-python-0.32.0/.github/workflows/ci.yml 2026-03-27 00:11:43.000000000 +0100 @@ -56,6 +56,9 @@ - cp311-cp311 - cp312-cp312 - cp313-cp313 + - cp313-cp313t + - cp314-cp314 + - cp314-cp314t permissions: id-token: write # This is required for requesting the JWT steps: @@ -81,6 +84,9 @@ - cp311-cp311 - cp312-cp312 - cp313-cp313 + - cp313-cp313t + - cp314-cp314 + - cp314-cp314t permissions: id-token: write # This is required for requesting the JWT steps: @@ -108,6 +114,7 @@ - cp311-cp311 - cp312-cp312 - cp313-cp313 + - cp313-cp313t permissions: id-token: write # This is required for requesting the JWT steps: @@ -133,6 +140,7 @@ - cp311-cp311 - cp312-cp312 - cp313-cp313 + - cp313-cp313t permissions: id-token: write # This is required for requesting the JWT steps: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/aws-crt-python-0.31.3/awscrt/aio/http.py new/aws-crt-python-0.32.0/awscrt/aio/http.py --- old/aws-crt-python-0.31.3/awscrt/aio/http.py 2026-03-06 22:06:47.000000000 +0100 +++ new/aws-crt-python-0.32.0/awscrt/aio/http.py 2026-03-27 00:11:43.000000000 +0100 @@ -22,6 +22,7 @@ from io import BytesIO from concurrent.futures import Future from typing import List, Tuple, Optional, Callable, AsyncIterator +import threading class AIOHttpClientConnectionUnified(HttpClientConnectionBase): @@ -328,13 +329,15 @@ '_completion_future', '_stream_completed', '_status_code', - '_loop') + '_loop', + '_deque_lock') def __init__(self, connection: AIOHttpClientConnection, request: HttpRequest, request_body_generator: AsyncIterator[bytes] = None, loop: Optional[asyncio.AbstractEventLoop] = None) -> None: + # Initialize the parent class http2_manual_write = request_body_generator is not None and connection.version is HttpVersion.Http2 super()._init_common(connection, request, http2_manual_write=http2_manual_write) @@ -347,14 +350,15 @@ raise TypeError("loop must be an instance of asyncio.AbstractEventLoop") self._loop = loop - # deque is thread-safe for appending and popping, so that we don't need - # locks to handle the callbacks from the C thread + # Lock to protect check-then-act sequences on deques for thread safety in free-threaded Python + self._deque_lock = threading.Lock() self._chunk_futures = deque() self._received_chunks = deque() self._stream_completed = False # Create futures for async operations self._completion_future = Future() + self._remote_completion_future = Future() self._response_status_future = Future() self._response_headers_future = Future() self._status_code = None @@ -373,12 +377,31 @@ self._response_headers_future.set_result(name_value_pairs) def _on_body(self, chunk: bytes) -> None: - """Process body chunk on the correct event loop thread.""" - if self._chunk_futures: - future = self._chunk_futures.popleft() - future.set_result(chunk) - else: - self._received_chunks.append(chunk) + """Process body chunk - called from C thread.""" + with self._deque_lock: + if self._chunk_futures: + future = self._chunk_futures.popleft() + else: + self._received_chunks.append(chunk) + return + + # Set result outside lock (Future is thread-safe) + future.set_result(chunk) + + def _resolve_pending_chunk_futures(self) -> None: + """Helper to resolve all pending chunk futures with empty bytes. + + This indicates end of stream to any waiting get_next_response_chunk() calls. + Must be called when either the stream completes or remote peer sends END_STREAM. + """ + # Resolve all pending chunk futures with lock protection + with self._deque_lock: + pending_futures = list(self._chunk_futures) + self._chunk_futures.clear() + + # Set results outside lock (Future is thread-safe) + for future in pending_futures: + future.set_result(b"") def _on_complete(self, error_code: int) -> None: """Set the completion status of the stream.""" @@ -387,10 +410,12 @@ else: self._completion_future.set_exception(awscrt.exceptions.from_code(error_code)) - # Resolve all pending chunk futures with an empty string to indicate end of stream - while self._chunk_futures: - future = self._chunk_futures.popleft() - future.set_result("") + self._resolve_pending_chunk_futures() + + def _on_h2_remote_end_stream(self) -> None: + """Called when the remote peer has finished sending (HTTP/2 only).""" + self._remote_completion_future.set_result(None) + self._resolve_pending_chunk_futures() async def _set_request_body_generator(self, body_iterator: AsyncIterator[bytes]): ... @@ -418,14 +443,17 @@ bytes: The next chunk of data from the response body. Returns empty bytes when the stream is completed and no more chunks are left. """ - if self._received_chunks: - return self._received_chunks.popleft() - elif self._completion_future.done(): - return b"" - else: - future = Future() - self._chunk_futures.append(future) - return await asyncio.wrap_future(future, loop=self._loop) + with self._deque_lock: + if self._received_chunks: + return self._received_chunks.popleft() + elif self._completion_future.done() or self._remote_completion_future.done(): + return b"" + else: + future = Future() + self._chunk_futures.append(future) + + # Await outside lock + return await asyncio.wrap_future(future, loop=self._loop) async def wait_for_completion(self) -> int: """Wait asynchronously for the stream to complete. diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/aws-crt-python-0.31.3/awscrt/http.py new/aws-crt-python-0.32.0/awscrt/http.py --- old/aws-crt-python-0.31.3/awscrt/http.py 2026-03-06 22:06:47.000000000 +0100 +++ new/aws-crt-python-0.32.0/awscrt/http.py 2026-03-27 00:11:43.000000000 +0100 @@ -569,6 +569,14 @@ else: self._completion_future.set_exception(awscrt.exceptions.from_code(error_code)) + def _on_h2_remote_end_stream(self) -> None: + """Called when remote peer sends END_STREAM (HTTP/2 only). + + This callback is only invoked for HTTP/2 connections. HTTP/1.x streams + will never receive this callback. Base implementation does nothing. + """ + pass + def update_window(self, increment_size: int) -> None: """ Update the stream's flow control window. @@ -613,14 +621,57 @@ class Http2ClientStream(HttpClientStreamBase): + __slots__ = ('_remote_end_stream_future',) + def __init__(self, connection: HttpClientConnection, request: 'HttpRequest', on_response: Optional[Callable[..., None]] = None, on_body: Optional[Callable[..., None]] = None, manual_write: bool = False) -> None: + self._remote_end_stream_future = Future() self._init_common(connection, request, on_response, on_body, manual_write) + @property + def remote_end_stream_future(self) -> "concurrent.futures.Future": + """ + concurrent.futures.Future: Future that completes when the remote peer has finished + sending (HTTP/2 only). This occurs when the server sends an END_STREAM flag. + + The future will contain a result of None on success, or an exception if the stream + encounters an error before END_STREAM is received (e.g., RST_STREAM). + + This is different from `completion_future` which completes when both the + client and server have finished (bidirectional stream closure). + + Note: This future only applies to HTTP/2 connections. It will complete when the + server sends END_STREAM, which may occur before the client finishes sending. + In case of stream completed without END_STREAM received, this future will complete + with exception. + """ + return self._remote_end_stream_future + + def _on_h2_remote_end_stream(self) -> None: + """Internal callback when remote peer sends END_STREAM (HTTP/2 only).""" + if not self._remote_end_stream_future.done(): + self._remote_end_stream_future.set_result(None) + + def _on_complete(self, error_code: int) -> None: + # done with HttpRequest, drop reference + self._request = None # type: ignore + + # Ensure remote_completion_future is always resolved + if not self._remote_end_stream_future.done(): + # Stream completed successfully but END_STREAM was never received, + # complete `remote_completion_future` with exception. + self._remote_end_stream_future.set_exception( + RuntimeError("Stream completed without receiving remote END_STREAM")) + + if error_code == 0: + self._completion_future.set_result(self._response_status_code) + else: + self._completion_future.set_exception(awscrt.exceptions.from_code(error_code)) + def activate(self) -> None: """Begin sending the request. diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/aws-crt-python-0.31.3/continuous-delivery/build-wheels-manylinux2014-aarch64.sh new/aws-crt-python-0.32.0/continuous-delivery/build-wheels-manylinux2014-aarch64.sh --- old/aws-crt-python-0.31.3/continuous-delivery/build-wheels-manylinux2014-aarch64.sh 2026-03-06 22:06:47.000000000 +0100 +++ new/aws-crt-python-0.32.0/continuous-delivery/build-wheels-manylinux2014-aarch64.sh 2026-03-27 00:11:43.000000000 +0100 @@ -22,6 +22,12 @@ /opt/python/cp313-cp313/bin/python -m build auditwheel repair --plat manylinux2014_aarch64 dist/awscrt-*cp313*.whl +# The free-threaded build does not currently support the Limited C API or the stable ABI. Built them separately +/opt/python/cp313-cp313t/bin/python -m build +auditwheel repair --plat manylinux2014_aarch64 dist/awscrt-*cp313t*.whl +/opt/python/cp314-cp314t/bin/python -m build +auditwheel repair --plat manylinux2014_aarch64 dist/awscrt-*cp314t*.whl + rm dist/*.whl cp -rv wheelhouse/* dist/ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/aws-crt-python-0.31.3/continuous-delivery/build-wheels-manylinux2014-x86_64.sh new/aws-crt-python-0.32.0/continuous-delivery/build-wheels-manylinux2014-x86_64.sh --- old/aws-crt-python-0.31.3/continuous-delivery/build-wheels-manylinux2014-x86_64.sh 2026-03-06 22:06:47.000000000 +0100 +++ new/aws-crt-python-0.32.0/continuous-delivery/build-wheels-manylinux2014-x86_64.sh 2026-03-27 00:11:43.000000000 +0100 @@ -22,6 +22,12 @@ /opt/python/cp313-cp313/bin/python -m build auditwheel repair --plat manylinux2014_x86_64 dist/awscrt-*cp313*.whl +# The free-threaded build does not currently support the Limited C API or the stable ABI. Built them separately +/opt/python/cp313-cp313t/bin/python -m build +auditwheel repair --plat manylinux2014_x86_64 dist/awscrt-*cp313t*.whl +/opt/python/cp314-cp314t/bin/python -m build +auditwheel repair --plat manylinux2014_x86_64 dist/awscrt-*cp314t*.whl + rm dist/*.whl cp -rv wheelhouse/* dist/ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/aws-crt-python-0.31.3/continuous-delivery/build-wheels-musllinux-1-1-aarch64.sh new/aws-crt-python-0.32.0/continuous-delivery/build-wheels-musllinux-1-1-aarch64.sh --- old/aws-crt-python-0.31.3/continuous-delivery/build-wheels-musllinux-1-1-aarch64.sh 2026-03-06 22:06:47.000000000 +0100 +++ new/aws-crt-python-0.32.0/continuous-delivery/build-wheels-musllinux-1-1-aarch64.sh 2026-03-27 00:11:43.000000000 +0100 @@ -22,6 +22,11 @@ /opt/python/cp313-cp313/bin/python -m build auditwheel repair --plat musllinux_1_1_aarch64 dist/awscrt-*cp313*.whl +# The free-threaded build does not currently support the Limited C API or the stable ABI. Built them separately +/opt/python/cp313-cp313t/bin/python -m build +auditwheel repair --plat musllinux_1_1_aarch64 dist/awscrt-*cp313t*.whl +# Musllinux-1-1 is EOL without python>3.13 + rm dist/*.whl cp -rv wheelhouse/* dist/ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/aws-crt-python-0.31.3/continuous-delivery/build-wheels-musllinux-1-1-x86_64.sh new/aws-crt-python-0.32.0/continuous-delivery/build-wheels-musllinux-1-1-x86_64.sh --- old/aws-crt-python-0.31.3/continuous-delivery/build-wheels-musllinux-1-1-x86_64.sh 2026-03-06 22:06:47.000000000 +0100 +++ new/aws-crt-python-0.32.0/continuous-delivery/build-wheels-musllinux-1-1-x86_64.sh 2026-03-27 00:11:43.000000000 +0100 @@ -22,6 +22,11 @@ /opt/python/cp313-cp313/bin/python -m build auditwheel repair --plat musllinux_1_1_x86_64 dist/awscrt-*cp313*.whl +# The free-threaded build does not currently support the Limited C API or the stable ABI. Built them separately +/opt/python/cp313-cp313t/bin/python -m build +auditwheel repair --plat musllinux_1_1_x86_64 dist/awscrt-*cp313t*.whl +# Musllinux-1-1 is EOL without python>3.13 + rm dist/*.whl cp -rv wheelhouse/* dist/ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/aws-crt-python-0.31.3/continuous-delivery/build-wheels-osx.sh new/aws-crt-python-0.32.0/continuous-delivery/build-wheels-osx.sh --- old/aws-crt-python-0.31.3/continuous-delivery/build-wheels-osx.sh 2026-03-06 22:06:47.000000000 +0100 +++ new/aws-crt-python-0.32.0/continuous-delivery/build-wheels-osx.sh 2026-03-27 00:11:43.000000000 +0100 @@ -15,4 +15,8 @@ # We are using the Python 3.13 stable ABI from Python 3.13 onwards because of deprecated functions. /Library/Frameworks/Python.framework/Versions/3.13/bin/python3 -m build +# The free-threaded build does not currently support the Limited C API or the stable ABI. Built them separately +/Library/Frameworks/PythonT.framework/Versions/3.13/bin/python3.13t -m build +/Library/Frameworks/PythonT.framework/Versions/3.14/bin/python3.14t -m build + #now you just need to run twine (that's in a different script) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/aws-crt-python-0.31.3/continuous-delivery/build-wheels-win32.bat new/aws-crt-python-0.32.0/continuous-delivery/build-wheels-win32.bat --- old/aws-crt-python-0.31.3/continuous-delivery/build-wheels-win32.bat 2026-03-06 22:06:47.000000000 +0100 +++ new/aws-crt-python-0.32.0/continuous-delivery/build-wheels-win32.bat 2026-03-27 00:11:43.000000000 +0100 @@ -11,6 +11,10 @@ :: We are using the 3.13 stable ABI from 3.13 onwards because of deprecated functions. "C:\Program Files (x86)\Python313-32\python.exe" -m build || goto error +:: The free-threaded build does not currently support the Limited C API or the stable ABI. Built them separately +"C:\Program Files (x86)\Python313-32\python3.13t.exe" -m build || goto error +"C:\Program Files (x86)\Python314-32\python3.14t.exe" -m build || goto error + goto :EOF :error diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/aws-crt-python-0.31.3/continuous-delivery/build-wheels-win64.bat new/aws-crt-python-0.32.0/continuous-delivery/build-wheels-win64.bat --- old/aws-crt-python-0.31.3/continuous-delivery/build-wheels-win64.bat 2026-03-06 22:06:47.000000000 +0100 +++ new/aws-crt-python-0.32.0/continuous-delivery/build-wheels-win64.bat 2026-03-27 00:11:43.000000000 +0100 @@ -10,6 +10,10 @@ :: We are using the 3.13 stable ABI from 3.13 onwards because of deprecated functions. "C:\Program Files\Python313\python.exe" -m build || goto error +:: The free-threaded build does not currently support the Limited C API or the stable ABI. Built them separately +"C:\Program Files\Python-313\python3.13t.exe" -m build || goto error +"C:\Program Files\Python314\python3.14t.exe" -m build || goto error + goto :EOF :error diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/aws-crt-python-0.31.3/setup.py new/aws-crt-python-0.32.0/setup.py --- old/aws-crt-python-0.31.3/setup.py 2026-03-06 22:06:47.000000000 +0100 +++ new/aws-crt-python-0.32.0/setup.py 2026-03-27 00:11:43.000000000 +0100 @@ -25,6 +25,9 @@ # sysconfig.get_config_var('MACOSX_DEPLOYMENT_TARGET'). MACOS_DEPLOYMENT_TARGET_MIN = "10.15" +# True if this is a free-threaded Python build. +FREE_THREADED_BUILD = sysconfig.get_config_var("Py_GIL_DISABLED") == 1 + # This is the minimum version of the Windows SDK needed for schannel.h with SCH_CREDENTIALS and # TLS_PARAMETERS. These are required to build Windows Binaries with TLS 1.3 support. WINDOWS_SDK_VERSION_TLS1_3_SUPPORT = "10.0.17763.0" @@ -428,7 +431,10 @@ def get_tag(self): python, abi, plat = super().get_tag() # on CPython, our wheels are abi3 and compatible back to 3.11 - if python.startswith("cp") and sys.version_info >= (3, 13): + if FREE_THREADED_BUILD: + # free-threaded builds don't use limited API, so skip abi3 tag + return python, abi, plat + elif python.startswith("cp") and sys.version_info >= (3, 13): # 3.13 deprecates PyWeakref_GetObject(), adds alternative return "cp313", "abi3", plat elif python.startswith("cp") and sys.version_info >= (3, 11): @@ -532,7 +538,11 @@ extra_link_args += ['-Wl,--fatal-warnings'] # prefer building with stable ABI, so a wheel can work with multiple major versions - if sys.version_info >= (3, 13): + if FREE_THREADED_BUILD and sys.version_info[:2] <= (3, 14): + # 3.14 free threaded (aka no gil) does not support limited api. + # disable it for now. 3.15 promises to support limited api + free threading combo + py_limited_api = False + elif sys.version_info >= (3, 13): # 3.13 deprecates PyWeakref_GetObject(), adds alternative define_macros.append(('Py_LIMITED_API', '0x030D0000')) py_limited_api = True diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/aws-crt-python-0.31.3/source/cbor.c new/aws-crt-python-0.32.0/source/cbor.c --- old/aws-crt-python-0.31.3/source/cbor.c 2026-03-06 22:06:47.000000000 +0100 +++ new/aws-crt-python-0.32.0/source/cbor.c 2026-03-27 00:11:43.000000000 +0100 @@ -160,6 +160,8 @@ PyObject *value = NULL; Py_ssize_t pos = 0; + /* Accessing the pydict without lock, since the cbor_decoder and encoder are not thread-safe. It's user's + * responsibility to not modifying the map from another thread. */ while (PyDict_Next(py_dict, &pos, &key, &value)) { PyObject *key_result = s_cbor_encoder_write_pyobject(encoder, key); if (!key_result) { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/aws-crt-python-0.31.3/source/http_connection.c new/aws-crt-python-0.32.0/source/http_connection.c --- old/aws-crt-python-0.31.3/source/http_connection.c 2026-03-06 22:06:47.000000000 +0100 +++ new/aws-crt-python-0.32.0/source/http_connection.c 2026-03-27 00:11:43.000000000 +0100 @@ -152,10 +152,17 @@ } *out_settings = aws_mem_calloc(allocator, py_list_size, sizeof(struct aws_http2_setting)); + PyObject *setting_py = NULL; + bool strong_ref = false; for (Py_ssize_t i = 0; i < py_list_size; i++) { - PyObject *setting_py = PyList_GetItem(initial_settings_py, i); +#ifdef Py_GIL_DISABLED + setting_py = PyList_GetItemRef(initial_settings_py, i); + strong_ref = true; +#else + setting_py = PyList_GetItem(initial_settings_py, i); // Borrowed Reference +#endif /* Get id attribute */ enum aws_http2_settings_id id = PyObject_GetAttrAsIntEnum(setting_py, "Http2Setting", "id"); if (PyErr_Occurred()) { @@ -169,11 +176,17 @@ } (*out_settings)[i].id = id; (*out_settings)[i].value = value; + if (strong_ref) { + Py_DECREF(setting_py); + } } *out_size = (size_t)py_list_size; return AWS_OP_SUCCESS; error: + if (setting_py && strong_ref) { + Py_DECREF(setting_py); + } *out_size = 0; aws_mem_release(allocator, *out_settings); *out_settings = NULL; diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/aws-crt-python-0.31.3/source/http_stream.c new/aws-crt-python-0.32.0/source/http_stream.c --- old/aws-crt-python-0.31.3/source/http_stream.c 2026-03-06 22:06:47.000000000 +0100 +++ new/aws-crt-python-0.32.0/source/http_stream.c 2026-03-27 00:11:43.000000000 +0100 @@ -213,6 +213,24 @@ /*************** GIL RELEASE ***************/ } +static void s_on_h2_remote_end_stream(struct aws_http_stream *native_stream, void *user_data) { + (void)native_stream; + struct http_stream_binding *stream = user_data; + + /*************** GIL ACQUIRE ***************/ + PyGILState_STATE state; + if (aws_py_gilstate_ensure(&state)) { + return; /* Python has shut down. Nothing matters anymore, but don't crash */ + } + + PyObject *result = PyObject_CallMethod(stream->self_proxy, "_on_h2_remote_end_stream", "()"); + if (result) { + Py_DECREF(result); + } + PyGILState_Release(state); + /*************** GIL RELEASE ***************/ +} + static void s_stream_capsule_destructor(PyObject *http_stream_capsule) { struct http_stream_binding *stream = PyCapsule_GetPointer(http_stream_capsule, s_capsule_name_http_stream); @@ -283,6 +301,7 @@ .on_response_header_block_done = s_on_incoming_header_block_done, .on_response_body = s_on_incoming_body, .on_complete = s_on_stream_complete, + .on_h2_remote_end_stream = s_on_h2_remote_end_stream, .user_data = stream, .http2_use_manual_data_writes = http2_manual_write, }; diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/aws-crt-python-0.31.3/source/module.c new/aws-crt-python-0.32.0/source/module.c --- old/aws-crt-python-0.31.3/source/module.c 2026-03-06 22:06:47.000000000 +0100 +++ new/aws-crt-python-0.32.0/source/module.c 2026-03-27 00:11:43.000000000 +0100 @@ -619,7 +619,13 @@ } int aws_py_gilstate_ensure(PyGILState_STATE *out_state) { + /* If Python >= 3.13 */ +#if PY_VERSION_HEX >= 0x030D0000 + // Py_IsFinalizing is part of the Stable ABI since version 3.13. + if (AWS_LIKELY(!Py_IsFinalizing())) { +#else if (AWS_LIKELY(Py_IsInitialized())) { +#endif *out_state = PyGILState_Ensure(); return AWS_OP_SUCCESS; } @@ -1023,6 +1029,10 @@ return NULL; } +#ifdef Py_GIL_DISABLED + PyUnstable_Module_SetGIL(m, Py_MOD_GIL_NOT_USED); +#endif + s_init_allocator(); /* Don't report this memory when dumping possible leaks. */ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/aws-crt-python-0.31.3/source/s3_client.c new/aws-crt-python-0.32.0/source/s3_client.c --- old/aws-crt-python-0.31.3/source/s3_client.c 2026-03-06 22:06:47.000000000 +0100 +++ new/aws-crt-python-0.32.0/source/s3_client.c 2026-03-27 00:11:43.000000000 +0100 @@ -365,15 +365,27 @@ network_interface_names = aws_mem_calloc(allocator, num_network_interface_names, sizeof(struct aws_byte_cursor)); for (size_t i = 0; i < num_network_interface_names; ++i) { - PyObject *str_obj = PyList_GetItem(network_interface_names_py, i); /* Borrowed reference */ + bool strong_ref = false; +#ifdef Py_GIL_DISABLED + PyObject *str_obj = PyList_GetItemRef(network_interface_names_py, i); + strong_ref = true; +#else + PyObject *str_obj = PyList_GetItem(network_interface_names_py, i); // Borrowed Reference +#endif if (!str_obj) { goto cleanup; } network_interface_names[i] = aws_byte_cursor_from_pyunicode(str_obj); if (network_interface_names[i].ptr == NULL) { + if (strong_ref) { + Py_DECREF(str_obj); + } PyErr_SetString(PyExc_TypeError, "Expected all network_interface_names elements to be strings."); goto cleanup; } + if (strong_ref) { + Py_DECREF(str_obj); + } } } struct aws_s3_file_io_options fio_opts = { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/aws-crt-python-0.31.3/test/test_aiohttp_client.py new/aws-crt-python-0.32.0/test/test_aiohttp_client.py --- old/aws-crt-python-0.31.3/test/test_aiohttp_client.py 2026-03-06 22:06:47.000000000 +0100 +++ new/aws-crt-python-0.32.0/test/test_aiohttp_client.py 2026-03-27 00:11:43.000000000 +0100 @@ -62,11 +62,13 @@ self.end_headers() -class TestAsyncClient(NativeResourceTest): +class AsyncLocalServerTestBase(NativeResourceTest): + """Base class for async tests that use local HTTP/1.x server""" hostname = 'localhost' timeout = 5 # seconds def _start_server(self, secure, http_1_0=False): + """Start local HTTP server""" # HTTP/1.0 closes the connection at the end of each request # HTTP/1.1 will keep the connection alive if http_1_0: @@ -89,10 +91,14 @@ self.server_thread.start() def _stop_server(self): + """Stop local HTTP server""" self.server.shutdown() self.server.server_close() self.server_thread.join() + +class TestAsyncClient(AsyncLocalServerTestBase): + async def _new_client_connection(self, secure, proxy_options=None): if secure: tls_ctx_opt = TlsContextOptions() @@ -473,8 +479,8 @@ asyncio.run(self._test_cross_thread_http2_client()) [email protected](os.environ.get('AWS_TEST_LOCALHOST'), 'set env var to run test: AWS_TEST_LOCALHOST') -class TestAsyncClientMockServer(NativeResourceTest): +class AsyncMockServerTestBase(NativeResourceTest): + """Base class for async tests that use the H2 mock server""" timeout = 5 # seconds p_server = None mock_server_url = None @@ -499,7 +505,6 @@ def _wait_for_server_ready(self): """Wait until server is accepting connections.""" max_attempts = 20 - for attempt in range(max_attempts): try: with socket.create_connection(("127.0.0.1", self.mock_server_url.port), timeout=1): @@ -520,6 +525,47 @@ self.p_server.kill() super().tearDown() + async def _new_mock_h2_connection( + self, + manual_window_management=False, + initial_window_size=None, + initial_settings=None): + """Create HTTP/2 async client connection to local mock server""" + event_loop_group = EventLoopGroup() + host_resolver = DefaultHostResolver(event_loop_group) + bootstrap = ClientBootstrap(event_loop_group, host_resolver) + + port = self.mock_server_url.port + if port is None: + port = 443 + + tls_ctx_options = TlsContextOptions() + tls_ctx_options.verify_peer = False + tls_ctx = ClientTlsContext(tls_ctx_options) + tls_conn_opt = tls_ctx.new_connection_options() + tls_conn_opt.set_server_name(self.mock_server_url.hostname) + tls_conn_opt.set_alpn_list(["h2"]) + + if initial_settings is None: + initial_settings = [Http2Setting(Http2SettingID.ENABLE_PUSH, 0)] + + kwargs = { + 'host_name': self.mock_server_url.hostname, + 'port': port, + 'bootstrap': bootstrap, + 'tls_connection_options': tls_conn_opt, + 'initial_settings': initial_settings, + 'manual_window_management': manual_window_management + } + if initial_window_size is not None: + kwargs['initial_window_size'] = initial_window_size + + return await AIOHttp2ClientConnection.new(**kwargs) + + [email protected](os.environ.get('AWS_TEST_LOCALHOST'), 'set env var to run test: AWS_TEST_LOCALHOST') +class TestAsyncClientMockServer(AsyncMockServerTestBase): + def _on_remote_settings_changed(self, settings): # The mock server has the default settings with # ENABLE_PUSH = 0 @@ -659,91 +705,193 @@ asyncio.run(self._test_h2_mock_server_settings()) -class AIOFlowControlTest(NativeResourceTest): +class AIOFlowControlTest(AsyncLocalServerTestBase): + """HTTP/1.1 async flow control tests using local server""" timeout = 10.0 + async def _new_h1_client_connection( + self, + secure, + manual_window_management=False, + initial_window_size=None, + read_buffer_capacity=None): + """Create HTTP/1.1 async client connection to local server""" + if secure: + tls_ctx_opt = TlsContextOptions() + tls_ctx_opt.verify_peer = False + tls_ctx = ClientTlsContext(tls_ctx_opt) + tls_conn_opt = tls_ctx.new_connection_options() + tls_conn_opt.set_server_name(self.hostname) + else: + tls_conn_opt = None + + event_loop_group = EventLoopGroup() + host_resolver = DefaultHostResolver(event_loop_group) + bootstrap = ClientBootstrap(event_loop_group, host_resolver) + + kwargs = { + 'host_name': self.hostname, + 'port': self.port, + 'bootstrap': bootstrap, + 'tls_connection_options': tls_conn_opt, + 'manual_window_management': manual_window_management + } + if initial_window_size is not None: + kwargs['initial_window_size'] = initial_window_size + if read_buffer_capacity is not None: + kwargs['read_buffer_capacity'] = read_buffer_capacity + + return await AIOHttpClientConnection.new(**kwargs) + async def _test_h1_manual_window_management_happy_path(self): """Test HTTP/1.1 manual window management happy path""" - tls_ctx_opt = TlsContextOptions() - tls_ctx_opt.verify_peer = False - tls_ctx_opt.alpn_list = ['http/1.1'] - tls_ctx = ClientTlsContext(tls_ctx_opt) - tls_options = tls_ctx.new_connection_options() - tls_options.set_server_name("httpbin.org") + self._start_server(secure=True) + try: + connection = await self._new_h1_client_connection( + secure=True, + manual_window_management=True, + initial_window_size=5, + read_buffer_capacity=1000 + ) - connection = await AIOHttpClientConnection.new( - host_name="httpbin.org", - port=443, - tls_connection_options=tls_options, - manual_window_management=True, - initial_window_size=5, - read_buffer_capacity=1000 - ) + # Create a file with known size for testing + test_data = b'0123456789' # 10 bytes + test_file_path = 'test_aio_flow_control_data.txt' + with open(test_file_path, 'wb') as f: + f.write(test_data) - request = HttpRequest('GET', '/bytes/10') - request.headers.add('host', 'httpbin.org') - stream = connection.request(request) + try: + request = HttpRequest('GET', '/' + test_file_path) + request.headers.add('host', self.hostname) + stream = connection.request(request) - response = Response() - status_code = await response.collect_response(stream) + chunks_received = [] + body = bytearray() - self.assertEqual(200, status_code) - self.assertEqual(10, len(response.body)) - await connection.close() + await stream.get_response_status_code() + while True: + chunk = await stream.get_next_response_chunk() + if not chunk: + break + chunks_received.append(len(chunk)) + body.extend(chunk) + stream.update_window(len(chunk)) + + self.assertEqual(test_data, bytes(body)) + self.assertGreater(len(chunks_received), 0, "No data chunks received") + + await connection.close() + finally: + # Clean up test file + if os.path.exists(test_file_path): + os.remove(test_file_path) + finally: + self._stop_server() def test_h1_manual_window_management_happy_path(self): asyncio.run(self._test_h1_manual_window_management_happy_path()) + async def _test_h1_stream_flow_control_blocks_and_resumes(self): + """Test that HTTP/1.1 stream flow control actually blocks and resumes""" + self._start_server(secure=True) + try: + connection = await self._new_h1_client_connection( + secure=True, + manual_window_management=True, + initial_window_size=1, + read_buffer_capacity=1000 + ) + + # Create a file with 100 bytes for testing + test_data = bytes(range(100)) + test_file_path = 'test_aio_flow_control_100.txt' + with open(test_file_path, 'wb') as f: + f.write(test_data) + + try: + request = HttpRequest('GET', '/' + test_file_path) + request.headers.add('host', self.hostname) + stream = connection.request(request) + + chunks_received = [] + body = bytearray() + + await stream.get_response_status_code() + while True: + chunk = await stream.get_next_response_chunk() + if not chunk: + break + chunks_received.append(len(chunk)) + body.extend(chunk) + stream.update_window(len(chunk)) + + self.assertEqual(test_data, bytes(body)) + # With window=1, we should receive many small chunks + self.assertGreater(len(chunks_received), 1, "Should receive multiple chunks with tiny window") + + await connection.close() + finally: + # Clean up test file + if os.path.exists(test_file_path): + os.remove(test_file_path) + finally: + self._stop_server() + + def test_h1_stream_flow_control_blocks_and_resumes(self): + asyncio.run(self._test_h1_stream_flow_control_blocks_and_resumes()) + + [email protected](os.environ.get('AWS_TEST_LOCALHOST'), 'set env var to run test: AWS_TEST_LOCALHOST') +class AIOFlowControlH2Test(AsyncMockServerTestBase): + """HTTP/2 async flow control tests using local mock server""" + timeout = 10.0 + async def _test_h2_manual_window_management_happy_path(self): """Test HTTP/2 manual window management happy path""" - tls_ctx_opt = TlsContextOptions() - tls_ctx_opt.verify_peer = False - tls_ctx_opt.alpn_list = ['h2'] - tls_ctx = ClientTlsContext(tls_ctx_opt) - tls_options = tls_ctx.new_connection_options() - tls_options.set_server_name("httpbin.org") - - connection = await AIOHttp2ClientConnection.new( - host_name="httpbin.org", - port=443, - tls_connection_options=tls_options, + connection = await self._new_mock_h2_connection( manual_window_management=True, initial_window_size=65536 ) - request = HttpRequest('GET', '/get') - request.headers.add('host', 'httpbin.org') + # GET request with x-repeat-data header to download data + request = HttpRequest('GET', self.mock_server_url.path) + request.headers.add('host', self.mock_server_url.hostname) + request.headers.add('x-repeat-data', '100') # Request 100 bytes of data + stream = connection.request(request) - response = Response() - status_code = await response.collect_response(stream) + chunks_received = [] + body = bytearray() + + await stream.get_response_status_code() + while True: + chunk = await stream.get_next_response_chunk() + if not chunk: + break + chunks_received.append(len(chunk)) + body.extend(chunk) + stream.update_window(len(chunk)) + + self.assertGreater(len(body), 0, "No response body received") + self.assertGreater(len(chunks_received), 0, "No data chunks received") - self.assertEqual(200, status_code) - self.assertGreater(len(response.body), 0, "No data received") await connection.close() def test_h2_manual_window_management_happy_path(self): asyncio.run(self._test_h2_manual_window_management_happy_path()) async def _test_h2_stream_flow_control_blocks_and_resumes(self): - """Test that stream flow control actually blocks and resumes""" - tls_ctx_opt = TlsContextOptions() - tls_ctx_opt.verify_peer = False - tls_ctx_opt.alpn_list = ['h2'] - tls_ctx = ClientTlsContext(tls_ctx_opt) - tls_options = tls_ctx.new_connection_options() - tls_options.set_server_name("httpbin.org") - - connection = await AIOHttp2ClientConnection.new( - host_name="httpbin.org", - port=443, - tls_connection_options=tls_options, + """Test that HTTP/2 stream flow control actually blocks and resumes""" + connection = await self._new_mock_h2_connection( manual_window_management=True, - initial_window_size=10 # Tiny window + initial_window_size=10 # Small window to force multiple chunks ) - request = HttpRequest('GET', '/bytes/100') - request.headers.add('host', 'httpbin.org') + # GET request with x-repeat-data header to download data + request = HttpRequest('GET', self.mock_server_url.path) + request.headers.add('host', self.mock_server_url.hostname) + request.headers.add('x-repeat-data', '100') # Request 100 bytes of data + stream = connection.request(request) chunks_received = [] @@ -758,53 +906,89 @@ body.extend(chunk) stream.update_window(len(chunk)) - self.assertEqual(100, len(body)) - self.assertEqual(len(chunks_received), 10, "Should receive exactly 10 chunks") + self.assertGreater(len(body), 0, "No response body received") + # With small window, we should receive multiple chunks + self.assertGreater(len(chunks_received), 1, "Should receive multiple chunks with small window") + await connection.close() def test_h2_stream_flow_control_blocks_and_resumes(self): asyncio.run(self._test_h2_stream_flow_control_blocks_and_resumes()) - async def _test_h1_stream_flow_control_blocks_and_resumes(self): - """Test that HTTP/1.1 stream flow control actually blocks and resumes""" - tls_ctx_opt = TlsContextOptions() - tls_ctx_opt.verify_peer = False - tls_ctx_opt.alpn_list = ['http/1.1'] - tls_ctx = ClientTlsContext(tls_ctx_opt) - tls_options = tls_ctx.new_connection_options() - tls_options.set_server_name("httpbin.org") - connection = await AIOHttpClientConnection.new( +class AIOHttp2RemoteEndStreamTest(NativeResourceTest): + """Test suite for HTTP/2 on_h2_remote_end_stream callback in asyncio""" + timeout = 10.0 + + async def _new_httpbin_h2_connection(self): + """Create HTTP/2 connection to httpbin.org""" + event_loop_group = EventLoopGroup() + host_resolver = DefaultHostResolver(event_loop_group) + bootstrap = ClientBootstrap(event_loop_group, host_resolver) + + tls_ctx_options = TlsContextOptions() + tls_ctx = ClientTlsContext(tls_ctx_options) + tls_conn_opt = tls_ctx.new_connection_options() + tls_conn_opt.set_server_name("httpbin.org") + tls_conn_opt.set_alpn_list(["h2"]) + + connection = await AIOHttp2ClientConnection.new( host_name="httpbin.org", port=443, - tls_connection_options=tls_options, - manual_window_management=True, - initial_window_size=1, # Tiny window - read_buffer_capacity=1000 - ) + bootstrap=bootstrap, + tls_connection_options=tls_conn_opt) + + return connection + + async def _test_h2_remote_end_stream_ordering(self): + """Test that on_h2_remote_end_stream fires before on_complete when server finishes first""" + connection = await self._new_httpbin_h2_connection() - request = HttpRequest('GET', '/bytes/100') + # Use httpbin.org 404 path - server responds immediately + request = HttpRequest('POST', '/this-path-does-not-exist-deliberately-404') request.headers.add('host', 'httpbin.org') - stream = connection.request(request) - chunks_received = [] - body = bytearray() + complete_success = asyncio.Event() + remote_finished = asyncio.Event() + complete_fired = asyncio.Event() + + async def slow_body_generator(): + # Send first chunk WITHOUT end_stream + yield b'chunk1' + # Wait for server to finish + await remote_finished.wait() + if not complete_fired.is_set(): + # Verify complete hasn't fired yet + complete_success.set() + # Now finish sending + yield b'chunk2' + + stream = connection.request(request, request_body_generator=slow_body_generator()) + + # Read response + status_code = await stream.get_response_status_code() + self.assertEqual(404, status_code) - await stream.get_response_status_code() + # Read all response body while True: chunk = await stream.get_next_response_chunk() if not chunk: break - chunks_received.append(len(chunk)) - body.extend(chunk) - stream.update_window(len(chunk)) - self.assertEqual(100, len(body)) - self.assertEqual(len(chunks_received), 100, "Should receive exactly 100 chunks") + # set remove stream + remote_finished.set() + + # Wait for stream to complete successfully + await stream.wait_for_completion() + complete_fired.set() + + self.assertTrue(complete_success.is_set()) + await connection.close() - def test_h1_stream_flow_control_blocks_and_resumes(self): - asyncio.run(self._test_h1_stream_flow_control_blocks_and_resumes()) + def test_h2_remote_end_stream_ordering(self): + """Test callback ordering with early server response""" + asyncio.run(self._test_h2_remote_end_stream_ordering()) if __name__ == '__main__': diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/aws-crt-python-0.31.3/test/test_http_client.py new/aws-crt-python-0.32.0/test/test_http_client.py --- old/aws-crt-python-0.31.3/test/test_http_client.py 2026-03-06 22:06:47.000000000 +0100 +++ new/aws-crt-python-0.32.0/test/test_http_client.py 2026-03-27 00:11:43.000000000 +0100 @@ -48,11 +48,13 @@ self.end_headers() -class TestClient(NativeResourceTest): +class LocalServerTestBase(NativeResourceTest): + """Base class for tests that use local HTTP/1.x server""" hostname = 'localhost' timeout = 5 # seconds def _start_server(self, secure, http_1_0=False): + """Start local HTTP server""" # HTTP/1.0 closes the connection at the end of each request # HTTP/1.1 will keep the connection alive if http_1_0: @@ -74,10 +76,14 @@ self.server_thread.start() def _stop_server(self): + """Stop local HTTP server""" self.server.shutdown() self.server.server_close() self.server_thread.join() + +class TestClient(LocalServerTestBase): + def _new_client_connection(self, secure, proxy_options=None, cipher_pref=TlsCipherPref.DEFAULT): if secure: tls_ctx_opt = TlsContextOptions() @@ -402,16 +408,15 @@ self._test_connect(secure=True, cipher_pref=TlsCipherPref.PQ_DEFAULT) [email protected](os.environ.get('AWS_TEST_LOCALHOST'), 'set env var to run test: AWS_TEST_LOCALHOST') -class TestClientMockServer(NativeResourceTest): - +class MockServerTestBase(NativeResourceTest): + """Base class for tests that use the H2 mock server""" timeout = 5 # seconds p_server = None mock_server_url = None def setUp(self): super().setUp() - # Start the mock server from the aws-c-http. + # Start the mock server from the aws-c-http server_path = os.path.join( os.path.dirname(__file__), '..', @@ -429,7 +434,6 @@ def _wait_for_server_ready(self): """Wait until server is accepting connections.""" max_attempts = 20 - for attempt in range(max_attempts): try: with socket.create_connection(("127.0.0.1", self.mock_server_url.port), timeout=1): @@ -450,13 +454,49 @@ self.p_server.kill() super().tearDown() + def _new_mock_h2_connection(self, manual_window_management=False, initial_window_size=None, initial_settings=None): + """Create HTTP/2 client connection to local mock server""" + event_loop_group = EventLoopGroup() + host_resolver = DefaultHostResolver(event_loop_group) + bootstrap = ClientBootstrap(event_loop_group, host_resolver) + + port = self.mock_server_url.port + if port is None: + port = 443 + + tls_ctx_options = TlsContextOptions() + tls_ctx_options.verify_peer = False + tls_ctx = ClientTlsContext(tls_ctx_options) + tls_conn_opt = tls_ctx.new_connection_options() + tls_conn_opt.set_server_name(self.mock_server_url.hostname) + tls_conn_opt.set_alpn_list(["h2"]) + + if initial_settings is None: + initial_settings = [Http2Setting(Http2SettingID.ENABLE_PUSH, 0)] + + kwargs = { + 'host_name': self.mock_server_url.hostname, + 'port': port, + 'bootstrap': bootstrap, + 'tls_connection_options': tls_conn_opt, + 'initial_settings': initial_settings, + 'manual_window_management': manual_window_management + } + if initial_window_size is not None: + kwargs['initial_window_size'] = initial_window_size + + connection_future = Http2ClientConnection.new(**kwargs) + return connection_future.result(self.timeout) + + [email protected](os.environ.get('AWS_TEST_LOCALHOST'), 'set env var to run test: AWS_TEST_LOCALHOST') +class TestClientMockServer(MockServerTestBase): + def _on_remote_settings_changed(self, settings): # The mock server has the default settings with # ENABLE_PUSH = 0 # MAX_CONCURRENT_STREAMS = 100 # MAX_HEADER_LIST_SIZE = 2**16 - # using [email protected], code can be found in - # https://github.com/python-hyper/h2/blob/191ac06e0949fcfe3367b06eeb101a5a1a335964/src/h2/connection.py#L340-L359 # Check the settings here self.assertEqual(len(settings), 3) for i in settings: @@ -468,31 +508,32 @@ self.assertEqual(i.value, 2**16) def _new_mock_connection(self, initial_settings=None): + """Create connection with remote settings callback""" + kwargs = {'initial_settings': initial_settings} + connection_future = Http2ClientConnection.new( + host_name=self.mock_server_url.hostname, + port=self.mock_server_url.port if self.mock_server_url.port else 443, + bootstrap=self._create_client_bootstrap(), + tls_connection_options=self._create_tls_connection_options(), + initial_settings=initial_settings if initial_settings else [Http2Setting(Http2SettingID.ENABLE_PUSH, 0)], + on_remote_settings_changed=self._on_remote_settings_changed) + return connection_future.result(self.timeout) + def _create_client_bootstrap(self): + """Create client bootstrap""" event_loop_group = EventLoopGroup() host_resolver = DefaultHostResolver(event_loop_group) - bootstrap = ClientBootstrap(event_loop_group, host_resolver) + return ClientBootstrap(event_loop_group, host_resolver) - port = self.mock_server_url.port - # only test https - if port is None: - port = 443 + def _create_tls_connection_options(self): + """Create TLS connection options for mock server""" tls_ctx_options = TlsContextOptions() - tls_ctx_options.verify_peer = False # allow localhost + tls_ctx_options.verify_peer = False tls_ctx = ClientTlsContext(tls_ctx_options) tls_conn_opt = tls_ctx.new_connection_options() tls_conn_opt.set_server_name(self.mock_server_url.hostname) tls_conn_opt.set_alpn_list(["h2"]) - if initial_settings is None: - initial_settings = [Http2Setting(Http2SettingID.ENABLE_PUSH, 0)] - - connection_future = Http2ClientConnection.new(host_name=self.mock_server_url.hostname, - port=port, - bootstrap=bootstrap, - tls_connection_options=tls_conn_opt, - initial_settings=initial_settings, - on_remote_settings_changed=self._on_remote_settings_changed) - return connection_future.result(self.timeout) + return tls_conn_opt def test_h2_mock_server_manual_write(self): connection = self._new_mock_connection() @@ -652,177 +693,272 @@ self.body.extend(chunk) -class FlowControlTest(NativeResourceTest): +class FlowControlTest(LocalServerTestBase): + """HTTP/1.1 flow control tests using local server""" timeout = 10.0 - def setUp(self): - super().setUp() - tls_ctx_opt = TlsContextOptions() - tls_ctx_opt.verify_peer = False - tls_ctx_opt.alpn_list = ['h2', 'http/1.1'] - tls_ctx = ClientTlsContext(tls_ctx_opt) - self.tls_options = tls_ctx.new_connection_options() - self.tls_options.set_server_name("httpbin.org") + def _new_h1_client_connection( + self, + secure, + manual_window_management=False, + initial_window_size=None, + read_buffer_capacity=None): + """Create HTTP/1.1 client connection to local server""" + if secure: + tls_ctx_opt = TlsContextOptions() + tls_ctx_opt.verify_peer = False + tls_ctx = ClientTlsContext(tls_ctx_opt) + tls_conn_opt = tls_ctx.new_connection_options() + tls_conn_opt.set_server_name(self.hostname) + else: + tls_conn_opt = None + + event_loop_group = EventLoopGroup() + host_resolver = DefaultHostResolver(event_loop_group) + bootstrap = ClientBootstrap(event_loop_group, host_resolver) + + kwargs = { + 'host_name': self.hostname, + 'port': self.port, + 'bootstrap': bootstrap, + 'tls_connection_options': tls_conn_opt, + 'manual_window_management': manual_window_management + } + if initial_window_size is not None: + kwargs['initial_window_size'] = initial_window_size + if read_buffer_capacity is not None: + kwargs['read_buffer_capacity'] = read_buffer_capacity + + connection_future = HttpClientConnection.new(**kwargs) + return connection_future.result(self.timeout) def test_h1_manual_window_management_happy_path(self): """Test HTTP/1.1 manual window management happy path""" - connection_future = HttpClientConnection.new( - host_name="httpbin.org", - port=443, - tls_connection_options=self.tls_options, - manual_window_management=True, - initial_window_size=5, - read_buffer_capacity=1000 - ) - + self._start_server(secure=True) try: - connection = connection_future.result(timeout=self.timeout) - request = HttpRequest('GET', '/bytes/10') - request.headers.add('host', 'httpbin.org') + connection = self._new_h1_client_connection( + secure=True, + manual_window_management=True, + initial_window_size=5, + read_buffer_capacity=1000 + ) + + # Create a file with known size for testing + test_data = b'0123456789' # 10 bytes + test_file_path = 'test_flow_control_data.txt' + with open(test_file_path, 'wb') as f: + f.write(test_data) - response = Response() - received_chunks = [] - window_updates_sent = [] + try: + request = HttpRequest('GET', '/' + test_file_path) + request.headers.add('host', self.hostname) - def on_body_with_window_update(http_stream, chunk, **kwargs): - received_chunks.append(len(chunk)) - response.body.extend(chunk) - if hasattr(http_stream, '_binding') and http_stream._binding: + response = Response() + received_chunks = [] + window_updates_sent = [] + + def on_body_with_window_update(http_stream, chunk, **kwargs): + received_chunks.append(len(chunk)) + response.body.extend(chunk) http_stream.update_window(len(chunk)) window_updates_sent.append(len(chunk)) - stream = connection.request(request, response.on_response, on_body_with_window_update) - stream.activate() - stream_completion_result = stream.completion_future.result(timeout=self.timeout) + stream = connection.request(request, response.on_response, on_body_with_window_update) + stream.activate() + stream_completion_result = stream.completion_future.result(timeout=self.timeout) - self.assertEqual(200, response.status_code) - self.assertEqual(200, stream_completion_result) - self.assertEqual(10, len(response.body)) - - if len(response.body) > 0: + self.assertEqual(200, response.status_code) + self.assertEqual(200, stream_completion_result) + self.assertEqual(test_data, bytes(response.body)) self.assertGreater(len(received_chunks), 0, "No data chunks received") self.assertGreater(len(window_updates_sent), 0, "No window updates sent") self.assertEqual(sum(received_chunks), sum(window_updates_sent), "Window updates don't match received data") - connection.close() - except Exception as e: - self.skipTest(f"HTTP/1.1 flow control test skipped due to connection issue: {e}") + self.assertEqual(None, connection.close().result(self.timeout)) + finally: + # Clean up test file + if os.path.exists(test_file_path): + os.remove(test_file_path) + finally: + self._stop_server() + + def test_h1_stream_flow_control_blocks_and_resumes(self): + """Test that HTTP/1.1 stream flow control actually blocks and resumes""" + self._start_server(secure=True) + try: + connection = self._new_h1_client_connection( + secure=True, + manual_window_management=True, + initial_window_size=1, + read_buffer_capacity=1000 + ) + + # Create a file with 100 bytes for testing + test_data = bytes(range(100)) + test_file_path = 'test_flow_control_100.txt' + with open(test_file_path, 'wb') as f: + f.write(test_data) + + try: + request = HttpRequest('GET', '/' + test_file_path) + request.headers.add('host', self.hostname) + + response = Response() + chunks_received = [] + + def on_body(http_stream, chunk, **kwargs): + chunks_received.append(len(chunk)) + response.body.extend(chunk) + http_stream.update_window(len(chunk)) + + stream = connection.request(request, response.on_response, on_body) + stream.activate() + stream.completion_future.result(timeout=self.timeout) + + self.assertEqual(test_data, bytes(response.body)) + # With window=1, we should receive many small chunks + self.assertGreater(len(chunks_received), 1, "Should receive multiple chunks with tiny window") + + self.assertEqual(None, connection.close().result(self.timeout)) + finally: + # Clean up test file + if os.path.exists(test_file_path): + os.remove(test_file_path) + finally: + self._stop_server() + + [email protected](os.environ.get('AWS_TEST_LOCALHOST'), 'set env var to run test: AWS_TEST_LOCALHOST') +class FlowControlH2Test(MockServerTestBase): + """HTTP/2 flow control tests using local mock server""" + timeout = 10.0 def test_h2_manual_window_management_happy_path(self): """Test HTTP/2 manual window management happy path""" - connection_future = Http2ClientConnection.new( - host_name="nghttp2.org", - port=443, - tls_connection_options=self.tls_options, + connection = self._new_mock_h2_connection( manual_window_management=True, initial_window_size=65536 ) - try: - connection = connection_future.result(timeout=self.timeout) - request = HttpRequest('GET', '/httpbin/get') - request.headers.add('host', 'nghttp2.org') + # GET request with x-repeat-data header to download data + request = HttpRequest('GET', self.mock_server_url.path) + request.headers.add('host', self.mock_server_url.hostname) + request.headers.add('x-repeat-data', '100') # Request 100 bytes of data - response = Response() - received_chunks = [] - window_updates_sent = [] + response = Response() + received_chunks = [] + window_updates_sent = [] - def on_body_with_window_update(http_stream, chunk, **kwargs): - received_chunks.append(len(chunk)) - response.body.extend(chunk) - if hasattr(http_stream, '_binding') and http_stream._binding: - http_stream.update_window(len(chunk)) - window_updates_sent.append(len(chunk)) + def on_body_with_window_update(http_stream, chunk, **kwargs): + received_chunks.append(len(chunk)) + response.body.extend(chunk) + http_stream.update_window(len(chunk)) + window_updates_sent.append(len(chunk)) - stream = connection.request(request, response.on_response, on_body_with_window_update) - stream.activate() - stream_completion_result = stream.completion_future.result(timeout=self.timeout) + stream = connection.request(request, response.on_response, on_body_with_window_update) + stream.activate() - self.assertEqual(200, response.status_code) - self.assertEqual(200, stream_completion_result) - self.assertGreater(len(received_chunks), 0, "No data chunks received") - self.assertGreater(len(window_updates_sent), 0, "No window updates sent") + stream_completion_result = stream.completion_future.result(timeout=self.timeout) - connection.close() - except Exception as e: - self.skipTest(f"HTTP/2 flow control test skipped due to connection issue: {e}") + self.assertEqual(200, response.status_code) + self.assertEqual(200, stream_completion_result) + self.assertGreater(len(response.body), 0, "No response body received") + self.assertGreater(len(received_chunks), 0, "No data chunks received") + self.assertGreater(len(window_updates_sent), 0, "No window updates sent") + + self.assertEqual(None, connection.close().result(self.timeout)) def test_h2_stream_flow_control_blocks_and_resumes(self): - """Test that stream flow control actually blocks and resumes""" - connection_future = Http2ClientConnection.new( - host_name="httpbin.org", - port=443, - tls_connection_options=self.tls_options, + """Test that HTTP/2 stream flow control actually blocks and resumes""" + connection = self._new_mock_h2_connection( manual_window_management=True, - initial_window_size=1 # Tiny window - will block immediately + initial_window_size=10 # Small window to force multiple chunks ) - try: - connection = connection_future.result(timeout=self.timeout) - request = HttpRequest('GET', '/bytes/100') - request.headers.add('host', 'httpbin.org') + # GET request with x-repeat-data header to download data + request = HttpRequest('GET', self.mock_server_url.path) + request.headers.add('host', self.mock_server_url.hostname) + request.headers.add('x-repeat-data', '100') # Request 100 bytes of data - response = Response() - chunks_received = [] + response = Response() + chunks_received = [] - def on_body(http_stream, chunk, **kwargs): - chunks_received.append(len(chunk)) - response.body.extend(chunk) - # Update window to allow more data - http_stream.update_window(len(chunk)) + def on_body(http_stream, chunk, **kwargs): + chunks_received.append(len(chunk)) + response.body.extend(chunk) + # Update window to allow more data + http_stream.update_window(len(chunk)) - stream = connection.request(request, response.on_response, on_body) - stream.activate() - stream.completion_future.result(timeout=self.timeout) + stream = connection.request(request, response.on_response, on_body) + stream.activate() - self.assertEqual(100, len(response.body)) - # With window=10, we should receive many small chunks - self.assertEqual(len(chunks_received), 100, "Expected multiple chunks with tiny window") + stream.completion_future.result(timeout=self.timeout) - connection.close() - except Exception as e: - self.skipTest(f"HTTP/2 flow control test skipped: {e}") + self.assertEqual(200, response.status_code) + self.assertGreater(len(response.body), 0, "No response body received") + # With small window, we should receive multiple chunks + self.assertGreater(len(chunks_received), 1, "Should receive multiple chunks with small window") - def test_h1_stream_flow_control_blocks_and_resumes(self): - """Test that HTTP/1.1 stream flow control actually blocks and resumes""" - connection_future = HttpClientConnection.new( + self.assertEqual(None, connection.close().result(self.timeout)) + + +class Http2RemoteEndStreamTest(NativeResourceTest): + """Test suite for HTTP/2 on_h2_remote_end_stream callback""" + timeout = 10 # seconds + + def _new_httpbin_h2_connection(self): + """Create HTTP/2 connection to httpbin.org""" + event_loop_group = EventLoopGroup() + host_resolver = DefaultHostResolver(event_loop_group) + bootstrap = ClientBootstrap(event_loop_group, host_resolver) + + tls_ctx_options = TlsContextOptions() + tls_ctx = ClientTlsContext(tls_ctx_options) + tls_conn_opt = tls_ctx.new_connection_options() + tls_conn_opt.set_server_name("httpbin.org") + tls_conn_opt.set_alpn_list(["h2"]) + + connection_future = Http2ClientConnection.new( host_name="httpbin.org", port=443, - tls_connection_options=self.tls_options, - manual_window_management=True, - initial_window_size=1, # Tiny window - read_buffer_capacity=1000 - ) - - try: - connection = connection_future.result(timeout=self.timeout) - request = HttpRequest('GET', '/bytes/100') - request.headers.add('host', 'httpbin.org') + bootstrap=bootstrap, + tls_connection_options=tls_conn_opt) - response = Response() - chunks_received = [] + return connection_future.result(self.timeout) - def on_body(http_stream, chunk, **kwargs): - chunks_received.append(len(chunk)) - response.body.extend(chunk) - http_stream.update_window(len(chunk)) + def test_h2_remote_end_stream_ordering(self): + """Test that on_h2_remote_end_stream fires before on_complete when server finishes first""" + connection = self._new_httpbin_h2_connection() + + # Use httpbin.org 404 path - server responds immediately + request = HttpRequest('POST', '/this-path-does-not-exist-deliberately-404') + request.headers.add('host', 'httpbin.org') + response = Response() - stream = connection.request(request, response.on_response, on_body) - stream.activate() - stream.completion_future.result(timeout=self.timeout) + stream = connection.request(request, response.on_response, response.on_body, manual_write=True) + stream.activate() - self.assertEqual(100, len(response.body)) - # With window=1, we should receive many small chunks - self.assertEqual(len(chunks_received), 100, "Should receive exactly 100 chunks") + # Send first chunk WITHOUT end_stream - keeping the client side open + stream.write_data(BytesIO(b'chunk1'), end_stream=False).result(self.timeout) - connection.close() - except Exception as e: - self.skipTest(f"HTTP/1.1 flow control test skipped: {e}") + # Wait for server to finish (remote_end_stream_future completes) + # Server will respond immediately with 404 and send END_STREAM + remote_result = stream.remote_end_stream_future.result(self.timeout) + self.assertIsNone(remote_result, "remote_end_stream_future should complete with None") + + # Verify completion_future has NOT completed yet (stream still open) + self.assertFalse(stream.completion_future.done(), + "completion_future should not be done until client closes") + + # Now send final chunk WITH end_stream to close client side + stream.write_data(BytesIO(b'chunk2'), end_stream=True).result(self.timeout) + + # Wait for stream completion + completion_result = stream.completion_future.result(self.timeout) + self.assertEqual(404, completion_result, "Should get 404 status code") - def tearDown(self): - self.tls_options = None - super().tearDown() + connection.close().result(self.timeout) if __name__ == '__main__': ++++++ skip-test-requiring-network.patch ++++++ --- /var/tmp/diff_new_pack.lIjOeY/_old 2026-04-09 17:48:24.406385239 +0200 +++ /var/tmp/diff_new_pack.lIjOeY/_new 2026-04-09 17:48:24.418385738 +0200 @@ -1,7 +1,7 @@ -diff -Nru aws-crt-python-0.31.2.orig/test/test_aiohttp_client.py aws-crt-python-0.31.2/test/test_aiohttp_client.py ---- aws-crt-python-0.31.2.orig/test/test_aiohttp_client.py 2026-02-12 19:06:36.000000000 +0100 -+++ aws-crt-python-0.31.2/test/test_aiohttp_client.py 2026-02-15 11:34:39.055485729 +0100 -@@ -290,6 +290,7 @@ +diff -Nru aws-crt-python-0.32.0.orig/test/test_aiohttp_client.py aws-crt-python-0.32.0/test/test_aiohttp_client.py +--- aws-crt-python-0.32.0.orig/test/test_aiohttp_client.py 2026-03-27 00:11:43.000000000 +0100 ++++ aws-crt-python-0.32.0/test/test_aiohttp_client.py 2026-04-09 13:47:45.883983789 +0200 +@@ -296,6 +296,7 @@ return connection @@ -9,7 +9,7 @@ async def _test_h2_client(self): url = urlparse("https://d1cz66xoahf9cl.cloudfront.net/http_test_doc.txt") connection = await self._new_h2_client_connection(url) -@@ -311,6 +312,7 @@ +@@ -317,6 +318,7 @@ await connection.close() @@ -17,7 +17,7 @@ async def _test_h2_manual_write_exception(self): url = urlparse("https://d1cz66xoahf9cl.cloudfront.net/http_test_doc.txt") connection = await self._new_h2_client_connection(url) -@@ -430,6 +432,7 @@ +@@ -436,6 +438,7 @@ finally: self._stop_server() @@ -25,42 +25,50 @@ async def _test_cross_thread_http2_client(self): """Test using an HTTP/2 client from a different thread/event loop.""" url = urlparse("https://d1cz66xoahf9cl.cloudfront.net/http_test_doc.txt") -@@ -691,6 +694,7 @@ - self.assertEqual(10, len(response.body)) - await connection.close() +@@ -743,6 +746,7 @@ + + return await AIOHttpClientConnection.new(**kwargs) + @unittest.skip("Requires network") + async def _test_h1_manual_window_management_happy_path(self): + """Test HTTP/1.1 manual window management happy path""" + self._start_server(secure=True) +@@ -791,6 +795,7 @@ def test_h1_manual_window_management_happy_path(self): asyncio.run(self._test_h1_manual_window_management_happy_path()) -@@ -722,6 +726,7 @@ - self.assertGreater(len(response.body), 0, "No data received") - await connection.close() ++ @unittest.skip("Requires network") + async def _test_h1_stream_flow_control_blocks_and_resumes(self): + """Test that HTTP/1.1 stream flow control actually blocks and resumes""" + self._start_server(secure=True) +@@ -846,6 +851,7 @@ + """HTTP/2 async flow control tests using local mock server""" + timeout = 10.0 + @unittest.skip("Requires network") + async def _test_h2_manual_window_management_happy_path(self): + """Test HTTP/2 manual window management happy path""" + connection = await self._new_mock_h2_connection( +@@ -880,6 +886,7 @@ def test_h2_manual_window_management_happy_path(self): asyncio.run(self._test_h2_manual_window_management_happy_path()) -@@ -762,6 +767,7 @@ - self.assertEqual(len(chunks_received), 10, "Should receive exactly 10 chunks") - await connection.close() - + @unittest.skip("Requires network") - def test_h2_stream_flow_control_blocks_and_resumes(self): - asyncio.run(self._test_h2_stream_flow_control_blocks_and_resumes()) + async def _test_h2_stream_flow_control_blocks_and_resumes(self): + """Test that HTTP/2 stream flow control actually blocks and resumes""" + connection = await self._new_mock_h2_connection( +@@ -940,6 +947,7 @@ -@@ -803,6 +809,7 @@ - self.assertEqual(len(chunks_received), 100, "Should receive exactly 100 chunks") - await connection.close() + return connection + @unittest.skip("Requires network") - def test_h1_stream_flow_control_blocks_and_resumes(self): - asyncio.run(self._test_h1_stream_flow_control_blocks_and_resumes()) - -diff -Nru aws-crt-python-0.31.2.orig/test/test_http_client.py aws-crt-python-0.31.2/test/test_http_client.py ---- aws-crt-python-0.31.2.orig/test/test_http_client.py 2026-02-12 19:06:36.000000000 +0100 -+++ aws-crt-python-0.31.2/test/test_http_client.py 2026-02-15 11:32:30.115453571 +0100 -@@ -354,6 +354,7 @@ + async def _test_h2_remote_end_stream_ordering(self): + """Test that on_h2_remote_end_stream fires before on_complete when server finishes first""" + connection = await self._new_httpbin_h2_connection() +diff -Nru aws-crt-python-0.32.0.orig/test/test_http_client.py aws-crt-python-0.32.0/test/test_http_client.py +--- aws-crt-python-0.32.0.orig/test/test_http_client.py 2026-03-27 00:11:43.000000000 +0100 ++++ aws-crt-python-0.32.0/test/test_http_client.py 2026-04-09 13:49:32.250254694 +0200 +@@ -360,6 +360,7 @@ tls_connection_options=tls_conn_opt) return connection_future.result(self.timeout) @@ -68,7 +76,7 @@ def test_h2_client(self): url = urlparse("https://d1cz66xoahf9cl.cloudfront.net/http_test_doc.txt") connection = self._new_h2_client_connection(url) -@@ -376,6 +377,7 @@ +@@ -382,6 +383,7 @@ self.assertEqual(None, connection.close().exception(self.timeout)) @@ -76,9 +84,17 @@ def test_h2_manual_write_exception(self): url = urlparse("https://d1cz66xoahf9cl.cloudfront.net/http_test_doc.txt") connection = self._new_h2_client_connection(url) -diff -Nru aws-crt-python-0.31.2.orig/test/test_io.py aws-crt-python-0.31.2/test/test_io.py ---- aws-crt-python-0.31.2.orig/test/test_io.py 2026-02-12 19:06:36.000000000 +0100 -+++ aws-crt-python-0.31.2/test/test_io.py 2026-02-15 11:36:23.312116529 +0100 +@@ -927,6 +929,7 @@ + + return connection_future.result(self.timeout) + ++ @unittest.skip("Requires network") + def test_h2_remote_end_stream_ordering(self): + """Test that on_h2_remote_end_stream fires before on_complete when server finishes first""" + connection = self._new_httpbin_h2_connection() +diff -Nru aws-crt-python-0.32.0.orig/test/test_io.py aws-crt-python-0.32.0/test/test_io.py +--- aws-crt-python-0.32.0.orig/test/test_io.py 2026-03-27 00:11:43.000000000 +0100 ++++ aws-crt-python-0.32.0/test/test_io.py 2026-04-09 13:44:01.482192866 +0200 @@ -33,6 +33,7 @@ event_loop_group_two = EventLoopGroup.get_or_create_static_default() self.assertTrue(event_loop_group_one == event_loop_group_two)
