From c8b3fce6c7e9237c3e21de3894a60cdbb884e584 Mon Sep 17 00:00:00 2001 From: Hugo Vacher Date: Fri, 5 Jun 2026 16:08:35 -0400 Subject: [PATCH] Make client smarter about Spring server state Add the ability for a second Spring client to connect to the server socket while the first client is still preloading the app, by retrying with an increased timeout when that situation is detected. Also handle the case where we have a PID file, but no corresponding server socket. --- lib/spring/client/run.rb | 44 ++++++++++++++-- test/support/acceptance_test.rb | 92 +++++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+), 5 deletions(-) diff --git a/lib/spring/client/run.rb b/lib/spring/client/run.rb index fcb72b19..bd9e4937 100644 --- a/lib/spring/client/run.rb +++ b/lib/spring/client/run.rb @@ -6,6 +6,7 @@ module Spring module Client class Run < Command FORWARDED_SIGNALS = %w(INT QUIT USR1 USR2 INFO WINCH) & Signal.list.keys + ServerReadTimeout = Class.new(StandardError) attr_reader :server @@ -120,13 +121,27 @@ def stop_server end def verify_server_version - unless IO.select([server], [], [], Spring.connect_timeout) - raise "Error connecting to Spring server" + begin + line = read_server_line + rescue ServerReadTimeout + if waiting_for_server_boot? + begin + # Try again, but with same timeout as booting, as server might still be booting + # from another client starting it. + line = read_server_line(Spring.boot_timeout) + rescue ServerReadTimeout + reboot_or_raise_connection_error + return + end + else + reboot_or_raise_connection_error + return + end end - line = server.gets - unless line - raise "Error connecting to Spring server" + if line.nil? + reboot_or_raise_connection_error + return end server_version = line.chomp @@ -145,6 +160,25 @@ def verify_server_version end end + def read_server_line(timeout = Spring.connect_timeout) + raise ServerReadTimeout if IO.select([server], [], [], timeout).nil? + + server.gets + end + + def waiting_for_server_boot? + !server_booted? && env.server_running? + end + + def reboot_or_raise_connection_error + if server_booted? + raise "Error connecting to Spring server" + else + stop_server + cold_run + end + end + def connect_to_application(client) server.send_io client send_json server, "args" => args, "default_rails_env" => default_rails_env, "spawn_env" => spawn_env, "reset_env" => reset_env diff --git a/test/support/acceptance_test.rb b/test/support/acceptance_test.rb index c481c531..cb7a1b92 100644 --- a/test/support/acceptance_test.rb +++ b/test/support/acceptance_test.rb @@ -1,4 +1,5 @@ require "io/wait" +require "shellwords" require "timeout" require "spring/client" require "active_support/core_ext/string/strip" @@ -716,6 +717,97 @@ def exec_name assert_failure "bin/rails runner ''", stderr: "timed out" end + test "waits for a server that is still booting the application" do + boot_wait_path = app.path("tmp/boot-wait-observed") + first_client_status_path = app.path("tmp/first-client-status") + app.path("tmp").mkpath + + File.write(app.spring_client_config, <<-RUBY.strip_heredoc) + Spring.connect_timeout = 0.5 + Spring.boot_timeout = 5 + + module BootWaitProbe + def read_server_line(timeout = Spring.connect_timeout) + if timeout == Spring.boot_timeout && ENV["EXPECT_BOOT_WAIT_PROBE"] == "1" + File.write("#{boot_wait_path}", "1") + end + super + end + end + + Spring::Client::Run.prepend(BootWaitProbe) + RUBY + File.write(app.application_config, app.application_config.read.sub("class Application < Rails::Application", <<-RUBY.strip_heredoc)) + class Application < Rails::Application + config.before_initialize { sleep 2 } + RUBY + + first_client_group = nil + first_client_command = "(bin/rails runner ''; printf $? > #{first_client_status_path.to_s.shellescape}) &" + Bundler.with_unbundled_env do + first_client_group = Process.spawn( + app.env, + "sh", + "-c", + first_client_command, + out: app.log_file, + err: app.log_file, + in: :close, + chdir: app.root.to_s, + pgroup: true, + ) + end + Process.wait(first_client_group) + + Timeout.timeout(5) do + sleep 0.05 until File.read(app.log_file.path).include?("[application:development] preloading app") + end + + assert !boot_wait_path.exist?, "first client unexpectedly retried with Spring.boot_timeout" + assert_success ["bin/rails runner ''", env: { "EXPECT_BOOT_WAIT_PROBE" => "1" }] + assert boot_wait_path.exist?, "expected second client to retry with Spring.boot_timeout" + + Timeout.timeout(5) { sleep 0.05 until first_client_status_path.exist? } + assert_equal "0", first_client_status_path.read.strip, "expected first client to succeed" + ensure + if first_client_group + begin + Process.kill("KILL", -first_client_group) + rescue Errno::ESRCH + end + end + end + + test "warns and reboots when the running server reports a different version than the client" do + version_patch = <<-RUBY.strip_heredoc + Spring.send(:remove_const, :VERSION) + Spring::VERSION = "0.0.0-fake" + RUBY + File.write("#{app.user_home}/.spring.rb", version_patch) + File.write(app.spring_client_config, version_patch) + + assert_success "bin/rails runner ''" + assert spring_env.server_running? + + File.delete("#{app.user_home}/.spring.rb") + File.delete(app.spring_client_config) + + # Warm client connects, reads "0.0.0-fake" from the running server, + # must detect the mismatch, restart, and succeed. + assert_success "bin/rails runner ''", stderr: "There is a version mismatch" + end + + test "boots a new server when a stale pidfile and socket are left on disk" do + spring_env.pidfile_path.write("999999\n") + UNIXServer.open(spring_env.socket_path).close + + assert !spring_env.server_running? + + assert_success "bin/rails runner ''" + assert spring_env.server_running? + refute_equal 999999, spring_env.pid, "expected a new server to have been started" + end + test "no warnings are shown for unsprung commands" do app.env["DISABLE_SPRING"] = "1" refute_output_includes "bin/rails runner ''", stderr: "WARN"