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-05-27 16:15:00 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/python-awscrt (Old) and /work/SRC/openSUSE:Factory/.python-awscrt.new.1937 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-awscrt" Wed May 27 16:15:00 2026 rev:7 rq:1355228 version:0.33.0 Changes: -------- --- /work/SRC/openSUSE:Factory/python-awscrt/python-awscrt.changes 2026-04-29 19:19:44.609750636 +0200 +++ /work/SRC/openSUSE:Factory/.python-awscrt.new.1937/python-awscrt.changes 2026-05-27 16:15:49.786137050 +0200 @@ -1,0 +2,10 @@ +Tue May 26 13:28:48 UTC 2026 - John Paul Adrian Glaubitz <[email protected]> + +- Update to version 0.33.0 + * builder -> v0.9.92 and clang-latest by @sbSteveK in (#733) + * Fix free threading http connection race by @TingDaoK in (#738) + * Error safely if header values are invalid by @azkrishpy in (#739) + * Enforce the depth limit for cbor decoder by @TingDaoK in (#736) + * Latest submodules by @TingDaoK in (#740) + +------------------------------------------------------------------- Old: ---- awscrt-0.32.2.tar.gz New: ---- awscrt-0.33.0.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ python-awscrt.spec ++++++ --- /var/tmp/diff_new_pack.RSrT2i/_old 2026-05-27 16:15:52.186235632 +0200 +++ /var/tmp/diff_new_pack.RSrT2i/_new 2026-05-27 16:15:52.202236290 +0200 @@ -18,7 +18,7 @@ %{?sle15_python_module_pythons} Name: python-awscrt -Version: 0.32.2 +Version: 0.33.0 Release: 0 Summary: A common runtime for AWS Python projects License: Apache-2.0 ++++++ awscrt-0.32.2.tar.gz -> awscrt-0.33.0.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/aws-crt-python-0.32.2/.github/workflows/ci.yml new/aws-crt-python-0.33.0/.github/workflows/ci.yml --- old/aws-crt-python-0.32.2/.github/workflows/ci.yml 2026-04-24 22:49:53.000000000 +0200 +++ new/aws-crt-python-0.33.0/.github/workflows/ci.yml 2026-05-22 19:12:54.000000000 +0200 @@ -7,7 +7,7 @@ - 'docs' env: - BUILDER_VERSION: v0.9.87 + BUILDER_VERSION: v0.9.92 BUILDER_SOURCE: releases BUILDER_HOST: https://d19elf31gohf1l.cloudfront.net PACKAGE_NAME: aws-crt-python @@ -209,6 +209,7 @@ - clang-9 - clang-10 - clang-11 + - clang-latest - gcc-5 - gcc-6 - gcc-7 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/aws-crt-python-0.32.2/source/cbor.c new/aws-crt-python-0.33.0/source/cbor.c --- old/aws-crt-python-0.32.2/source/cbor.c 2026-04-24 22:49:53.000000000 +0200 +++ new/aws-crt-python-0.33.0/source/cbor.c 2026-05-22 19:12:54.000000000 +0200 @@ -6,6 +6,9 @@ #include <aws/common/cbor.h> +/* Maximum nesting depth for recursive CBOR decoding to prevent stack exhaustion */ +static const size_t s_cbor_max_decode_depth = 128; + /******************************************************************************* * ENCODE ******************************************************************************/ @@ -185,30 +188,49 @@ /** * TODO: timestamp <-> datetime?? Decimal fraction <-> decimal?? */ - if (PyLong_CheckExact(py_object)) { - return s_cbor_encoder_write_pylong(encoder, py_object); - } else if (PyFloat_CheckExact(py_object)) { - return s_cbor_encoder_write_pyobject_as_float(encoder, py_object); - } else if (PyBool_Check(py_object)) { - return s_cbor_encoder_write_pyobject_as_bool(encoder, py_object); - } else if (PyBytes_CheckExact(py_object)) { - return s_cbor_encoder_write_pyobject_as_bytes(encoder, py_object); - } else if (PyUnicode_Check(py_object)) { + + /* Handle None first as it's a singleton, not a type */ + if (py_object == Py_None) { + aws_cbor_encoder_write_null(encoder); + Py_RETURN_NONE; + } + + /* Get type once for efficiency - PyObject_Type returns a new reference */ + /* https://docs.python.org/3/c-api/structures.html#c.Py_TYPE is not a stable API until 3.14, so that we cannot use + * it. */ + PyObject *type = PyObject_Type(py_object); + if (!type) { + return NULL; + } + + PyObject *result = NULL; + + /* Exact type matches first (no subclasses) */ + if (type == (PyObject *)&PyLong_Type) { + result = s_cbor_encoder_write_pylong(encoder, py_object); + } else if (type == (PyObject *)&PyFloat_Type) { + result = s_cbor_encoder_write_pyobject_as_float(encoder, py_object); + } else if (type == (PyObject *)&PyBool_Type) { + result = s_cbor_encoder_write_pyobject_as_bool(encoder, py_object); + } else if (type == (PyObject *)&PyBytes_Type) { + result = s_cbor_encoder_write_pyobject_as_bytes(encoder, py_object); + } else if (PyType_IsSubtype((PyTypeObject *)type, &PyUnicode_Type)) { /* Allow subclasses of `str` */ - return s_cbor_encoder_write_pyobject_as_text(encoder, py_object); - } else if (PyList_Check(py_object)) { + result = s_cbor_encoder_write_pyobject_as_text(encoder, py_object); + } else if (PyType_IsSubtype((PyTypeObject *)type, &PyList_Type)) { /* Write py_list, allow subclasses of `list` */ - return s_cbor_encoder_write_pylist(encoder, py_object); - } else if (PyDict_Check(py_object)) { + result = s_cbor_encoder_write_pylist(encoder, py_object); + } else if (PyType_IsSubtype((PyTypeObject *)type, &PyDict_Type)) { /* Write py_dict, allow subclasses of `dict` */ - return s_cbor_encoder_write_pydict(encoder, py_object); - } else if (py_object == Py_None) { - aws_cbor_encoder_write_null(encoder); + result = s_cbor_encoder_write_pydict(encoder, py_object); } else { - PyErr_Format(PyExc_ValueError, "Not supported type %R", (PyObject *)Py_TYPE(py_object)); + /* Unsupported type */ + PyErr_Format(PyExc_ValueError, "Not supported type %R", type); } - Py_RETURN_NONE; + /* Release the type reference */ + Py_DECREF(type); + return result; } /*********************************** BINDINGS ***********************************************/ @@ -290,6 +312,9 @@ /* Encoder has simple lifetime, no async/multi-thread allowed. */ PyObject *self_py; + + /* Current recursion depth for pop_next_data_item */ + size_t current_depth; }; static const char *s_capsule_name_cbor_decoder = "aws_cbor_decoder"; @@ -646,62 +671,85 @@ * Generic helper to convert a cbor encoded data to PyObject */ static PyObject *s_cbor_decoder_pop_next_data_item_to_pyobject(struct decoder_binding *binding) { + if (binding->current_depth >= s_cbor_max_decode_depth) { + PyErr_SetString(PyExc_RecursionError, "CBOR nesting depth exceeds maximum allowed (128)"); + return NULL; + } + ++binding->current_depth; + + PyObject *result = NULL; struct aws_cbor_decoder *decoder = binding->native; enum aws_cbor_type out_type = AWS_CBOR_TYPE_UNKNOWN; if (aws_cbor_decoder_peek_type(decoder, &out_type)) { - return PyErr_AwsLastError(); + result = PyErr_AwsLastError(); + goto done; } switch (out_type) { case AWS_CBOR_TYPE_UINT: - return s_cbor_decoder_pop_next_unsigned_int_val_to_pyobject(decoder); + result = s_cbor_decoder_pop_next_unsigned_int_val_to_pyobject(decoder); + break; case AWS_CBOR_TYPE_NEGINT: { /* The value from native code is -1 - val. */ PyObject *minus_one = PyLong_FromLong(-1); if (!minus_one) { - return NULL; + break; } PyObject *val = s_cbor_decoder_pop_next_negative_int_val_to_pyobject(decoder); if (!val) { Py_DECREF(minus_one); - return NULL; + break; } - PyObject *ret_val = PyNumber_Subtract(minus_one, val); + result = PyNumber_Subtract(minus_one, val); Py_DECREF(minus_one); Py_DECREF(val); - return ret_val; + break; } case AWS_CBOR_TYPE_FLOAT: - return s_cbor_decoder_pop_next_float_val_to_pyobject(decoder); + result = s_cbor_decoder_pop_next_float_val_to_pyobject(decoder); + break; case AWS_CBOR_TYPE_BYTES: - return s_cbor_decoder_pop_next_bytes_val_to_pyobject(decoder); + result = s_cbor_decoder_pop_next_bytes_val_to_pyobject(decoder); + break; case AWS_CBOR_TYPE_TEXT: - return s_cbor_decoder_pop_next_text_val_to_pyobject(decoder); + result = s_cbor_decoder_pop_next_text_val_to_pyobject(decoder); + break; case AWS_CBOR_TYPE_BOOL: - return s_cbor_decoder_pop_next_boolean_val_to_pyobject(decoder); + result = s_cbor_decoder_pop_next_boolean_val_to_pyobject(decoder); + break; case AWS_CBOR_TYPE_NULL: /* fall through */ case AWS_CBOR_TYPE_UNDEFINED: aws_cbor_decoder_consume_next_single_element(decoder); - Py_RETURN_NONE; + Py_INCREF(Py_None); + result = Py_None; + break; case AWS_CBOR_TYPE_MAP_START: /* fall through */ case AWS_CBOR_TYPE_INDEF_MAP_START: - return s_cbor_decoder_pop_next_data_item_to_py_dict(binding); + result = s_cbor_decoder_pop_next_data_item_to_py_dict(binding); + break; case AWS_CBOR_TYPE_ARRAY_START: /* fall through */ case AWS_CBOR_TYPE_INDEF_ARRAY_START: - return s_cbor_decoder_pop_next_data_item_to_py_list(binding); + result = s_cbor_decoder_pop_next_data_item_to_py_list(binding); + break; case AWS_CBOR_TYPE_INDEF_BYTES_START: - return s_cbor_decoder_pop_next_inf_bytes_to_py_bytes(decoder); + result = s_cbor_decoder_pop_next_inf_bytes_to_py_bytes(decoder); + break; case AWS_CBOR_TYPE_INDEF_TEXT_START: - return s_cbor_decoder_pop_next_inf_string_to_py_str(decoder); + result = s_cbor_decoder_pop_next_inf_string_to_py_str(decoder); + break; case AWS_CBOR_TYPE_TAG: - return s_cbor_decoder_pop_next_tag_to_pyobject(binding); + result = s_cbor_decoder_pop_next_tag_to_pyobject(binding); + break; default: aws_raise_error(AWS_ERROR_CBOR_UNEXPECTED_TYPE); - return PyErr_AwsLastError(); + result = PyErr_AwsLastError(); + break; } - return NULL; +done: + --binding->current_depth; + return result; } /*********************************** BINDINGS ***********************************************/ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/aws-crt-python-0.32.2/source/http_connection.c new/aws-crt-python-0.33.0/source/http_connection.c --- old/aws-crt-python-0.32.2/source/http_connection.c 2026-04-24 22:49:53.000000000 +0200 +++ new/aws-crt-python-0.33.0/source/http_connection.c 2026-05-22 19:12:54.000000000 +0200 @@ -7,6 +7,7 @@ #include "io.h" #include <aws/common/array_list.h> +#include <aws/common/ref_count.h> #include <aws/http/connection.h> #include <aws/http/proxy.h> #include <aws/http/request_response.h> @@ -16,20 +17,22 @@ /** * Lifetime notes: - * - If connect() reports immediate failure, binding can be destroyed. - * - If on_connection_setup reports failure, binding can be destroyed. - * - Otherwise, binding cannot be destroyed until BOTH release() has been called AND on_connection_shutdown has fired. + * - Binding starts with ref_count=1 (owned by whoever will destroy on failure, or by the capsule on success). + * - If on_connection_setup succeeds, acquire another refcount. The additional ref is for the connection + * thread to invoke callbacks (e.g. s_on_connection_shutdown) with a valid binding. The shutdown callback + * is the last callback invoked by the connection thread, so it releases this ref. + * - The last release (ref_count 1→0) calls s_connection_destroy(). */ struct http_connection_binding { struct aws_http_connection *native; /* Reference to python object that reference to other related python object to keep it alive */ PyObject *py_core; - bool release_called; - bool shutdown_called; + struct aws_ref_count ref_count; }; -static void s_connection_destroy(struct http_connection_binding *connection) { +static void s_connection_destroy(void *user_data) { + struct http_connection_binding *connection = user_data; Py_XDECREF(connection->py_core); aws_mem_release(aws_py_get_allocator(), connection); @@ -40,38 +43,23 @@ connection, s_capsule_name_http_connection, "HttpConnectionBase", http_connection_binding); } -static void s_connection_release(struct http_connection_binding *connection) { - AWS_FATAL_ASSERT(!connection->release_called); - connection->release_called = true; - - bool destroy_after_release = connection->shutdown_called; +static void s_connection_capsule_destructor(PyObject *capsule) { + struct http_connection_binding *connection = PyCapsule_GetPointer(capsule, s_capsule_name_http_connection); aws_http_connection_release(connection->native); - if (destroy_after_release) { - s_connection_destroy(connection); - } -} - -static void s_connection_capsule_destructor(PyObject *capsule) { - struct http_connection_binding *connection = PyCapsule_GetPointer(capsule, s_capsule_name_http_connection); - s_connection_release(connection); + aws_ref_count_release(&connection->ref_count); } static void s_on_connection_shutdown(struct aws_http_connection *native_connection, int error_code, void *user_data) { (void)native_connection; struct http_connection_binding *connection = user_data; - AWS_FATAL_ASSERT(!connection->shutdown_called); PyGILState_STATE state; if (aws_py_gilstate_ensure(&state)) { return; /* Python has shut down. Nothing matters anymore, but don't crash */ } - connection->shutdown_called = true; - - bool destroy_after_shutdown = connection->release_called; - /* Invoke on_shutdown, then clear our reference to it */ PyObject *result = PyObject_CallMethod(connection->py_core, "_on_shutdown", "(i)", error_code); @@ -82,9 +70,8 @@ PyErr_WriteUnraisable(PyErr_Occurred()); } - if (destroy_after_shutdown) { - s_connection_destroy(connection); - } + /* This is the last callback invoked by the connection thread. Release the connection-thread ref. */ + aws_ref_count_release(&connection->ref_count); PyGILState_Release(state); } @@ -107,6 +94,10 @@ /* If setup was successful, encapsulate binding so we can pass it to python */ PyObject *capsule = NULL; if (!error_code) { + /* Acquire ref for the connection thread to invoke callbacks with a valid binding. + * The shutdown callback is the last one invoked, and will release this ref. */ + aws_ref_count_acquire(&connection->ref_count); + capsule = PyCapsule_New(connection, s_capsule_name_http_connection, s_connection_capsule_destructor); if (!capsule) { error_code = AWS_ERROR_UNKNOWN; @@ -125,13 +116,18 @@ } if (native_connection) { - /* Connection exists, but failed to create capsule. Release connection, which eventually destroys binding */ if (!capsule) { - s_connection_release(connection); + /* Native connection exists but capsule creation failed. We won't use the connection as a capsule, + * but the connection thread is still running. Release the initial ref (no capsule to own it), + * then release the native connection to initiate shutdown. The shutdown callback will release + * the connection-thread ref. */ + aws_ref_count_release(&connection->ref_count); + aws_http_connection_release(connection->native); } } else { - /* Connection failed its setup, destroy binding now */ - s_connection_destroy(connection); + /* Native connection failed to create. No capsule, no connection thread running. + * Release the sole ref to destroy the binding. */ + aws_ref_count_release(&connection->ref_count); } Py_XDECREF(capsule); @@ -281,6 +277,7 @@ } struct http_connection_binding *connection = aws_mem_calloc(allocator, 1, sizeof(struct http_connection_binding)); + aws_ref_count_init(&connection->ref_count, connection, s_connection_destroy); /* From hereon, we need to clean up if errors occur */ struct aws_http2_setting *http2_settings = NULL; size_t http2_settings_count = 0; @@ -388,7 +385,7 @@ aws_mem_release(allocator, http2_settings); } if (!success) { - s_connection_destroy(connection); + aws_ref_count_release(&connection->ref_count); return NULL; } Py_RETURN_NONE; diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/aws-crt-python-0.32.2/source/websocket.c new/aws-crt-python-0.33.0/source/websocket.c --- old/aws-crt-python-0.32.2/source/websocket.c 2026-04-24 22:49:53.000000000 +0200 +++ new/aws-crt-python-0.33.0/source/websocket.c 2026-05-22 19:12:54.000000000 +0200 @@ -209,12 +209,24 @@ PyObject *tuple_py = PyTuple_New(2); AWS_FATAL_ASSERT(tuple_py && "header tuple allocation failed"); + /* Header names are tokens as per RFC 7230 Section 3.2 (strict ASCII), + * which means aws-c-http rejects on the wire if they contain non-ASCII bytes. + * So errors related to http header decoding will be caught at the protocol level. + * We should never fail wrangling the header name. */ PyObject *name_py = PyUnicode_FromAwsByteCursor(&header_i->name); AWS_FATAL_ASSERT(name_py && "header name wrangling failed"); PyTuple_SetItem(tuple_py, 0, name_py); /* Steals a reference */ + /* Header value can contain RFC 7230 obs-text (0x80-0xFF), which is + * not guaranteed valid UTF-8. On decode failure, log it and drop + * the whole header list rather than aborting the process. */ PyObject *value_py = PyUnicode_FromAwsByteCursor(&header_i->value); - AWS_FATAL_ASSERT(value_py && "header value wrangling failed"); + if (!value_py) { + PyErr_WriteUnraisable(websocket_core_py); + Py_DECREF(tuple_py); + Py_CLEAR(headers_py); + break; + } PyTuple_SetItem(tuple_py, 1, value_py); /* Steals a reference */ PyList_SetItem(headers_py, i, tuple_py); /* Steals a reference */ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/aws-crt-python-0.32.2/test/test_http_connection_lifetime.py new/aws-crt-python-0.33.0/test/test_http_connection_lifetime.py --- old/aws-crt-python-0.32.2/test/test_http_connection_lifetime.py 1970-01-01 01:00:00.000000000 +0100 +++ new/aws-crt-python-0.33.0/test/test_http_connection_lifetime.py 2026-05-22 19:12:54.000000000 +0200 @@ -0,0 +1,118 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0. + +import gc +import threading +import unittest +from test import NativeResourceTest +from http.server import HTTPServer, SimpleHTTPRequestHandler +from awscrt.io import ClientBootstrap, DefaultHostResolver, EventLoopGroup +from awscrt.http import HttpClientConnection, HttpRequest +import awscrt.exceptions + + +class SilentHandler(SimpleHTTPRequestHandler): + def log_message(self, format, *args): + pass + + def do_GET(self): + self.send_response(200, 'OK') + self.send_header('Content-Length', '5') + self.end_headers() + self.wfile.write(b'hello') + + +class TestConnectionLifetime(NativeResourceTest): + """Tests for http_connection_binding ref-count based lifetime management. + + Under free-threaded Python (Py_GIL_DISABLED), the capsule destructor + (application thread) and on_connection_shutdown (event-loop thread) can + race. These tests exercise both orderings and a stress scenario. + """ + hostname = 'localhost' + timeout = 10 + + def setUp(self): + super().setUp() + self.server = HTTPServer((self.hostname, 0), SilentHandler) + self.port = self.server.server_address[1] + self.server_thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.server_thread.start() + + def tearDown(self): + self.server.shutdown() + self.server.server_close() + self.server_thread.join() + super().tearDown() + + def _new_connection(self): + event_loop_group = EventLoopGroup() + host_resolver = DefaultHostResolver(event_loop_group) + bootstrap = ClientBootstrap(event_loop_group, host_resolver) + future = HttpClientConnection.new( + host_name=self.hostname, + port=self.port, + bootstrap=bootstrap) + return future.result(self.timeout) + + def test_release_before_shutdown(self): + """Capsule destructor fires first, then shutdown callback.""" + connection = self._new_connection() + shutdown_future = connection.shutdown_future + + del connection + gc.collect() + + shutdown_future.result(self.timeout) + + def test_shutdown_before_release(self): + """Shutdown callback fires first (via close), then capsule destructor.""" + connection = self._new_connection() + shutdown_future = connection.shutdown_future + + connection.close() + shutdown_future.result(self.timeout) + + del connection + gc.collect() + + def test_concurrent_release_and_shutdown_stress(self): + """Stress: race capsule destructor against shutdown from many threads. + + Under Py_GIL_DISABLED, the old two-bool approach would double-free. + With atomic ref-counting, exactly one path destroys the binding. + """ + iterations = 50 + errors = [] + + def release_connection(conn): + try: + del conn + gc.collect() + except Exception as e: + errors.append(e) + + for i in range(iterations): + try: + connection = self._new_connection() + except awscrt.exceptions.AwsCrtError as e: + if e.name == 'AWS_IO_SOCKET_CONNECTION_REFUSED': + continue + raise + + shutdown_future = connection.shutdown_future + + connection.close() + + t = threading.Thread(target=release_connection, args=(connection,)) + del connection + t.start() + + shutdown_future.result(self.timeout) + t.join(self.timeout) + + self.assertEqual([], errors) + + +if __name__ == '__main__': + unittest.main() diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/aws-crt-python-0.32.2/test/test_websocket.py new/aws-crt-python-0.33.0/test/test_websocket.py --- old/aws-crt-python-0.32.2/test/test_websocket.py 2026-04-24 22:49:53.000000000 +0200 +++ new/aws-crt-python-0.33.0/test/test_websocket.py 2026-05-22 19:12:54.000000000 +0200 @@ -13,6 +13,8 @@ from queue import Empty, Queue import secrets import socket +import subprocess +import sys from test import NativeResourceTest import threading from time import sleep, time @@ -182,6 +184,54 @@ asyncio.run_coroutine_threadsafe(self._current_connection.send(msg), self._server_loop) +class MockHandshakeServer: + # A raw-socket server that accepts one connection, drains the client's + # HTTP handshake request, and sends back a caller-provided response. + # Use this when tests need to send byte sequences that the 3rdparty + # `websockets` library can't produce (e.g. malformed headers). + # + # Usage: + # with MockHandshakeServer(host, response=b"HTTP/1.1 ...") as server: + # # spawn a client that connects to (host, server.port) + # ... + + def __init__(self, host, response): + self._host = host + self._response = response + self._listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self._listener.bind((host, 0)) + self._listener.listen(1) + self.port = self._listener.getsockname()[1] + self._thread = threading.Thread(target=self._serve, daemon=True) + + def __enter__(self): + self._thread.start() + return self + + def __exit__(self, exc_type, exc_value, exc_tb): + self._listener.close() + self._thread.join(TIMEOUT) + + def _serve(self): + try: + conn, _ = self._listener.accept() + except OSError: + return + with closing(conn): + conn.settimeout(TIMEOUT) + try: + buf = b"" + while b"\r\n\r\n" not in buf: + chunk = conn.recv(4096) + if not chunk: + return + buf += chunk + conn.sendall(self._response) + except OSError: + pass + + class TestClient(NativeResourceTest): def setUp(self): super().setUp() @@ -324,6 +374,60 @@ # check that body is a valid string self.assertGreater(len(setup_data.handshake_response_body.decode()), 0) + def test_connect_response_header_with_invalid_name_is_protocol_error(self): + # A response header whose name contains a non-tchar byte (e.g. 0xE9) is + # rejected by aws-c-http's HTTP/1.1 decoder before reaching the binding. + # The connection should fail with AWS_ERROR_HTTP_PROTOCOL_ERROR. + response = ( + b"HTTP/1.1 403 Forbidden\r\n" + b"Content-Length: 0\r\n" + b"X-Bad\xe9Name: whatever\r\n" + b"\r\n" + ) + with MockHandshakeServer(self.host, response=response) as server: + setup_future = Future() + connect( + host=self.host, + port=server.port, + handshake_request=create_handshake_request(host=self.host), + on_connection_setup=lambda x: setup_future.set_result(x)) + + setup_data: OnConnectionSetupData = setup_future.result(TIMEOUT) + + self.assertIsNone(setup_data.websocket) + self.assertIsNotNone(setup_data.exception) + self.assertEqual("AWS_ERROR_HTTP_PROTOCOL_ERROR", setup_data.exception.name) + # bad-name response is rejected at the parser, so no headers reach Python + self.assertIsNone(setup_data.handshake_response_headers) + + def test_connect_response_header_with_obs_text_does_not_abort(self): + # A response header value containing a non-UTF-8 obs-text byte (e.g. lone 0xE9) + # must not crash the process. Run the client in a subprocess so that an abort, + # if it happens, is observable as a non-zero exit code. + response = ( + b"HTTP/1.1 403 Forbidden\r\n" + b"Content-Length: 0\r\n" + b"X-Reason: caf\xe9\r\n" + b"\r\n" + ) + with MockHandshakeServer(self.host, response=response) as server: + proc = subprocess.Popen( + [sys.executable, '-m', 'test.ws_connect_helper', self.host, str(server.port)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + + try: + stdout, stderr = proc.communicate(timeout=TIMEOUT) + except subprocess.TimeoutExpired: + proc.kill() + stdout, stderr = proc.communicate() + self.fail("client subprocess hung") + + self.assertEqual( + 0, proc.returncode, + f"client subprocess crashed (returncode={proc.returncode}). " + f"stdout={stdout!r} stderr={stderr!r}") + def test_exception_in_setup_callback_closes_websocket(self): with WebSocketServer(self.host, self.port) as server: setup_future = Future() diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/aws-crt-python-0.32.2/test/ws_connect_helper.py new/aws-crt-python-0.33.0/test/ws_connect_helper.py --- old/aws-crt-python-0.32.2/test/ws_connect_helper.py 1970-01-01 01:00:00.000000000 +0100 +++ new/aws-crt-python-0.33.0/test/ws_connect_helper.py 2026-05-22 19:12:54.000000000 +0200 @@ -0,0 +1,28 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0. + +# Helper for test_websocket subprocess scenarios. +# Runs awscrt.websocket.connect() against a host:port given on the command +# line and waits for on_connection_setup to fire. Used by tests that need +# to observe whether a malformed server response crashes the client process. + +import sys +from concurrent.futures import Future + +from awscrt.websocket import connect, create_handshake_request + +TIMEOUT = 10.0 + + +def main(host, port): + setup_future = Future() + connect( + host=host, + port=port, + handshake_request=create_handshake_request(host=host), + on_connection_setup=lambda x: setup_future.set_result(x)) + setup_future.result(TIMEOUT) + + +if __name__ == '__main__': + main(sys.argv[1], int(sys.argv[2]))
