@@ -82,6 +82,24 @@ def finish
8282 end
8383 end
8484
85+ # A pooled connection that either returns a canned response or raises when
86+ # driven, so execute_request can be exercised without real TCP+TLS.
87+ class StubConnection < FakeConnection
88+ attr_accessor :read_timeout , :open_timeout
89+
90+ def initialize ( response : nil , error : nil , **kwargs )
91+ super ( **kwargs )
92+ @response = response
93+ @error = error
94+ end
95+
96+ def request ( _request )
97+ raise @error if @error
98+
99+ @response
100+ end
101+ end
102+
85103 def setup
86104 @client = WorkOS ::BaseClient . new ( api_key : "sk_test_123" , max_retries : 1 )
87105 end
@@ -206,6 +224,143 @@ def test_evict_connection_removes_matching_pooled_connections
206224 refute keep . finished
207225 end
208226
227+ # An exception execute_request's rescue clause doesn't list still leaves the
228+ # socket mid-stream, so the connection must not survive in the pool for the
229+ # next request on this thread to pick up.
230+ def test_unlisted_request_error_evicts_the_pooled_connection
231+ conn = StubConnection . new ( error : Net ::HTTPBadResponse . new ( "wrong version" ) )
232+ cache = @client . send ( :thread_connections )
233+ cache [ "https:api.workos.com:443:30" ] = conn
234+
235+ assert_raises ( Net ::HTTPBadResponse ) do
236+ @client . execute_request ( request : Net ::HTTP ::Get . new ( "/things" ) )
237+ end
238+
239+ refute cache . key? ( "https:api.workos.com:443:30" ) ,
240+ "a connection whose request did not complete must not stay pooled"
241+ assert conn . finished
242+ end
243+
244+ def test_completed_request_keeps_the_connection_pooled
245+ conn = StubConnection . new ( response : Net ::HTTPOK . new ( "1.1" , "200" , "OK" ) )
246+ cache = @client . send ( :thread_connections )
247+ cache [ "https:api.workos.com:443:30" ] = conn
248+
249+ @client . execute_request ( request : Net ::HTTP ::Get . new ( "/things" ) )
250+
251+ assert cache . key? ( "https:api.workos.com:443:30" )
252+ refute conn . finished
253+ end
254+
255+ # Raised asynchronously into the worker thread to stand in for a caller-side
256+ # abort: Timeout.timeout, Thread#kill, a signal handler unwinding the stack.
257+ # It descends from Exception rather than StandardError so that nothing in
258+ # execute_request can catch it — the `ensure` is the only cleanup that runs.
259+ class AbortSignal < Exception ; end # standard:disable Lint/InheritException
260+
261+ # End-to-end regression test over a real keep-alive socket, for the failure
262+ # the eviction actually prevents: request one is abandoned mid-flight, the
263+ # server then writes response one onto that socket, and request two on the
264+ # same thread reads those stale bytes as if they were its own response.
265+ #
266+ # Before the fix, request two sees marker "one" (or a mangled response) off
267+ # the pooled socket. After it, the abandoned socket is closed and the server
268+ # accepts a fresh connection for request two.
269+ def test_aborted_request_does_not_leak_its_response_to_the_next_request
270+ WebMock . disable!
271+ server = TCPServer . new ( "127.0.0.1" , 0 )
272+ port = server . addr [ 1 ]
273+ client = WorkOS ::BaseClient . new (
274+ api_key : "sk_test_123" ,
275+ base_url : "http://127.0.0.1:#{ port } " ,
276+ timeout : 5 ,
277+ max_retries : 0
278+ )
279+
280+ request_one_read = Queue . new
281+ release_response_one = Queue . new
282+ response_one_written = Queue . new
283+ aborted = Queue . new
284+ connections = Queue . new
285+
286+ server_thread = Thread . new do
287+ # Connection one: read the request, then hold the response back until
288+ # the client has been aborted and has unwound.
289+ first = server . accept
290+ connections << first
291+ read_http_request ( first )
292+ request_one_read << true
293+ release_response_one . pop
294+ begin
295+ write_http_response ( first , { marker : "one" } )
296+ rescue Errno ::EPIPE , Errno ::ECONNRESET , IOError
297+ # Expected once the fix closes the abandoned socket.
298+ end
299+ response_one_written << true
300+
301+ # Connection two: a fresh accept, which only completes because the
302+ # client did not reuse the socket above.
303+ second = server . accept
304+ connections << second
305+ read_http_request ( second )
306+ write_http_response ( second , { marker : "two" } )
307+ end
308+
309+ worker = Thread . new do
310+ begin
311+ client . execute_request ( request : Net ::HTTP ::Get . new ( "/one" ) )
312+ rescue AbortSignal
313+ # The caller unwinds but the thread survives and goes on to serve more
314+ # work, the way a Puma or Solid Queue worker does. execute_request's
315+ # `ensure` has already run by the time this body executes.
316+ aborted << true
317+ response_one_written . pop
318+ end
319+ response = client . execute_request ( request : Net ::HTTP ::Get . new ( "/two" ) )
320+ JSON . parse ( response . body ) [ "marker" ]
321+ end
322+
323+ marker = Timeout . timeout ( 15 ) do
324+ request_one_read . pop
325+ worker . raise ( AbortSignal )
326+ aborted . pop
327+ release_response_one << true
328+ worker . value
329+ end
330+
331+ assert_equal "two" , marker ,
332+ "request two read the abandoned socket's response instead of its own"
333+ ensure
334+ server_thread &.kill
335+ worker &.kill
336+ until connections . nil? || connections . empty?
337+ socket = connections . pop
338+ socket . close unless socket . closed?
339+ end
340+ server &.close
341+ WebMock . enable!
342+ end
343+
344+ def read_http_request ( socket )
345+ socket . gets # request line
346+ loop do
347+ line = socket . gets
348+ break if line . nil? || line == "\r \n "
349+ end
350+ end
351+
352+ def write_http_response ( socket , body )
353+ payload = JSON . generate ( body )
354+ socket . write (
355+ "HTTP/1.1 200 OK\r \n " \
356+ "Content-Type: application/json\r \n " \
357+ "Content-Length: #{ payload . bytesize } \r \n " \
358+ "Connection: keep-alive\r \n " \
359+ "\r \n #{ payload } "
360+ )
361+ socket . flush
362+ end
363+
209364 # Regression test for https://github.com/workos/workos-ruby/issues/496
210365 #
211366 # The connection cache must be isolated per thread. A previous version
0 commit comments