@@ -82,10 +82,36 @@ 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
88106
107+ def teardown
108+ super
109+ # Close any open connections and clear the fiber-local cache to avoid
110+ # leaking pooled connections between tests.
111+ @client . shutdown
112+ Fiber [ :workos_connections ] = nil
113+ end
114+
89115 def test_request_dispatches_known_methods
90116 client = RecordingClient . new ( api_key : "sk_test_123" )
91117
@@ -170,4 +196,141 @@ def test_evict_connection_removes_matching_pooled_connections
170196 assert evict . finished
171197 refute keep . finished
172198 end
199+
200+ # An exception execute_request's rescue clause doesn't list still leaves the
201+ # socket mid-stream, so the connection must not survive in the pool for the
202+ # next request on this thread to pick up.
203+ def test_unlisted_request_error_evicts_the_pooled_connection
204+ conn = StubConnection . new ( error : Net ::HTTPBadResponse . new ( "wrong version" ) )
205+ cache = @client . send ( :thread_connections )
206+ cache [ "https:api.workos.com:443:30" ] = conn
207+
208+ assert_raises ( Net ::HTTPBadResponse ) do
209+ @client . execute_request ( request : Net ::HTTP ::Get . new ( "/things" ) )
210+ end
211+
212+ refute cache . key? ( "https:api.workos.com:443:30" ) ,
213+ "a connection whose request did not complete must not stay pooled"
214+ assert conn . finished
215+ end
216+
217+ def test_completed_request_keeps_the_connection_pooled
218+ conn = StubConnection . new ( response : Net ::HTTPOK . new ( "1.1" , "200" , "OK" ) )
219+ cache = @client . send ( :thread_connections )
220+ cache [ "https:api.workos.com:443:30" ] = conn
221+
222+ @client . execute_request ( request : Net ::HTTP ::Get . new ( "/things" ) )
223+
224+ assert cache . key? ( "https:api.workos.com:443:30" )
225+ refute conn . finished
226+ end
227+
228+ # Raised asynchronously into the worker thread to stand in for a caller-side
229+ # abort: Timeout.timeout, Thread#kill, a signal handler unwinding the stack.
230+ # It descends from Exception rather than StandardError so that nothing in
231+ # execute_request can catch it — the `ensure` is the only cleanup that runs.
232+ class AbortSignal < Exception ; end # standard:disable Lint/InheritException
233+
234+ # End-to-end regression test over a real keep-alive socket, for the failure
235+ # the eviction actually prevents: request one is abandoned mid-flight, the
236+ # server then writes response one onto that socket, and request two on the
237+ # same thread reads those stale bytes as if they were its own response.
238+ #
239+ # Before the fix, request two sees marker "one" (or a mangled response) off
240+ # the pooled socket. After it, the abandoned socket is closed and the server
241+ # accepts a fresh connection for request two.
242+ def test_aborted_request_does_not_leak_its_response_to_the_next_request
243+ WebMock . disable!
244+ server = TCPServer . new ( "127.0.0.1" , 0 )
245+ port = server . addr [ 1 ]
246+ client = WorkOS ::BaseClient . new (
247+ api_key : "sk_test_123" ,
248+ base_url : "http://127.0.0.1:#{ port } " ,
249+ timeout : 5 ,
250+ max_retries : 0
251+ )
252+
253+ request_one_read = Queue . new
254+ release_response_one = Queue . new
255+ response_one_written = Queue . new
256+ aborted = Queue . new
257+ connections = Queue . new
258+
259+ server_thread = Thread . new do
260+ # Connection one: read the request, then hold the response back until
261+ # the client has been aborted and has unwound.
262+ first = server . accept
263+ connections << first
264+ read_http_request ( first )
265+ request_one_read << true
266+ release_response_one . pop
267+ begin
268+ write_http_response ( first , { marker : "one" } )
269+ rescue Errno ::EPIPE , Errno ::ECONNRESET , IOError
270+ # Expected once the fix closes the abandoned socket.
271+ end
272+ response_one_written << true
273+
274+ # Connection two: a fresh accept, which only completes because the
275+ # client did not reuse the socket above.
276+ second = server . accept
277+ connections << second
278+ read_http_request ( second )
279+ write_http_response ( second , { marker : "two" } )
280+ end
281+
282+ worker = Thread . new do
283+ begin
284+ client . execute_request ( request : Net ::HTTP ::Get . new ( "/one" ) )
285+ rescue AbortSignal
286+ # The caller unwinds but the thread survives and goes on to serve more
287+ # work, the way a Puma or Solid Queue worker does. execute_request's
288+ # `ensure` has already run by the time this body executes.
289+ aborted << true
290+ response_one_written . pop
291+ end
292+ response = client . execute_request ( request : Net ::HTTP ::Get . new ( "/two" ) )
293+ JSON . parse ( response . body ) [ "marker" ]
294+ end
295+
296+ marker = Timeout . timeout ( 15 ) do
297+ request_one_read . pop
298+ worker . raise ( AbortSignal )
299+ aborted . pop
300+ release_response_one << true
301+ worker . value
302+ end
303+
304+ assert_equal "two" , marker ,
305+ "request two read the abandoned socket's response instead of its own"
306+ ensure
307+ server_thread &.kill
308+ worker &.kill
309+ until connections . nil? || connections . empty?
310+ socket = connections . pop
311+ socket . close unless socket . closed?
312+ end
313+ server &.close
314+ WebMock . enable!
315+ end
316+
317+ def read_http_request ( socket )
318+ socket . gets # request line
319+ loop do
320+ line = socket . gets
321+ break if line . nil? || line == "\r \n "
322+ end
323+ end
324+
325+ def write_http_response ( socket , body )
326+ payload = JSON . generate ( body )
327+ socket . write (
328+ "HTTP/1.1 200 OK\r \n " \
329+ "Content-Type: application/json\r \n " \
330+ "Content-Length: #{ payload . bytesize } \r \n " \
331+ "Connection: keep-alive\r \n " \
332+ "\r \n #{ payload } "
333+ )
334+ socket . flush
335+ end
173336end
0 commit comments