Skip to content

Commit 52a9b89

Browse files
authored
Fix free threading http connection race (#738)
1 parent 3bdbda4 commit 52a9b89

2 files changed

Lines changed: 147 additions & 32 deletions

File tree

source/http_connection.c

Lines changed: 29 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
#include "io.h"
88

99
#include <aws/common/array_list.h>
10+
#include <aws/common/ref_count.h>
1011
#include <aws/http/connection.h>
1112
#include <aws/http/proxy.h>
1213
#include <aws/http/request_response.h>
@@ -16,20 +17,22 @@ static const char *s_capsule_name_http_connection = "aws_http_connection";
1617

1718
/**
1819
* Lifetime notes:
19-
* - If connect() reports immediate failure, binding can be destroyed.
20-
* - If on_connection_setup reports failure, binding can be destroyed.
21-
* - Otherwise, binding cannot be destroyed until BOTH release() has been called AND on_connection_shutdown has fired.
20+
* - Binding starts with ref_count=1 (owned by whoever will destroy on failure, or by the capsule on success).
21+
* - If on_connection_setup succeeds, acquire another refcount. The additional ref is for the connection
22+
* thread to invoke callbacks (e.g. s_on_connection_shutdown) with a valid binding. The shutdown callback
23+
* is the last callback invoked by the connection thread, so it releases this ref.
24+
* - The last release (ref_count 1→0) calls s_connection_destroy().
2225
*/
2326
struct http_connection_binding {
2427
struct aws_http_connection *native;
2528
/* Reference to python object that reference to other related python object to keep it alive */
2629
PyObject *py_core;
2730

28-
bool release_called;
29-
bool shutdown_called;
31+
struct aws_ref_count ref_count;
3032
};
3133

32-
static void s_connection_destroy(struct http_connection_binding *connection) {
34+
static void s_connection_destroy(void *user_data) {
35+
struct http_connection_binding *connection = user_data;
3336
Py_XDECREF(connection->py_core);
3437

3538
aws_mem_release(aws_py_get_allocator(), connection);
@@ -40,38 +43,23 @@ struct aws_http_connection *aws_py_get_http_connection(PyObject *connection) {
4043
connection, s_capsule_name_http_connection, "HttpConnectionBase", http_connection_binding);
4144
}
4245

43-
static void s_connection_release(struct http_connection_binding *connection) {
44-
AWS_FATAL_ASSERT(!connection->release_called);
45-
connection->release_called = true;
46-
47-
bool destroy_after_release = connection->shutdown_called;
46+
static void s_connection_capsule_destructor(PyObject *capsule) {
47+
struct http_connection_binding *connection = PyCapsule_GetPointer(capsule, s_capsule_name_http_connection);
4848

4949
aws_http_connection_release(connection->native);
5050

51-
if (destroy_after_release) {
52-
s_connection_destroy(connection);
53-
}
54-
}
55-
56-
static void s_connection_capsule_destructor(PyObject *capsule) {
57-
struct http_connection_binding *connection = PyCapsule_GetPointer(capsule, s_capsule_name_http_connection);
58-
s_connection_release(connection);
51+
aws_ref_count_release(&connection->ref_count);
5952
}
6053

6154
static void s_on_connection_shutdown(struct aws_http_connection *native_connection, int error_code, void *user_data) {
6255
(void)native_connection;
6356
struct http_connection_binding *connection = user_data;
64-
AWS_FATAL_ASSERT(!connection->shutdown_called);
6557

6658
PyGILState_STATE state;
6759
if (aws_py_gilstate_ensure(&state)) {
6860
return; /* Python has shut down. Nothing matters anymore, but don't crash */
6961
}
7062

71-
connection->shutdown_called = true;
72-
73-
bool destroy_after_shutdown = connection->release_called;
74-
7563
/* Invoke on_shutdown, then clear our reference to it */
7664
PyObject *result = PyObject_CallMethod(connection->py_core, "_on_shutdown", "(i)", error_code);
7765

@@ -82,9 +70,8 @@ static void s_on_connection_shutdown(struct aws_http_connection *native_connecti
8270
PyErr_WriteUnraisable(PyErr_Occurred());
8371
}
8472

85-
if (destroy_after_shutdown) {
86-
s_connection_destroy(connection);
87-
}
73+
/* This is the last callback invoked by the connection thread. Release the connection-thread ref. */
74+
aws_ref_count_release(&connection->ref_count);
8875

8976
PyGILState_Release(state);
9077
}
@@ -107,6 +94,10 @@ static void s_on_client_connection_setup(
10794
/* If setup was successful, encapsulate binding so we can pass it to python */
10895
PyObject *capsule = NULL;
10996
if (!error_code) {
97+
/* Acquire ref for the connection thread to invoke callbacks with a valid binding.
98+
* The shutdown callback is the last one invoked, and will release this ref. */
99+
aws_ref_count_acquire(&connection->ref_count);
100+
110101
capsule = PyCapsule_New(connection, s_capsule_name_http_connection, s_connection_capsule_destructor);
111102
if (!capsule) {
112103
error_code = AWS_ERROR_UNKNOWN;
@@ -125,13 +116,18 @@ static void s_on_client_connection_setup(
125116
}
126117

127118
if (native_connection) {
128-
/* Connection exists, but failed to create capsule. Release connection, which eventually destroys binding */
129119
if (!capsule) {
130-
s_connection_release(connection);
120+
/* Native connection exists but capsule creation failed. We won't use the connection as a capsule,
121+
* but the connection thread is still running. Release the initial ref (no capsule to own it),
122+
* then release the native connection to initiate shutdown. The shutdown callback will release
123+
* the connection-thread ref. */
124+
aws_ref_count_release(&connection->ref_count);
125+
aws_http_connection_release(connection->native);
131126
}
132127
} else {
133-
/* Connection failed its setup, destroy binding now */
134-
s_connection_destroy(connection);
128+
/* Native connection failed to create. No capsule, no connection thread running.
129+
* Release the sole ref to destroy the binding. */
130+
aws_ref_count_release(&connection->ref_count);
135131
}
136132

137133
Py_XDECREF(capsule);
@@ -281,6 +277,7 @@ PyObject *aws_py_http_client_connection_new(PyObject *self, PyObject *args) {
281277
}
282278

283279
struct http_connection_binding *connection = aws_mem_calloc(allocator, 1, sizeof(struct http_connection_binding));
280+
aws_ref_count_init(&connection->ref_count, connection, s_connection_destroy);
284281
/* From hereon, we need to clean up if errors occur */
285282
struct aws_http2_setting *http2_settings = NULL;
286283
size_t http2_settings_count = 0;
@@ -388,7 +385,7 @@ PyObject *aws_py_http_client_connection_new(PyObject *self, PyObject *args) {
388385
aws_mem_release(allocator, http2_settings);
389386
}
390387
if (!success) {
391-
s_connection_destroy(connection);
388+
aws_ref_count_release(&connection->ref_count);
392389
return NULL;
393390
}
394391
Py_RETURN_NONE;
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
# SPDX-License-Identifier: Apache-2.0.
3+
4+
import gc
5+
import threading
6+
import unittest
7+
from test import NativeResourceTest
8+
from http.server import HTTPServer, SimpleHTTPRequestHandler
9+
from awscrt.io import ClientBootstrap, DefaultHostResolver, EventLoopGroup
10+
from awscrt.http import HttpClientConnection, HttpRequest
11+
import awscrt.exceptions
12+
13+
14+
class SilentHandler(SimpleHTTPRequestHandler):
15+
def log_message(self, format, *args):
16+
pass
17+
18+
def do_GET(self):
19+
self.send_response(200, 'OK')
20+
self.send_header('Content-Length', '5')
21+
self.end_headers()
22+
self.wfile.write(b'hello')
23+
24+
25+
class TestConnectionLifetime(NativeResourceTest):
26+
"""Tests for http_connection_binding ref-count based lifetime management.
27+
28+
Under free-threaded Python (Py_GIL_DISABLED), the capsule destructor
29+
(application thread) and on_connection_shutdown (event-loop thread) can
30+
race. These tests exercise both orderings and a stress scenario.
31+
"""
32+
hostname = 'localhost'
33+
timeout = 10
34+
35+
def setUp(self):
36+
super().setUp()
37+
self.server = HTTPServer((self.hostname, 0), SilentHandler)
38+
self.port = self.server.server_address[1]
39+
self.server_thread = threading.Thread(target=self.server.serve_forever, daemon=True)
40+
self.server_thread.start()
41+
42+
def tearDown(self):
43+
self.server.shutdown()
44+
self.server.server_close()
45+
self.server_thread.join()
46+
super().tearDown()
47+
48+
def _new_connection(self):
49+
event_loop_group = EventLoopGroup()
50+
host_resolver = DefaultHostResolver(event_loop_group)
51+
bootstrap = ClientBootstrap(event_loop_group, host_resolver)
52+
future = HttpClientConnection.new(
53+
host_name=self.hostname,
54+
port=self.port,
55+
bootstrap=bootstrap)
56+
return future.result(self.timeout)
57+
58+
def test_release_before_shutdown(self):
59+
"""Capsule destructor fires first, then shutdown callback."""
60+
connection = self._new_connection()
61+
shutdown_future = connection.shutdown_future
62+
63+
del connection
64+
gc.collect()
65+
66+
shutdown_future.result(self.timeout)
67+
68+
def test_shutdown_before_release(self):
69+
"""Shutdown callback fires first (via close), then capsule destructor."""
70+
connection = self._new_connection()
71+
shutdown_future = connection.shutdown_future
72+
73+
connection.close()
74+
shutdown_future.result(self.timeout)
75+
76+
del connection
77+
gc.collect()
78+
79+
def test_concurrent_release_and_shutdown_stress(self):
80+
"""Stress: race capsule destructor against shutdown from many threads.
81+
82+
Under Py_GIL_DISABLED, the old two-bool approach would double-free.
83+
With atomic ref-counting, exactly one path destroys the binding.
84+
"""
85+
iterations = 50
86+
errors = []
87+
88+
def release_connection(conn):
89+
try:
90+
del conn
91+
gc.collect()
92+
except Exception as e:
93+
errors.append(e)
94+
95+
for i in range(iterations):
96+
try:
97+
connection = self._new_connection()
98+
except awscrt.exceptions.AwsCrtError as e:
99+
if e.name == 'AWS_IO_SOCKET_CONNECTION_REFUSED':
100+
continue
101+
raise
102+
103+
shutdown_future = connection.shutdown_future
104+
105+
connection.close()
106+
107+
t = threading.Thread(target=release_connection, args=(connection,))
108+
del connection
109+
t.start()
110+
111+
shutdown_future.result(self.timeout)
112+
t.join(self.timeout)
113+
114+
self.assertEqual([], errors)
115+
116+
117+
if __name__ == '__main__':
118+
unittest.main()

0 commit comments

Comments
 (0)