From 3ec066e742de19bea286411e5cdc0182460e0804 Mon Sep 17 00:00:00 2001 From: Duane May Date: Mon, 15 Jun 2026 12:15:38 -0400 Subject: [PATCH] Update to ruby 4, with minimum 3.3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matrix → ['3.3', '3.4', '4.0'], added fail-fast: false Added required_ruby_version >= 3.3 Removed eventmachine and em-http-request runtime deps rewrite: require 'eventmachine' → require 'socket'; module Connection + EM-based Server → TCPServer + thread-per-connection with accept_loop/handle_client; EM assertions removed from Base Removed require 'em-http'; removed the two EM fiber test cases and the "on a fiber" context block --- .github/workflows/ruby.yml | 2 +- cf-uaac.gemspec | 5 +- lib/uaa/stub/server.rb | 143 ++++++++++++++++++----------------- lib/uaa/stub/uaa.rb | 6 +- spec/http_spec.rb | 119 +++++++++++++---------------- spec/spec_helper.rb | 15 +--- spec/ssl_integration_spec.rb | 1 - 7 files changed, 135 insertions(+), 156 deletions(-) diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index 51e0689..7f797e3 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -13,7 +13,7 @@ jobs: strategy: fail-fast: false matrix: - ruby-version: ['2.7', '3.2', '3.3', '3.4'] + ruby-version: ['3.3', '3.4', '4.0'] steps: - uses: actions/checkout@v6 diff --git a/cf-uaac.gemspec b/cf-uaac.gemspec index d1c2b31..a136395 100644 --- a/cf-uaac.gemspec +++ b/cf-uaac.gemspec @@ -25,13 +25,14 @@ Gem::Specification.new do |s| s.description = %q{Client command line tools for interacting with the CloudFoundry User Account and Authorization (UAA) server. The UAA is an OAuth2 Authorization Server so it can be used by webapps and command line apps to obtain access tokens to act on behalf of users. The tokens can then be used to access protected resources in a Resource Server. This library can be used by clients (as a convenient wrapper for mainstream oauth gems) or by resource servers.} s.license = 'Apache-2.0' + s.required_ruby_version = '>= 3.3' s.files = `git ls-files`.split("\n") s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } s.require_paths = ['lib'] # dependencies - s.add_runtime_dependency 'cf-uaa-lib', '~> 4.0.9' + s.add_runtime_dependency 'cf-uaa-lib', '~> 4.0.10' s.add_development_dependency 'rake', '~> 13.0' s.add_development_dependency 'rspec', '~> 3.12' s.add_development_dependency 'simplecov', '~> 0.22.0' @@ -39,9 +40,7 @@ Gem::Specification.new do |s| s.add_development_dependency 'ci_reporter', '~> 2.1.0' s.add_development_dependency 'ci_reporter_rspec', '~> 1.0' s.add_runtime_dependency 'highline', '>= 2', '< 4' - s.add_runtime_dependency 'eventmachine', '~> 1.2' s.add_runtime_dependency 'launchy', '>= 2.5', '< 4.0' - s.add_runtime_dependency 'em-http-request', '~> 1.1', '>= 1.1.2' s.add_runtime_dependency 'json', '~> 2.19', '>= 2.19.3' s.add_runtime_dependency 'rack', '~> 3.2', '>= 3.2.5' end diff --git a/lib/uaa/stub/server.rb b/lib/uaa/stub/server.rb index 25152a3..5affb60 100644 --- a/lib/uaa/stub/server.rb +++ b/lib/uaa/stub/server.rb @@ -11,7 +11,7 @@ # subcomponent's license, as noted in the LICENSE file. #++ -require 'eventmachine' +require 'socket' require 'date' require 'logger' require 'pp' @@ -33,7 +33,6 @@ def initialize; @state, @prelude = :init, "" end private def bslice(str, range) - # byteslice is available in ruby 1.9.3 str.respond_to?(:byteslice) ? str.byteslice(range) : str.slice(range) end @@ -64,7 +63,7 @@ def add_lines(str) public - # adds data to the request, returns true if request is complete + # adds data to the request, returns truthy if request is complete def completed?(str) str, @prelude = @prelude + str, "" unless @prelude.empty? add_lines(str) @@ -136,7 +135,6 @@ class Base attr_accessor :request, :reply, :match, :server def self.route(http_methods, matcher, filters = {}, &handler) - fail unless !EM.reactor_running? || EM.reactor_thread? matcher = Regexp.new("^#{Regexp.escape(matcher.to_s)}$") unless matcher.is_a?(Regexp) filters = filters.each_with_object({}) { |(k, v), o| o[k.downcase] = v.is_a?(Regexp) ? v : Regexp.new("^#{Regexp.escape(v.to_s)}$") @@ -156,7 +154,6 @@ def self.route(http_methods, matcher, filters = {}, &handler) end def self.find_route(request) - fail unless EM.reactor_thread? if @routes && (rary = @routes[request.method]) rary.each { |r; m| next unless (m = r[0].match(request.path)) @@ -204,44 +201,60 @@ def reply_in_kind(status = nil, info) end -#------------------------------------------------------------------------------ -module Connection - attr_accessor :req_handler - def unbind; req_handler.server.delete_connection(self) end - - def receive_data(data) - #req_handler.server.logger.debug "got #{data.bytesize} bytes: #{data.inspect}" - return unless req_handler.request.completed? data - req_handler.process - send_data req_handler.reply.to_s - if req_handler.reply.headers['connection'] =~ /^close$/i || req_handler.server.status != :running - close_connection_after_writing - end - rescue Exception => e - req_handler.server.logger.debug "exception from receive_data: #{e.message}" - req_handler.server.trace { e.backtrace } - close_connection - end -end - #------------------------------------------------------------------------------ class Server private - def done - fail unless @connections.empty? - EM.stop if @em_thread && EM.reactor_running? - @connections, @status, @sig, @em_thread = [], :stopped, nil, nil - sleep 0.1 unless EM.reactor_thread? # give EM a chance to stop - logger.debug EM.reactor_running?? "server done but EM still running": "server really done" + # Handle one TCP client socket: parse requests, dispatch, write replies. + # Supports keep-alive: the Request object resets itself after each + # completed? call, so the same req_handler is reused for pipelined requests. + def handle_client(socket) + req_handler = @req_handler.new(self) + loop do + # If leftover bytes from the previous request already complete the next + # one (pipelining / chunked reads), process without a blocking read. + unless req_handler.request.completed?("") + begin + data = socket.readpartial(4096) + rescue EOFError, Errno::ECONNRESET + break + end + next unless req_handler.request.completed?(data) + end + req_handler.process + socket.write(req_handler.reply.to_s) + break if req_handler.reply.headers['connection'] =~ /^close$/i || @status != :running + end + rescue Errno::ECONNRESET, Errno::EPIPE, IOError => e + logger.debug "connection error: #{e.message}" + ensure + socket.close rescue nil + @mutex.synchronize { @connections.delete(socket) } + logger.debug "connection closed" end - def initialize_connection(conn) - logger.debug "starting connection" - fail unless EM.reactor_thread? - @connections << conn - conn.req_handler, conn.comm_inactivity_timeout = @req_handler.new(self), 30 + # Accept connections in a loop until the server is stopped or the listening + # socket is closed. + def accept_loop + loop do + begin + socket = @tcp_server.accept_nonblock + @mutex.synchronize { @connections << socket } + logger.debug "starting connection" + Thread.new(socket) { |s| handle_client(s) } + rescue IO::WaitReadable, Errno::EINTR + IO.select([@tcp_server], nil, nil, 0.5) rescue nil + break if @status != :running + rescue Errno::EBADF, IOError + break + end + end + rescue => e + logger.debug "accept loop error: #{e.message}" unless e.is_a?(IOError) || e.is_a?(Errno::EBADF) + ensure + @status = :stopped + logger.debug "server really done" end public @@ -258,64 +271,54 @@ def initialize(req_handler, options) @host = options[:host] || "localhost" @init_port = options[:port] || 0 @root = options[:root] - @connections, @status, @sig, @em_thread = [], :stopped, nil, nil + @connections = [] + @mutex = Mutex.new + @status = :stopped + @server_thread = nil end def start raise ArgumentError, "attempt to start a server that's already running" unless @status == :stopped logger.debug "starting #{self.class} server #{@host}" - EM.schedule do - @sig = EM.start_server(@host, @init_port, Connection) { |c| initialize_connection(c) } - @port = Socket.unpack_sockaddr_in(EM.get_sockname(@sig))[0] - logger.info "#{self.class} server started at #{url}" - end + @tcp_server = TCPServer.new(@host, @init_port) + @port = @tcp_server.addr[1] + logger.info "#{self.class} server started at #{url}" @status = :running self end + # Start the server and run the accept loop on a background thread. + # Returns immediately; caller can use #url and #port right away. def run_on_thread - raise ArgumentError, "can't run on thread, EventMachine already running" if EM.reactor_running? - logger.debug { "starting eventmachine on thread" } - cthred = Thread.current - @em_thread = Thread.new do - begin - EM.run { start; cthred.run } - logger.debug "server thread done" - rescue Exception => e - logger.debug { "unhandled exception on stub server thread: #{e.message}" } - trace { e.backtrace } - raise - end - end - Thread.stop + raise ArgumentError, "can't run on thread, server already running" if @status == :running + logger.debug "starting server on thread" + start + @server_thread = Thread.new { accept_loop } logger.debug "running on thread" self end + # Start the server and run the accept loop on the calling thread (blocking). def run - raise ArgumentError, "can't run, EventMachine already running" if EM.reactor_running? - @em_thread = Thread.current - EM.run { start } - logger.debug "server and event machine done" + raise ArgumentError, "can't run, server already running" if @status == :running + @server_thread = Thread.current + start + accept_loop + logger.debug "server and event loop done" end - # if on reactor thread, start shutting down but return if connections still - # in process, and let them disconnect when complete -- server is not really - # done until it's status is stopped. - # if not on reactor thread, wait until everything's cleaned up and stopped + # Stop accepting new connections, close the listening socket, and wait for + # the accept loop thread to mark status :stopped. def stop logger.debug "stopping server" @status = :stopping - EM.stop_server @sig - done if @connections.empty? - sleep 0.1 while @status != :stopped unless EM.reactor_thread? + @tcp_server.close rescue nil + sleep 0.05 while @status != :stopped end def delete_connection(conn) logger.debug "deleting connection" - fail unless EM.reactor_thread? - @connections.delete(conn) - done if @status != :running && @connections.empty? + @mutex.synchronize { @connections.delete(conn) } end end diff --git a/lib/uaa/stub/uaa.rb b/lib/uaa/stub/uaa.rb index 4fd56f4..201d7a1 100644 --- a/lib/uaa/stub/uaa.rb +++ b/lib/uaa/stub/uaa.rb @@ -466,12 +466,12 @@ def obj_access?(rtype, oid, perm) route :get, %r{^/Groups/External/list(\?|$)(.*)} do return unless valid_token('scim.read') - query_params = CGI::parse(match[2]) + query_params = URI.decode_www_form(match[2]).to_h - start_index_param = query_params['startIndex'].first + start_index_param = query_params['startIndex'] || '' start_index = start_index_param.empty? ? 1 : start_index_param.to_i - count_param = query_params['count'].first + count_param = query_params['count'] || '' count = count_param.empty? ? 100 : count_param.to_i group_mappings = server.scim.get_group_mappings diff --git a/spec/http_spec.rb b/spec/http_spec.rb index cb03996..f12edab 100644 --- a/spec/http_spec.rb +++ b/spec/http_spec.rb @@ -14,7 +14,6 @@ require 'spec_helper' require 'fiber' require 'net/http' -require 'em-http' require 'uaa/http' require 'uaa/cli/version' require 'uaa/stub/server' @@ -42,56 +41,45 @@ def get(target, path = nil, headers = {}) http_get(target, path, headers) end after :all do @stub_http.stop if @stub_http end - it "gets something from stub server on a fiber" do - frequest(true) { - f = Fiber.current - http = EM::HttpRequest.new("#{@stub_http.url}/").get - http.errback { f.resume "error" } - http.callback { - http.response_header.http_status.should == 200 - f.resume http.response - } - Fiber.yield - }.should match /welcome to stub http/ - end - - it "uses persistent connections from stubserver" do - frequest(true) { - f = Fiber.current - conn = EM::HttpRequest.new("#{@stub_http.url}/") - req1 = conn.get keepalive: true - req1.errback { f.resume "error1" } - req1.callback { - req2 = conn.get - req2.errback { f.resume req2.error } - req2.callback { f.resume req2.response } - } - Fiber.yield - }.should match /welcome to stub http/ - end - it "gets something from stub server on a thread" do @async = false resp = Net::HTTP.get(URI("#{@stub_http.url}/")) resp.should match /welcome to stub http/ end + it "reuses connections to the same host (connection caching)" do + # Replaces the old EM keepalive test. The Http module caches one HTTPClient + # instance per host via @http_cache; verify multiple sequential requests + # all succeed through that cache. + client = HttpClient.new + 3.times do + status, body, _ = client.get(@stub_http.url, "/") + status.should == 200 + body.should match /welcome to stub http/ + end + end + + it "works when called from a Ruby Fiber" do + # Replaces the old EM-fiber GET test. The HTTP client is synchronous, so + # calling it from a native Fiber must work without deadlocking. + result = nil + Fiber.new { + result = begin + status, body, _ = HttpClient.new.get(@stub_http.url, "/") + [status, body] + rescue => e + e + end + }.resume + result[0].should == 200 + result[1].should match /welcome to stub http/ + end + shared_examples_for "http client" do - # the following is intended to test that a failed dns lookup will fail the - # same way on the buggy em-http-request 1.0.0.beta3 client as it does on - # the rest-client. However, some networks (such as the one I am on now) - # configure the dhcp client with a dns server that will resolve - # every name as a valid address, e.g. bad.example.bad returns an address - # to a service signup screen. I have tried stubbing the code in various - # ways: - # EventMachine.stub(:connect) { raise EventMachine::ConnectionError, "fake error for bad dns lookup" } - # EventMachine.unstub(:connect) - # Socket.stub(:gethostbyname) { raise SocketError, "getaddrinfo: Name or service not known" } - # Socket.unstub(:gethostbyname) - # This has had varied success but seems rather brittle. Currently I have opted - # to just make the domain name invalid with tildes, but this may not test - # the desired code paths + # the following is intended to test that a failed dns lookup will fail + # cleanly. Some networks resolve every name, so tildes are used to ensure + # an invalid hostname. it "fails cleanly for a failed dns lookup" do result = frequest(@on_fiber) { @client.get("http://bad~host~name/") } result.should be_an_instance_of BadTarget @@ -125,39 +113,36 @@ def debug(str = nil) ; @log << (str ? str : yield) end end end - context "on a fiber" do + context "on a thread" do before :all do - @on_fiber = true + @on_fiber = false @client = HttpClient.new - @client.set_request_handler do |url, method, body, headers| - f = Fiber.current - connection = EventMachine::HttpRequest.new(url, connect_timeout: 2, inactivity_timeout: 2) - client = connection.setup_request(method, head: headers, body: body) - - # This check is for proper error handling with em-http-request 1.0.0.beta.3 - if defined?(EventMachine::FailedConnection) && connection.is_a?(EventMachine::FailedConnection) - raise BadTarget, "HTTP connection setup error: #{client.error}" - end - - client.callback { f.resume [client.response_header.http_status, client.response, client.response_header] } - client.errback { f.resume [:error, client.error] } - result = Fiber.yield - if result[0] == :error - raise BadTarget, "connection failed" unless result[1] && result[1] != "" - raise BadTarget, "connection refused" if result[1].to_s =~ /ECONNREFUSED/ - raise BadTarget, "unable to resolve address" if /unable.*resolve.*address/.match result[1] - raise HTTPException, result[1] - end - [result[0], result[1], Util.hash_keys!(result[2], :dash)] - end end it_should_behave_like "http client" end - context "on a thread" do + # Replaces the old "on a fiber" context which ran the shared examples through + # an EventMachine async request handler via set_request_handler. + # Now exercises set_request_handler with a simple Net::HTTP backend, + # ensuring the callback interface itself works correctly. + context "with a custom request handler (Net::HTTP backend)" do before :all do @on_fiber = false @client = HttpClient.new + @client.set_request_handler do |url, method, body, headers| + uri = URI.parse(url) + http = Net::HTTP.new(uri.host, uri.port) + request = Net::HTTP::Get.new(uri.request_uri, headers) + begin + resp = http.request(request) + content_type = resp['content-type'] || '' + [resp.code.to_i, resp.body, {'content-type' => content_type}] + rescue SocketError, Errno::ECONNREFUSED, Errno::ECONNRESET => e + raise CF::UAA::BadTarget, e.message + rescue => e + raise CF::UAA::HTTPException, e.message + end + end end it_should_behave_like "http client" end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 3f964de..c2b1102 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -23,7 +23,6 @@ end require 'rspec' -require 'eventmachine' require 'uaa/stub/uaa' RSpec.configure do |config| @@ -42,16 +41,11 @@ def capture_exception e end - # Runs given block on a thread or fiber and returns result. - # If eventmachine is running on another thread, the fiber - # must be on the same thread, hence EM.schedule and the - # restriction that the given block cannot include rspec matchers. + # Runs the given block and returns the result (or a captured exception). + # The on_fiber parameter is retained for API compatibility; with EventMachine + # removed it simply executes on the current thread. def frequest(on_fiber, &blk) - return capture_exception(&blk) unless on_fiber - result, cthred = nil, Thread.current - EM.schedule { Fiber.new { result = capture_exception(&blk); cthred.run }.resume } - Thread.stop - result + capture_exception(&blk) end def setup_target(opts = {}) @@ -100,4 +94,3 @@ def cleanup_target end end - diff --git a/spec/ssl_integration_spec.rb b/spec/ssl_integration_spec.rb index 9bad818..a59a470 100644 --- a/spec/ssl_integration_spec.rb +++ b/spec/ssl_integration_spec.rb @@ -13,7 +13,6 @@ require 'spec_helper' require 'fiber' -require 'em-http' require 'uaac_cli' require 'uaa/stub/server'