Skip to content

Commit d978a43

Browse files
committed
Merge bitcoin/bitcoin#32408: tests: Expand HTTP coverage to assert libevent behavior
f16c8c6 tests: Expand HTTP coverage to assert libevent behavior (Matthew Zipkin) Pull request description: These commits are cherry-picked from #32061 and part of a project to [remove libevent](bitcoin/bitcoin#31194). This PR only adds functional tests to `interface_http` to cover some HTTP server behaviors we inherit from libevent, in order to maintain those behaviors when we replace libevent with our own HTTP server. 1. Pipelining: The server must respond to requests from a client in the order in which they were received [RFC 7230 6.3.2](https://www.rfc-editor.org/rfc/rfc7230#section-6.3.2) 2. `-rpcservertimeout` config option which sets the amount of time the server will keep an idle client connection alive 3. "Chunked" Transfer-Encoding: Allows a client to send a request in pieces, without the `Content-Length` header [RFC 7230 4.1](https://www.rfc-editor.org/rfc/rfc7230#section-4.1) ACKs for top commit: achow101: ACK f16c8c6 vasild: ACK f16c8c6 polespinasa: ACK f16c8c6 fjahr: utACK f16c8c6 Tree-SHA512: 405b59431b4d2bf118fde04b270865dee06ef980ab120d9cc1dce28e5d65dfd880a57055b407009d22f4de614bc3eebdb3e203bcd39e86cb14fbfd62195ed06a
2 parents f3bbc74 + f16c8c6 commit d978a43

File tree

3 files changed

+139
-0
lines changed

3 files changed

+139
-0
lines changed

test/functional/interface_http.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from test_framework.util import assert_equal, str_to_b64str
99

1010
import http.client
11+
import time
1112
import urllib.parse
1213

1314
class HTTPBasicsTest (BitcoinTestFramework):
@@ -105,5 +106,136 @@ def run_test(self):
105106
assert_equal(out1.status, http.client.BAD_REQUEST)
106107

107108

109+
self.log.info("Check pipelining")
110+
# Requests are responded to in order they were received
111+
# See https://www.rfc-editor.org/rfc/rfc7230#section-6.3.2
112+
tip_height = self.nodes[2].getblockcount()
113+
114+
req = "POST / HTTP/1.1\r\n"
115+
req += f'Authorization: Basic {str_to_b64str(authpair)}\r\n'
116+
117+
# First request will take a long time to process
118+
body1 = f'{{"method": "waitforblockheight", "params": [{tip_height + 1}]}}'
119+
req1 = req
120+
req1 += f'Content-Length: {len(body1)}\r\n\r\n'
121+
req1 += body1
122+
123+
# Second request will process very fast
124+
body2 = '{"method": "getblockcount"}'
125+
req2 = req
126+
req2 += f'Content-Length: {len(body2)}\r\n\r\n'
127+
req2 += body2
128+
# Get the underlying socket from HTTP connection so we can send something unusual
129+
conn = http.client.HTTPConnection(urlNode2.hostname, urlNode2.port)
130+
conn.connect()
131+
sock = conn.sock
132+
sock.settimeout(5)
133+
# Send two requests in a row. The first will block the second indefinitely
134+
sock.sendall(req1.encode("utf-8"))
135+
sock.sendall(req2.encode("utf-8"))
136+
try:
137+
# The server should not respond to the fast, second request
138+
# until the (very) slow first request has been handled:
139+
res = sock.recv(1024)
140+
assert False
141+
except TimeoutError:
142+
pass
143+
144+
# Use a separate http connection to generate a block
145+
self.generate(self.nodes[2], 1, sync_fun=self.no_op)
146+
147+
# Wait for two responses to be received
148+
res = b""
149+
while res.count(b"result") != 2:
150+
res += sock.recv(1024)
151+
152+
# waitforblockheight was responded to first, and then getblockcount
153+
# which includes the block added after the request was made
154+
chunks = res.split(b'"result":')
155+
assert chunks[1].startswith(b'{"hash":')
156+
assert chunks[2].startswith(bytes(f'{tip_height + 1}', 'utf8'))
157+
158+
159+
self.log.info("Check HTTP request encoded with chunked transfer")
160+
headers_chunked = headers.copy()
161+
headers_chunked.update({"Transfer-encoding": "chunked"})
162+
body_chunked = [
163+
b'{"method": "submitblock", "params": ["',
164+
b'0' * 1000000,
165+
b'1' * 1000000,
166+
b'2' * 1000000,
167+
b'3' * 1000000,
168+
b'"]}'
169+
]
170+
conn = http.client.HTTPConnection(urlNode2.hostname, urlNode2.port)
171+
conn.connect()
172+
conn.request(
173+
method='POST',
174+
url='/',
175+
body=iter(body_chunked),
176+
headers=headers_chunked,
177+
encode_chunked=True)
178+
out1 = conn.getresponse().read()
179+
assert_equal(out1, b'{"result":"high-hash","error":null}\n')
180+
181+
182+
self.log.info("Check -rpcservertimeout")
183+
# The test framework typically reuses a single persistent HTTP connection
184+
# for all RPCs to a TestNode. Because we are setting -rpcservertimeout
185+
# so low on this one node, its connection will quickly timeout and get dropped by
186+
# the server. Negating this setting will force the AuthServiceProxy
187+
# for this node to create a fresh new HTTP connection for every command
188+
# called for the remainder of this test.
189+
self.nodes[2].reuse_http_connections = False
190+
191+
self.restart_node(2, extra_args=["-rpcservertimeout=2"])
192+
# This is the amount of time the server will wait for a client to
193+
# send a complete request. Test it by sending an incomplete but
194+
# so-far otherwise well-formed HTTP request, and never finishing it.
195+
196+
# Copied from http_incomplete_test_() in regress_http.c in libevent.
197+
# A complete request would have an additional "\r\n" at the end.
198+
http_request = "GET /test1 HTTP/1.1\r\nHost: somehost\r\n"
199+
200+
# Get the underlying socket from HTTP connection so we can send something unusual
201+
conn = http.client.HTTPConnection(urlNode2.hostname, urlNode2.port)
202+
conn.connect()
203+
sock = conn.sock
204+
sock.sendall(http_request.encode("utf-8"))
205+
# Wait for response, but expect a timeout disconnection after 1 second
206+
start = time.time()
207+
res = sock.recv(1024)
208+
stop = time.time()
209+
# Server disconnected with EOF
210+
assert_equal(res, b"")
211+
# Server disconnected within an acceptable range of time:
212+
# not immediately, and not too far over the configured duration.
213+
# This allows for some jitter in the test between client and server.
214+
duration = stop - start
215+
assert duration <= 4, f"Server disconnected too slow: {duration} > 4"
216+
assert duration >= 1, f"Server disconnected too fast: {duration} < 1"
217+
218+
# The connection is definitely closed.
219+
got_expected_error = False
220+
try:
221+
conn.request('GET', '/')
222+
conn.getresponse()
223+
# macos/linux windows
224+
except (ConnectionResetError, ConnectionAbortedError):
225+
got_expected_error = True
226+
assert got_expected_error
227+
228+
# Sanity check
229+
http_request = "GET /test2 HTTP/1.1\r\nHost: somehost\r\n\r\n"
230+
conn = http.client.HTTPConnection(urlNode2.hostname, urlNode2.port)
231+
conn.connect()
232+
sock = conn.sock
233+
sock.sendall(http_request.encode("utf-8"))
234+
res = sock.recv(1024)
235+
assert res.startswith(b"HTTP/1.1 404 Not Found")
236+
# still open
237+
conn.request('GET', '/')
238+
conn.getresponse()
239+
108240
if __name__ == '__main__':
109241
HTTPBasicsTest(__file__).main()

test/functional/test_framework/authproxy.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ def __init__(self, service_url, service_name=None, timeout=HTTP_TIMEOUT, connect
7575
self.__service_url = service_url
7676
self._service_name = service_name
7777
self.ensure_ascii = ensure_ascii # can be toggled on the fly by tests
78+
self.reuse_http_connections = True
7879
self.__url = urllib.parse.urlparse(service_url)
7980
user = None if self.__url.username is None else self.__url.username.encode('utf8')
8081
passwd = None if self.__url.password is None else self.__url.password.encode('utf8')
@@ -92,6 +93,8 @@ def __getattr__(self, name):
9293
raise AttributeError
9394
if self._service_name is not None:
9495
name = "%s.%s" % (self._service_name, name)
96+
if not self.reuse_http_connections:
97+
self._set_conn()
9598
return AuthServiceProxy(self.__service_url, name, connection=self.__conn)
9699

97100
def _request(self, method, path, postdata):
@@ -102,6 +105,8 @@ def _request(self, method, path, postdata):
102105
'User-Agent': USER_AGENT,
103106
'Authorization': self.__auth_header,
104107
'Content-type': 'application/json'}
108+
if not self.reuse_http_connections:
109+
self._set_conn()
105110
self.__conn.request(method, path, postdata, headers)
106111
return self._get_response()
107112

test/functional/test_framework/test_node.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ def __init__(self, i, datadir_path, *, chain, rpchost, timewait, timeout_factor,
154154
self.process = None
155155
self.rpc_connected = False
156156
self.rpc = None
157+
self.reuse_http_connections = True # Must be set before calling get_rpc_proxy() i.e. before restarting node
157158
self.url = None
158159
self.log = logging.getLogger('TestFramework.node%d' % i)
159160
# Cache perf subprocesses here by their data output filename.
@@ -285,6 +286,7 @@ def suppress_error(category: str, e: Exception):
285286
timeout=self.rpc_timeout // 2, # Shorter timeout to allow for one retry in case of ETIMEDOUT
286287
coveragedir=self.coverage_dir,
287288
)
289+
rpc.auth_service_proxy_instance.reuse_http_connections = self.reuse_http_connections
288290
rpc.getblockcount()
289291
# If the call to getblockcount() succeeds then the RPC connection is up
290292
if self.version_is_at_least(190000) and wait_for_import:

0 commit comments

Comments
 (0)