|
1 | 1 | # frozen_string_literal: true |
2 | 2 |
|
3 | 3 | require_relative "test_helper" |
| 4 | +require "socket" |
4 | 5 |
|
5 | 6 | class ThreadSafetyTest < Minitest::Test |
6 | 7 | def test_gvl_released_during_network_io |
@@ -91,4 +92,77 @@ def test_thread_kill_cancels_request |
91 | 92 | "Thread.kill took #{elapsed.round(2)}s to interrupt the request " \ |
92 | 93 | "(expected < 3s; cancellation may not be working)" |
93 | 94 | end |
| 95 | + |
| 96 | + def test_thread_kill_aborts_inflight_connection |
| 97 | + # Use a local TCP server to prove the HTTP connection is actually |
| 98 | + # closed (not just that the Ruby thread exits). The server accepts |
| 99 | + # the connection, reads the request, then tries to keep writing to |
| 100 | + # the socket. Once the client aborts, the write will fail with a |
| 101 | + # broken-pipe / connection-reset error. |
| 102 | + server = TCPServer.new("127.0.0.1", 0) |
| 103 | + port = server.addr[1] |
| 104 | + |
| 105 | + server_error = nil |
| 106 | + client_disconnected = false |
| 107 | + chunks_sent = 0 |
| 108 | + |
| 109 | + server_thread = Thread.new do |
| 110 | + conn = server.accept |
| 111 | + request = +"" |
| 112 | + loop do |
| 113 | + request << conn.readpartial(4096) |
| 114 | + break if request.include?("\r\n\r\n") |
| 115 | + end |
| 116 | + |
| 117 | + conn.write "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n" |
| 118 | + |
| 119 | + begin |
| 120 | + 50.times do |
| 121 | + conn.write "5\r\nhello\r\n" |
| 122 | + chunks_sent += 1 |
| 123 | + sleep 0.1 |
| 124 | + end |
| 125 | + # If we get here, the client never disconnected |
| 126 | + conn.write "0\r\n\r\n" |
| 127 | + rescue Errno::EPIPE, Errno::ECONNRESET, Errno::EPROTOTYPE, IOError |
| 128 | + client_disconnected = true |
| 129 | + ensure |
| 130 | + conn.close rescue nil |
| 131 | + end |
| 132 | + end |
| 133 | + |
| 134 | + client = Wreq::Client.new( |
| 135 | + timeout: 30, |
| 136 | + emulation: false, |
| 137 | + verify_cert: false, |
| 138 | + verify_host: false, |
| 139 | + http1_only: true |
| 140 | + ) |
| 141 | + |
| 142 | + request_thread = Thread.new do |
| 143 | + client.get("http://127.0.0.1:#{port}/slow") |
| 144 | + rescue => e |
| 145 | + # Expected — request interrupted |
| 146 | + e |
| 147 | + end |
| 148 | + |
| 149 | + sleep 0.5 |
| 150 | + |
| 151 | + request_thread.kill |
| 152 | + request_thread.join(5) |
| 153 | + |
| 154 | + server_thread.join(5) |
| 155 | + |
| 156 | + assert client_disconnected, |
| 157 | + "Expected the server to detect a client disconnect (broken pipe) " \ |
| 158 | + "after Thread.kill, but the connection was not aborted" |
| 159 | + |
| 160 | + assert chunks_sent >= 1, |
| 161 | + "Expected at least 1 chunk to be sent before disconnect, got #{chunks_sent}" |
| 162 | + assert chunks_sent < 10, |
| 163 | + "Expected the connection to be aborted before 10 chunks were sent, " \ |
| 164 | + "but #{chunks_sent} chunks were sent" |
| 165 | + ensure |
| 166 | + server&.close rescue nil |
| 167 | + end |
94 | 168 | end |
0 commit comments