Skip to content

Test: Make the connection-reuse tests exercise reuse again - #645

Merged
heifner merged 4 commits into
masterfrom
fix/http-test-stale-connection-reuse
Sep 26, 2026
Merged

heifner merged 4 commits into
masterfrom
fix/http-test-stale-connection-reuse

Conversation

@heifner

@heifner heifner commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

Five connection-reuse tests stopped exercising reuse when the transport rewrite added a check before reuse: healthy_for_reuse peeks the idle socket and discards a connection whose peer has already closed it. Each test had its server close the first connection right after answering it. So the second request always went out on a fresh connection, whatever the feature under test did.

Test What it no longer exercised
stale_metadata_connection_retries_download_on_fresh_connection the reused-connection retry that retry_failed_reused_connection enables
stale_metadata_reconnect_failure_cleans_up_safely the same retry, failing at the reconnect
idle_connection_pool_cap_can_disable_reuse the idle-pool cap
expired_idle_connection_is_not_reused the idle-age expiry
idempotent_call_recovers_from_a_stale_cached_connection the JSON-RPC stale-connection replay

With all four features disabled, the versions of these tests on master still pass.

The same gap is why stale_metadata_reconnect_failure_cleans_up_safely was the one test_fc case still failing on WSL2 after #633. Its only connection attempt was a fresh connect within a millisecond of the server closing its listener. On WSL2 mirrored networking a closed listener keeps accepting for several milliseconds and then resets. So the transport reported response header read failed: Connection reset by peer (an io failure) rather than Failed to connect.

The fix

Each server now keeps the first connection open until the client acts on it, so the feature under test is the only thing that decides whether the connection is reused.

  • Download tests. The server answers the metadata request with keep-alive, reads the download request on the same connection, and drops it unanswered, so the transport has to retry on a fresh connection. Both tests assert that the download request arrived on the reused connection. The failure test also asserts exactly two connection attempts. Its predicate now rejects a retry_exhausted wrapper, as fresh_download_connection_failure_is_not_retried does.
  • Pool tests. The server keeps answering on the first connection until the client closes it.
  • JSON-RPC test. The fixture holds the first connection until the second call arrives on it, then drops that call, so the client has to replay it. The server now receives three requests instead of two.

In the failure test, the reconnect now follows the 100 ms retry backoff, so it is already well past the WSL2 window. To make it deterministic regardless of scheduling, the test's status callback holds the retry at its connecting phase until the port refuses a probe connection. On Linux and macOS the first probe is refused, so nothing waits.

With the same four features disabled, all five tests now fail on every host.

Fixture teardown race

scripted_http_server's destructor and its worker could both close the listener at once, and asio's close is not thread-safe: the worker dereferenced reactor data the destructor had just cleared. Under 16-way parallel load, the unchanged unbounded_interim_responses_are_rejected crashed this way 18 times in 2000 runs. The reworked pool-cap test crashed 6 times, because its worker now closes the listener just as the test ends. The first commit serializes those closes under a mutex. It also has the destructor shut the socket down, so a handler blocked reading it wakes instead of waiting for the client to close its end.

scripted_http_server::finished() lost its only caller and is removed.

The destructor closed the listener and the connection socket on the test thread while the worker could be closing the same listener at the end of serve(). Asio's close is not thread-safe. The worker can read the listener's descriptor just before the destructor's close clears the reactor data, then dereference that null data in epoll_reactor::deregister_descriptor. Under parallel load, unbounded_interim_responses_are_rejected crashes this way about once in a hundred runs.

Take one mutex around every close and reassignment of the listener and socket that both threads reach. The destructor also shuts the socket down before closing it. On Linux a close from another thread does not wake a handler blocked reading the socket, so without the shutdown, teardown would wait for the client to close its end.
…tion

Both stale-connection download tests shut the server side down right after the keep-alive metadata response. Since the transport started checking an idle connection before reusing it, a connection whose peer has closed is discarded rather than reused. So neither test reached the reused-connection retry that retry_failed_reused_connection enables: the download went straight to a fresh connection.

Hold the connection open after the metadata response, take the download request on it, then drop it unanswered. The reuse check passes, the reused request fails, and the transport retries on a fresh connection. Both tests assert that the download request arrived on the reused connection.

The reconnect-failure test's only connect used to come within a millisecond of the server closing its listener. On hosts whose listener teardown is asynchronous, WSL2 mirrored networking among them, that connect was accepted and then reset, which surfaced an io failure instead of the expected connect failure. The reconnect now follows the retry backoff, and the test holds it at its connecting phase until the port refuses, so it fails at connect on every host.

The fixture's finished() accessor lost its only caller and is removed.
…ests

idle_connection_pool_cap_can_disable_reuse, expired_idle_connection_is_not_reused and idempotent_call_recovers_from_a_stale_cached_connection each had the server close the first connection right after answering it. The transport's closed-peer check then discards that connection before reuse. So the second request always went out on a fresh connection, whether or not the idle-pool cap, the idle-age expiry or the JSON-RPC stale-connection replay worked.

The pool tests' server now keeps answering on the first connection until the client closes it, so the pool alone decides whether the connection is reused. The JSON-RPC fixture holds the first connection until the second call arrives on it, then drops that call unanswered, so the client has to replay it on a fresh connection. The server therefore receives three requests instead of two.
@heifner
heifner requested review from a team and huangminghuang September 25, 2026 18:56
return;
request_arrived = !read_request_header(socket).empty();
boost::system::error_code ec;
socket.close(ec);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Serialize this socket close with fixture teardown

This helper calls socket.close() without the fixture’s _close_mutex. If metadata fails or times out, client destruction or the server destructor’s new shutdown() can wake this blocked read while teardown also closes the same socket. The worker therefore still permits concurrent Asio closes—the crash class this PR addresses.

Please route this close through the fixture’s synchronized close operation, or leave closing to the worker under that mutex.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. The close only overlaps teardown when the test is already failing, but then it's the double close the first commit removes, and it would turn an assertion failure into a crash of the whole test_fc run.

Fixed in 4e5912f: the helper no longer closes. Once the reused request arrives it only shuts the connection down, which the client still sees as the stale-connection EOF. If the read fails, it returns without touching the socket. Every close now happens in the fixture under _close_mutex. The same unlocked close is also gone from truncated_fixed_length_response_is_rejected_and_removed, whose client can see the FIN from its shutdown before that close runs.

serve_metadata_then_drop_next_request closed the fixture's socket without its close mutex. In a test that is already failing, the client may never reuse the pooled connection. The helper then stays blocked reading until teardown, where client destruction or the destructor's shutdown wakes it. Its close then races the destructor's close, and the resulting crash takes down the whole test_fc run.

The helper now only shuts the connection down, and only once the reused request has arrived. The client still sees the EOF of a stale connection. If the read fails, the helper returns without touching the socket. truncated_fixed_length_response_is_rejected_and_removed closed the socket right after shutting down its send side, and its client can see that FIN and reach teardown first. It now leaves the close to the fixture as well.

@huangminghuang huangminghuang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed the full diff at 4e5912f. The earlier double-close finding is addressed, and I found no new actionable issues. Validation was static; PR-head tests were not run locally.

@heifner
heifner merged commit 2ef5fcf into master Sep 26, 2026
13 checks passed
@heifner
heifner deleted the fix/http-test-stale-connection-reuse branch September 26, 2026 15:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants