diff options
99 files changed, 998 insertions, 594 deletions
diff --git a/.travis.yml b/.travis.yml index f01b58ecb3..585791f757 100644 --- a/.travis.yml +++ b/.travis.yml @@ -35,10 +35,8 @@ env: matrix: - "GEM=railties" - "GEM=ap" - - "GEM=ac" - - "GEM=ac FAYE=1" + - "GEM=ac TESTOPTS=-vs26" - "GEM=ac:integration" - - "GEM=ac:integration FAYE=1" - "GEM=am,amo,as,av,aj" - "GEM=as PRESERVE_TIMEZONES=1" - "GEM=ar:mysql2" @@ -69,7 +67,6 @@ matrix: - rvm: ruby-head - rvm: jruby-9.0.5.0 - env: "GEM=ac:integration" - - env: "GEM=ac:integration FAYE=1" fast_finish: true notifications: @@ -44,7 +44,7 @@ end # Active Support. gem "dalli", ">= 2.2.1" -gem "listen", "~> 3.0.5", require: false +gem "listen", ">= 3.0.5", "< 3.2", require: false # Active Job. group :job do @@ -72,10 +72,7 @@ group :cable do gem "hiredis", require: false gem "redis", require: false - gem "faye-websocket", require: false - - # Lock to 1.1.1 until the fix for https://github.com/faye/faye/issues/394 is released - gem "faye", "1.1.1", require: false + gem "websocket-client-simple", require: false gem "blade", require: false, platforms: [:ruby] gem "blade-sauce_labs_plugin", require: false, platforms: [:ruby] diff --git a/Gemfile.lock b/Gemfile.lock index 68c75997b1..14635a8cb2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,10 +1,10 @@ GIT remote: https://github.com/QueueClassic/queue_classic.git - revision: 1ef197b9db8149a895e59077badcb5b94d4c8b44 + revision: 51d56ca6fa2fdf1eeffdffd702ae1cc0940b5156 branch: master specs: queue_classic (3.2.0.RC1) - pg (>= 0.17, < 0.19) + pg (>= 0.17, < 0.20) GIT remote: https://github.com/collectiveidea/delayed_job.git @@ -31,7 +31,7 @@ GIT GIT remote: https://github.com/resque/resque.git - revision: a3a66389618b830de0e6acf862b0dc9fde05cf49 + revision: 20d885065ac19e7f7d7a982f4ed1296083db0300 specs: resque (1.27.0) mono_logger (~> 1.0) @@ -112,8 +112,9 @@ GEM addressable (2.4.0) amq-protocol (2.0.1) arel (7.1.2) - backburner (1.3.0) + backburner (1.3.1) beaneater (~> 1.0) + concurrent-ruby (~> 1.0.1) dante (> 0.1.5) bcrypt (3.1.11) bcrypt (3.1.11-x64-mingw32) @@ -141,7 +142,7 @@ GEM builder (3.2.2) bunny (2.2.2) amq-protocol (>= 2.0.1) - byebug (9.0.5) + byebug (9.0.6) childprocess (0.5.9) ffi (~> 1.0, >= 1.0.11) coffee-rails (4.2.1) @@ -170,13 +171,14 @@ GEM em-socksify (0.3.1) eventmachine (>= 1.0.0.beta.4) erubis (2.7.0) + event_emitter (0.2.5) eventmachine (1.2.0.1) eventmachine (1.2.0.1-x64-mingw32) eventmachine (1.2.0.1-x86-mingw32) execjs (2.7.0) faraday (0.9.2) multipart-post (>= 1.2, < 3) - faye (1.1.1) + faye (1.2.2) cookiejar (>= 0.3.0) em-http-request (>= 0.3.0) eventmachine (>= 0.12.0) @@ -203,9 +205,10 @@ GEM kindlerb (0.1.1) mustache nokogiri - listen (3.0.8) + listen (3.1.5) rb-fsevent (~> 0.9, >= 0.9.4) rb-inotify (~> 0.9, >= 0.9.7) + ruby_dep (~> 1.2) loofah (2.0.3) nokogiri (>= 1.5.9) mail (2.6.4) @@ -236,9 +239,9 @@ GEM nokogiri (1.6.8-x86-mingw32) mini_portile2 (~> 2.1.0) pkg-config (~> 1.1.7) - pg (0.18.4) - pg (0.18.4-x64-mingw32) - pg (0.18.4-x86-mingw32) + pg (0.19.0) + pg (0.19.0-x64-mingw32) + pg (0.19.0-x86-mingw32) pkg-config (1.1.7) psych (2.1.1) puma (3.6.0) @@ -262,7 +265,7 @@ GEM nokogiri (~> 1.6.0) rails-html-sanitizer (1.0.3) loofah (~> 2.0) - rake (11.2.2) + rake (11.3.0) rb-fsevent (0.9.7) rdoc (4.2.2) json (~> 1.4) @@ -275,6 +278,7 @@ GEM redis (~> 3.3) resque (~> 1.26) rufus-scheduler (~> 3.2) + ruby_dep (1.4.0) rubyzip (1.2.0) rufus-scheduler (3.2.2) sass-rails (5.0.6) @@ -290,10 +294,10 @@ GEM childprocess (~> 0.5) rubyzip (~> 1.0) websocket (~> 1.0) - sequel (4.38.0) + sequel (4.39.0) serverengine (1.5.11) sigdump (~> 0.2.2) - sidekiq (4.2.0) + sidekiq (4.2.2) concurrent-ruby (~> 1.0) connection_pool (~> 2.2, >= 2.2.0) rack-protection (~> 1.5) @@ -318,7 +322,7 @@ GEM sqlite3 (1.3.11) sqlite3 (1.3.11-x64-mingw32) sqlite3 (1.3.11-x86-mingw32) - stackprof (0.2.9) + stackprof (0.2.10) sucker_punch (2.0.2) concurrent-ruby (~> 1.0.0) thin (1.7.0) @@ -334,7 +338,7 @@ GEM turbolinks-source (5.0.0) tzinfo (1.2.2) thread_safe (~> 0.1) - tzinfo-data (1.2016.6) + tzinfo-data (1.2016.7) tzinfo (>= 1.0.0) uglifier (3.0.2) execjs (>= 0.3.0, < 3) @@ -346,6 +350,9 @@ GEM nokogiri wdm (0.1.1) websocket (1.2.3) + websocket-client-simple (0.3.0) + event_emitter + websocket websocket-driver (0.6.4) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.2) @@ -370,12 +377,10 @@ DEPENDENCIES delayed_job! delayed_job_active_record! em-hiredis - faye (= 1.1.1) - faye-websocket hiredis jquery-rails kindlerb (= 0.1.1) - listen (~> 3.0.5) + listen (>= 3.0.5, < 3.2) minitest (< 5.3.4) mocha (~> 0.14) mysql2 (>= 0.4.4) @@ -409,6 +414,7 @@ DEPENDENCIES uglifier (>= 1.3.0) w3c_validators wdm (>= 0.1.0) + websocket-client-simple BUNDLED WITH - 1.13.1 + 1.13.2 @@ -23,9 +23,6 @@ task default: %w(test test:isolated) FRAMEWORKS.each do |project| system(%(cd #{project} && #{$0} #{task_name} --trace)) || errors << project end - if task_name =~ /test/ - system(%(cd actioncable && env FAYE=1 #{$0} #{task_name} --trace)) || errors << "actioncable-faye" - end fail("Errors in #{errors.join(', ')}") unless errors.empty? end end @@ -36,7 +33,6 @@ task :smoke do system %(cd #{project} && #{$0} test:isolated --trace) end system %(cd activerecord && #{$0} sqlite3:isolated_test --trace) - system %(cd actioncable && env FAYE=1 #{$0} test:isolated --trace) end desc "Install gems for all projects." diff --git a/actioncable/CHANGELOG.md b/actioncable/CHANGELOG.md index dec6f7c027..137c88d91b 100644 --- a/actioncable/CHANGELOG.md +++ b/actioncable/CHANGELOG.md @@ -1,3 +1,16 @@ +* Prevent race where the client could receive and act upon a + subscription confirmation before the channel's `subscribed` method + completed. + + Fixes #25381. + + *Vladimir Dementyev* + +* Buffer writes to websocket connections, to avoid blocking threads + that could be doing more useful things. + + *Matthew Draper*, *Tinco Andringa* + * Protect against concurrent writes to a websocket connection from multiple threads; the underlying OS write is not always threadsafe. diff --git a/actioncable/README.md b/actioncable/README.md index 28e2602cbf..a0b7412dd4 100644 --- a/actioncable/README.md +++ b/actioncable/README.md @@ -167,7 +167,7 @@ App.cable.subscriptions.create "AppearanceChannel", buttonSelector = "[data-behavior~=appear_away]" install: -> - $(document).on "page:change.appearance", => + $(document).on "turbolinks:load.appearance", => @appear() $(document).on "click.appearance", buttonSelector, => diff --git a/actioncable/lib/action_cable/channel/base.rb b/actioncable/lib/action_cable/channel/base.rb index 2e589a2cfa..a866044f95 100644 --- a/actioncable/lib/action_cable/channel/base.rb +++ b/actioncable/lib/action_cable/channel/base.rb @@ -144,13 +144,14 @@ module ActionCable # When a channel is streaming via pubsub, we want to delay the confirmation # transmission until pubsub subscription is confirmed. - @defer_subscription_confirmation = false + # + # The counter starts at 1 because it's awaiting a call to #subscribe_to_channel + @defer_subscription_confirmation_counter = Concurrent::AtomicFixnum.new(1) @reject_subscription = nil @subscription_confirmation_sent = nil delegate_connection_identifiers - subscribe_to_channel end # Extract the action name from the passed data and process it via the channel. The process will ensure @@ -169,6 +170,17 @@ module ActionCable end end + # This method is called after subscription has been added to the connection + # and confirms or rejects the subscription. + def subscribe_to_channel + run_callbacks :subscribe do + subscribed + end + + reject_subscription if subscription_rejected? + ensure_confirmation_sent + end + # Called by the cable connection when it's cut, so the channel has a chance to cleanup with callbacks. # This method is not intended to be called directly by the user. Instead, overwrite the #unsubscribed callback. def unsubscribe_from_channel # :nodoc: @@ -201,12 +213,18 @@ module ActionCable end end + def ensure_confirmation_sent + return if subscription_rejected? + @defer_subscription_confirmation_counter.decrement + transmit_subscription_confirmation unless defer_subscription_confirmation? + end + def defer_subscription_confirmation! - @defer_subscription_confirmation = true + @defer_subscription_confirmation_counter.increment end def defer_subscription_confirmation? - @defer_subscription_confirmation + @defer_subscription_confirmation_counter.value > 0 end def subscription_confirmation_sent? @@ -230,18 +248,6 @@ module ActionCable end end - def subscribe_to_channel - run_callbacks :subscribe do - subscribed - end - - if subscription_rejected? - reject_subscription - else - transmit_subscription_confirmation unless defer_subscription_confirmation? - end - end - def extract_action(data) (data["action"].presence || :receive).to_sym end diff --git a/actioncable/lib/action_cable/channel/streams.rb b/actioncable/lib/action_cable/channel/streams.rb index 13deb62662..dbba333353 100644 --- a/actioncable/lib/action_cable/channel/streams.rb +++ b/actioncable/lib/action_cable/channel/streams.rb @@ -84,7 +84,7 @@ module ActionCable connection.server.event_loop.post do pubsub.subscribe(broadcasting, handler, lambda do - transmit_subscription_confirmation + ensure_confirmation_sent logger.info "#{self.class.name} is streaming from #{broadcasting}" end) end diff --git a/actioncable/lib/action_cable/connection.rb b/actioncable/lib/action_cable/connection.rb index 5f813cf8e0..902efb07e2 100644 --- a/actioncable/lib/action_cable/connection.rb +++ b/actioncable/lib/action_cable/connection.rb @@ -8,8 +8,6 @@ module ActionCable autoload :ClientSocket autoload :Identification autoload :InternalChannel - autoload :FayeClientSocket - autoload :FayeEventLoop autoload :MessageBuffer autoload :Stream autoload :StreamEventLoop diff --git a/actioncable/lib/action_cable/connection/base.rb b/actioncable/lib/action_cable/connection/base.rb index 4c7fcc1434..06f4f5edd3 100644 --- a/actioncable/lib/action_cable/connection/base.rb +++ b/actioncable/lib/action_cable/connection/base.rb @@ -57,7 +57,7 @@ module ActionCable @worker_pool = server.worker_pool @logger = new_tagged_logger - @websocket = ActionCable::Connection::WebSocket.new(env, self, event_loop, server.config.client_socket_class) + @websocket = ActionCable::Connection::WebSocket.new(env, self, event_loop) @subscriptions = ActionCable::Connection::Subscriptions.new(self) @message_buffer = ActionCable::Connection::MessageBuffer.new(self) diff --git a/actioncable/lib/action_cable/connection/faye_client_socket.rb b/actioncable/lib/action_cable/connection/faye_client_socket.rb deleted file mode 100644 index 06e92c5d52..0000000000 --- a/actioncable/lib/action_cable/connection/faye_client_socket.rb +++ /dev/null @@ -1,48 +0,0 @@ -require "faye/websocket" - -module ActionCable - module Connection - class FayeClientSocket - def initialize(env, event_target, stream_event_loop, protocols) - @env = env - @event_target = event_target - @protocols = protocols - - @faye = nil - end - - def alive? - @faye && @faye.ready_state == Faye::WebSocket::API::OPEN - end - - def transmit(data) - connect - @faye.send data - end - - def close - @faye && @faye.close - end - - def protocol - @faye && @faye.protocol - end - - def rack_response - connect - @faye.rack_response - end - - private - def connect - return if @faye - @faye = Faye::WebSocket.new(@env, @protocols) - - @faye.on(:open) { |event| @event_target.on_open } - @faye.on(:message) { |event| @event_target.on_message(event.data) } - @faye.on(:close) { |event| @event_target.on_close(event.reason, event.code) } - @faye.on(:error) { |event| @event_target.on_error(event.message) } - end - end - end -end diff --git a/actioncable/lib/action_cable/connection/faye_event_loop.rb b/actioncable/lib/action_cable/connection/faye_event_loop.rb deleted file mode 100644 index cfbe26ee6a..0000000000 --- a/actioncable/lib/action_cable/connection/faye_event_loop.rb +++ /dev/null @@ -1,44 +0,0 @@ -require "thread" - -require "eventmachine" -EventMachine.epoll if EventMachine.epoll? -EventMachine.kqueue if EventMachine.kqueue? - -module ActionCable - module Connection - class FayeEventLoop - @@mutex = Mutex.new - - def timer(interval, &block) - ensure_reactor_running - EMTimer.new(::EM::PeriodicTimer.new(interval, &block)) - end - - def post(task = nil, &block) - task ||= block - - ensure_reactor_running - ::EM.next_tick(&task) - end - - private - def ensure_reactor_running - return if EventMachine.reactor_running? - @@mutex.synchronize do - Thread.new { EventMachine.run } unless EventMachine.reactor_running? - Thread.pass until EventMachine.reactor_running? - end - end - - class EMTimer - def initialize(inner) - @inner = inner - end - - def shutdown - @inner.cancel - end - end - end - end -end diff --git a/actioncable/lib/action_cable/connection/stream.rb b/actioncable/lib/action_cable/connection/stream.rb index 5a2aace0ba..d66e1b4e41 100644 --- a/actioncable/lib/action_cable/connection/stream.rb +++ b/actioncable/lib/action_cable/connection/stream.rb @@ -14,6 +14,9 @@ module ActionCable @rack_hijack_io = nil @write_lock = Mutex.new + + @write_head = nil + @write_buffer = Queue.new end def each(&callback) @@ -30,14 +33,62 @@ module ActionCable end def write(data) - @write_lock.synchronize do - return @rack_hijack_io.write(data) if @rack_hijack_io - return @stream_send.call(data) if @stream_send + if @stream_send + return @stream_send.call(data) end + + if @write_lock.try_lock + begin + if @write_head.nil? && @write_buffer.empty? + written = @rack_hijack_io.write_nonblock(data, exception: false) + + case written + when :wait_writable + # proceed below + when data.bytesize + return data.bytesize + else + @write_head = data.byteslice(written, data.bytesize) + @event_loop.writes_pending @rack_hijack_io + + return data.bytesize + end + end + ensure + @write_lock.unlock + end + end + + @write_buffer << data + @event_loop.writes_pending @rack_hijack_io + + data.bytesize rescue EOFError, Errno::ECONNRESET @socket_object.client_gone end + def flush_write_buffer + @write_lock.synchronize do + loop do + if @write_head.nil? + return true if @write_buffer.empty? + @write_head = @write_buffer.pop + end + + written = @rack_hijack_io.write_nonblock(@write_head, exception: false) + case written + when :wait_writable + return false + when @write_head.bytesize + @write_head = nil + else + @write_head = @write_head.byteslice(written, @write_head.bytesize) + return false + end + end + end + end + def receive(data) @socket_object.parse(data) end diff --git a/actioncable/lib/action_cable/connection/stream_event_loop.rb b/actioncable/lib/action_cable/connection/stream_event_loop.rb index 106b948c45..eec24638b6 100644 --- a/actioncable/lib/action_cable/connection/stream_event_loop.rb +++ b/actioncable/lib/action_cable/connection/stream_event_loop.rb @@ -5,7 +5,7 @@ module ActionCable module Connection class StreamEventLoop def initialize - @nio = @thread = nil + @nio = @executor = @thread = nil @map = {} @stopping = false @todo = Queue.new @@ -20,13 +20,14 @@ module ActionCable def post(task = nil, &block) task ||= block - Concurrent.global_io_executor << task + spawn + @executor << task end def attach(io, stream) @todo << lambda do - @map[io] = stream - @nio.register(io, :r) + @map[io] = @nio.register(io, :r) + @map[io].value = stream end wakeup end @@ -39,6 +40,15 @@ module ActionCable wakeup end + def writes_pending(io) + @todo << lambda do + if monitor = @map[io] + monitor.interests = :rw + end + end + wakeup + end + def stop @stopping = true wakeup if @nio @@ -52,6 +62,13 @@ module ActionCable return if @thread && @thread.status @nio ||= NIO::Selector.new + + @executor ||= Concurrent::ThreadPoolExecutor.new( + min_threads: 1, + max_threads: 10, + max_queue: 0, + ) + @thread = Thread.new { run } return true @@ -77,12 +94,25 @@ module ActionCable monitors.each do |monitor| io = monitor.io - stream = @map[io] + stream = monitor.value begin - stream.receive io.read_nonblock(4096) - rescue IO::WaitReadable - next + if monitor.writable? + if stream.flush_write_buffer + monitor.interests = :r + end + next unless monitor.readable? + end + + incoming = io.read_nonblock(4096, exception: false) + case incoming + when :wait_readable + next + when nil + stream.close + else + stream.receive incoming + end rescue # We expect one of EOFError or Errno::ECONNRESET in # normal operation (when the client goes away). But if diff --git a/actioncable/lib/action_cable/connection/subscriptions.rb b/actioncable/lib/action_cable/connection/subscriptions.rb index 9060183249..00511aead5 100644 --- a/actioncable/lib/action_cable/connection/subscriptions.rb +++ b/actioncable/lib/action_cable/connection/subscriptions.rb @@ -26,10 +26,14 @@ module ActionCable id_key = data["identifier"] id_options = ActiveSupport::JSON.decode(id_key).with_indifferent_access + return if subscriptions.key?(id_key) + subscription_klass = id_options[:channel].safe_constantize if subscription_klass && ActionCable::Channel::Base >= subscription_klass - subscriptions[id_key] ||= subscription_klass.new(connection, id_key, id_options) + subscription = subscription_klass.new(connection, id_key, id_options) + subscriptions[id_key] = subscription + subscription.subscribe_to_channel else logger.error "Subscription class not found: #{id_options[:channel].inspect}" end diff --git a/actioncable/lib/action_cable/connection/web_socket.rb b/actioncable/lib/action_cable/connection/web_socket.rb index 52d8daad4b..382141b89f 100644 --- a/actioncable/lib/action_cable/connection/web_socket.rb +++ b/actioncable/lib/action_cable/connection/web_socket.rb @@ -4,8 +4,8 @@ module ActionCable module Connection # Wrap the real socket to minimize the externally-presented API class WebSocket - def initialize(env, event_target, event_loop, client_socket_class, protocols: ActionCable::INTERNAL[:protocols]) - @websocket = ::WebSocket::Driver.websocket?(env) ? client_socket_class.new(env, event_target, event_loop, protocols) : nil + def initialize(env, event_target, event_loop, protocols: ActionCable::INTERNAL[:protocols]) + @websocket = ::WebSocket::Driver.websocket?(env) ? ClientSocket.new(env, event_target, event_loop, protocols) : nil end def possible? diff --git a/actioncable/lib/action_cable/server/base.rb b/actioncable/lib/action_cable/server/base.rb index dd059a553b..419eccd73c 100644 --- a/actioncable/lib/action_cable/server/base.rb +++ b/actioncable/lib/action_cable/server/base.rb @@ -37,9 +37,13 @@ module ActionCable connections.each(&:close) @mutex.synchronize do - worker_pool.halt if @worker_pool - + # Shutdown the worker pool + @worker_pool.halt if @worker_pool @worker_pool = nil + + # Shutdown the pub/sub adapter + @pubsub.shutdown if @pubsub + @pubsub = nil end end @@ -49,7 +53,7 @@ module ActionCable end def event_loop - @event_loop || @mutex.synchronize { @event_loop ||= config.event_loop_class.new } + @event_loop || @mutex.synchronize { @event_loop ||= ActionCable::Connection::StreamEventLoop.new } end # The worker pool is where we run connection callbacks and channel actions. We do as little as possible on the server's main thread. diff --git a/actioncable/lib/action_cable/server/configuration.rb b/actioncable/lib/action_cable/server/configuration.rb index 7153593d4c..dc146f07b0 100644 --- a/actioncable/lib/action_cable/server/configuration.rb +++ b/actioncable/lib/action_cable/server/configuration.rb @@ -4,7 +4,7 @@ module ActionCable # in a Rails config initializer. class Configuration attr_accessor :logger, :log_tags - attr_accessor :use_faye, :connection_class, :worker_pool_size + attr_accessor :connection_class, :worker_pool_size attr_accessor :disable_request_forgery_protection, :allowed_request_origins attr_accessor :cable, :url, :mount_path @@ -35,22 +35,6 @@ module ActionCable adapter = "PostgreSQL" if adapter == "Postgresql" "ActionCable::SubscriptionAdapter::#{adapter}".constantize end - - def event_loop_class - if use_faye - ActionCable::Connection::FayeEventLoop - else - ActionCable::Connection::StreamEventLoop - end - end - - def client_socket_class - if use_faye - ActionCable::Connection::FayeClientSocket - else - ActionCable::Connection::ClientSocket - end - end end end end diff --git a/actioncable/lib/action_cable/server/worker.rb b/actioncable/lib/action_cable/server/worker.rb index 7460472551..43639c27af 100644 --- a/actioncable/lib/action_cable/server/worker.rb +++ b/actioncable/lib/action_cable/server/worker.rb @@ -25,7 +25,7 @@ module ActionCable # Stop processing work: any work that has not already started # running will be discarded from the queue def halt - @executor.kill + @executor.shutdown end def stopping? diff --git a/actioncable/test/channel/base_test.rb b/actioncable/test/channel/base_test.rb index 2bb3214f74..9a3a3581e6 100644 --- a/actioncable/test/channel/base_test.rb +++ b/actioncable/test/channel/base_test.rb @@ -77,11 +77,13 @@ class ActionCable::Channel::BaseTest < ActiveSupport::TestCase @channel = ChatChannel.new @connection, "{id: 1}", id: 1 end - test "should subscribe to a channel on initialize" do + test "should subscribe to a channel" do + @channel.subscribe_to_channel assert_equal 1, @channel.room.id end test "on subscribe callbacks" do + @channel.subscribe_to_channel assert @channel.subscribed end @@ -90,6 +92,8 @@ class ActionCable::Channel::BaseTest < ActiveSupport::TestCase end test "unsubscribing from a channel" do + @channel.subscribe_to_channel + assert @channel.room assert @channel.subscribed? @@ -150,8 +154,13 @@ class ActionCable::Channel::BaseTest < ActiveSupport::TestCase assert_equal expected, @connection.last_transmission end - test "subscription confirmation" do + test "do not send subscription confirmation on initialize" do + assert_nil @connection.last_transmission + end + + test "subscription confirmation on subscribe_to_channel" do expected = { "identifier" => "{id: 1}", "type" => "confirm_subscription" } + @channel.subscribe_to_channel assert_equal expected, @connection.last_transmission end @@ -208,6 +217,8 @@ class ActionCable::Channel::BaseTest < ActiveSupport::TestCase test "notification for transmit_subscription_confirmation" do begin + @channel.subscribe_to_channel + events = [] ActiveSupport::Notifications.subscribe "transmit_subscription_confirmation.action_cable" do |*args| events << ActiveSupport::Notifications::Event.new(*args) diff --git a/actioncable/test/channel/periodic_timers_test.rb b/actioncable/test/channel/periodic_timers_test.rb index 529abb9535..2ee711fd29 100644 --- a/actioncable/test/channel/periodic_timers_test.rb +++ b/actioncable/test/channel/periodic_timers_test.rb @@ -62,6 +62,7 @@ class ActionCable::Channel::PeriodicTimersTest < ActiveSupport::TestCase @connection.server.event_loop.expects(:timer).times(3).returns(stub(shutdown: nil)) channel = ChatChannel.new @connection, "{id: 1}", id: 1 + channel.subscribe_to_channel channel.unsubscribe_from_channel assert_equal [], channel.send(:active_periodic_timers) end diff --git a/actioncable/test/channel/rejection_test.rb b/actioncable/test/channel/rejection_test.rb index faf35ad048..99c4a7603a 100644 --- a/actioncable/test/channel/rejection_test.rb +++ b/actioncable/test/channel/rejection_test.rb @@ -20,6 +20,7 @@ class ActionCable::Channel::RejectionTest < ActiveSupport::TestCase test "subscription rejection" do @connection.expects(:subscriptions).returns mock().tap { |m| m.expects(:remove_subscription).with instance_of(SecretChannel) } @channel = SecretChannel.new @connection, "{id: 1}", id: 1 + @channel.subscribe_to_channel expected = { "identifier" => "{id: 1}", "type" => "reject_subscription" } assert_equal expected, @connection.last_transmission @@ -28,6 +29,7 @@ class ActionCable::Channel::RejectionTest < ActiveSupport::TestCase test "does not execute action if subscription is rejected" do @connection.expects(:subscriptions).returns mock().tap { |m| m.expects(:remove_subscription).with instance_of(SecretChannel) } @channel = SecretChannel.new @connection, "{id: 1}", id: 1 + @channel.subscribe_to_channel expected = { "identifier" => "{id: 1}", "type" => "reject_subscription" } assert_equal expected, @connection.last_transmission diff --git a/actioncable/test/channel/stream_test.rb b/actioncable/test/channel/stream_test.rb index da26f81a5c..31dcde2e29 100644 --- a/actioncable/test/channel/stream_test.rb +++ b/actioncable/test/channel/stream_test.rb @@ -53,6 +53,7 @@ module ActionCable::StreamTests connection = TestConnection.new connection.expects(:pubsub).returns mock().tap { |m| m.expects(:subscribe).with("test_room_1", kind_of(Proc), kind_of(Proc)).returns stub_everything(:pubsub) } channel = ChatChannel.new connection, "{id: 1}", id: 1 + channel.subscribe_to_channel connection.expects(:pubsub).returns mock().tap { |m| m.expects(:unsubscribe) } channel.unsubscribe_from_channel @@ -64,6 +65,7 @@ module ActionCable::StreamTests connection = TestConnection.new connection.expects(:pubsub).returns mock().tap { |m| m.expects(:subscribe).with("channel", kind_of(Proc), kind_of(Proc)).returns stub_everything(:pubsub) } channel = SymbolChannel.new connection, "" + channel.subscribe_to_channel connection.expects(:pubsub).returns mock().tap { |m| m.expects(:unsubscribe) } channel.unsubscribe_from_channel @@ -76,6 +78,7 @@ module ActionCable::StreamTests connection.expects(:pubsub).returns mock().tap { |m| m.expects(:subscribe).with("action_cable:stream_tests:chat:Room#1-Campfire", kind_of(Proc), kind_of(Proc)).returns stub_everything(:pubsub) } channel = ChatChannel.new connection, "" + channel.subscribe_to_channel channel.stream_for Room.new(1) end end @@ -84,7 +87,9 @@ module ActionCable::StreamTests run_in_eventmachine do connection = TestConnection.new - ChatChannel.new connection, "{id: 1}", id: 1 + channel = ChatChannel.new connection, "{id: 1}", id: 1 + channel.subscribe_to_channel + assert_nil connection.last_transmission wait_for_async @@ -114,7 +119,7 @@ module ActionCable::StreamTests end end - require "action_cable/subscription_adapter/inline" + require "action_cable/subscription_adapter/async" class UserCallbackChannel < ActionCable::Channel::Base def subscribed @@ -124,9 +129,16 @@ module ActionCable::StreamTests end end - class StreamEncodingTest < ActionCable::TestCase + class MultiChatChannel < ActionCable::Channel::Base + def subscribed + stream_from "main_room" + stream_from "test_all_rooms" + end + end + + class StreamFromTest < ActionCable::TestCase setup do - @server = TestServer.new(subscription_adapter: ActionCable::SubscriptionAdapter::Inline) + @server = TestServer.new(subscription_adapter: ActionCable::SubscriptionAdapter::Async) @server.config.allowed_request_origins = %w( http://rubyonrails.com ) end @@ -153,6 +165,17 @@ module ActionCable::StreamTests end end + test "subscription confirmation should only be sent out once with muptiple stream_from" do + run_in_eventmachine do + connection = open_connection + expected = { "identifier" => { "channel" => MultiChatChannel.name }.to_json, "type" => "confirm_subscription" } + connection.websocket.expects(:transmit).with(expected.to_json) + receive(connection, command: "subscribe", channel: MultiChatChannel.name, identifiers: {}) + + wait_for_async + end + end + private def subscribe_to(connection, identifiers:) receive connection, command: "subscribe", identifiers: identifiers diff --git a/actioncable/test/client_test.rb b/actioncable/test/client_test.rb index 2e3821828f..f6d4ab3202 100644 --- a/actioncable/test/client_test.rb +++ b/actioncable/test/client_test.rb @@ -1,13 +1,38 @@ require "test_helper" require "concurrent" -require "faye/websocket" +require "websocket-client-simple" require "json" require "active_support/hash_with_indifferent_access" +#### +# 😷 Warning suppression 😷 +WebSocket::Frame::Handler::Handler03.prepend Module.new { + def initialize(*) + @application_data_buffer = nil + super + end +} + +WebSocket::Frame::Data.prepend Module.new { + def initialize(*) + @masking_key = nil + super + end +} + +WebSocket::Client::Simple::Client.prepend Module.new { + def initialize(*) + @socket = nil + super + end +} +# +#### + class ClientTest < ActionCable::TestCase - WAIT_WHEN_EXPECTING_EVENT = 8 + WAIT_WHEN_EXPECTING_EVENT = 2 WAIT_WHEN_NOT_EXPECTING_EVENT = 0.5 class EchoChannel < ActionCable::Channel::Base @@ -39,20 +64,9 @@ class ClientTest < ActionCable::TestCase server.config.logger = Logger.new(StringIO.new).tap { |l| l.level = Logger::UNKNOWN } server.config.cable = ActiveSupport::HashWithIndifferentAccess.new(adapter: "async") - server.config.use_faye = ENV["FAYE"].present? # and now the "real" setup for our test: server.config.disable_request_forgery_protection = true - - Thread.new { EventMachine.run } unless EventMachine.reactor_running? - Thread.pass until EventMachine.reactor_running? - - # faye-websocket is warning-rich - @previous_verbose, $VERBOSE = $VERBOSE, nil - end - - def teardown - $VERBOSE = @previous_verbose end def with_puma_server(rack_app = ActionCable.server, port = 3099) @@ -73,44 +87,49 @@ class ClientTest < ActionCable::TestCase attr_reader :pings def initialize(port) - @ws = Faye::WebSocket::Client.new("ws://127.0.0.1:#{port}/") - @messages = Queue.new - @closed = Concurrent::Event.new - @has_messages = Concurrent::Semaphore.new(0) - @pings = 0 - - open = Concurrent::Event.new - error = nil - - @ws.on(:error) do |event| - if open.set? - @messages << RuntimeError.new(event.message) - else - error = event.message - open.set + messages = @messages = Queue.new + closed = @closed = Concurrent::Event.new + has_messages = @has_messages = Concurrent::Semaphore.new(0) + pings = @pings = Concurrent::AtomicFixnum.new(0) + + open = Concurrent::Promise.new + + @ws = WebSocket::Client::Simple.connect("ws://127.0.0.1:#{port}/") do |ws| + ws.on(:error) do |event| + event = RuntimeError.new(event.message) unless event.is_a?(Exception) + + if open.pending? + open.fail(event) + else + messages << event + has_messages.release + end end - end - @ws.on(:open) do |event| - open.set - end + ws.on(:open) do |event| + open.set(true) + end - @ws.on(:message) do |event| - message = JSON.parse(event.data) - if message["type"] == "ping" - @pings += 1 - else - @messages << message - @has_messages.release + ws.on(:message) do |event| + if event.type == :close + closed.set + else + message = JSON.parse(event.data) + if message["type"] == "ping" + pings.increment + else + messages << message + has_messages.release + end + end end - end - @ws.on(:close) do |event| - @closed.set + ws.on(:close) do |event| + closed.set + end end - open.wait(WAIT_WHEN_EXPECTING_EVENT) - raise error if error + open.wait!(WAIT_WHEN_EXPECTING_EVENT) end def read_message @@ -161,13 +180,17 @@ class ClientTest < ActionCable::TestCase end end - def faye_client(port) + def websocket_client(port) SyncClient.new(port) end + def concurrently(enum) + enum.map { |*x| Concurrent::Future.execute { yield(*x) } }.map(&:value!) + end + def test_single_client with_puma_server do |port| - c = faye_client(port) + c = websocket_client(port) assert_equal({ "type" => "welcome" }, c.read_message) # pop the first welcome message off the stack c.send_message command: "subscribe", identifier: JSON.generate(channel: "ClientTest::EchoChannel") assert_equal({ "identifier"=>"{\"channel\":\"ClientTest::EchoChannel\"}", "type"=>"confirm_subscription" }, c.read_message) @@ -179,12 +202,12 @@ class ClientTest < ActionCable::TestCase def test_interacting_clients with_puma_server do |port| - clients = 10.times.map { faye_client(port) } + clients = concurrently(10.times) { websocket_client(port) } barrier_1 = Concurrent::CyclicBarrier.new(clients.size) barrier_2 = Concurrent::CyclicBarrier.new(clients.size) - clients.map { |c| Concurrent::Future.execute { + concurrently(clients) do |c| assert_equal({ "type" => "welcome" }, c.read_message) # pop the first welcome message off the stack c.send_message command: "subscribe", identifier: JSON.generate(channel: "ClientTest::EchoChannel") assert_equal({ "identifier"=>'{"channel":"ClientTest::EchoChannel"}', "type"=>"confirm_subscription" }, c.read_message) @@ -194,38 +217,38 @@ class ClientTest < ActionCable::TestCase c.send_message command: "message", identifier: JSON.generate(channel: "ClientTest::EchoChannel"), data: JSON.generate(action: "bulk", message: "hello") barrier_2.wait WAIT_WHEN_EXPECTING_EVENT assert_equal clients.size, c.read_messages(clients.size).size - } }.each(&:wait!) + end - clients.map { |c| Concurrent::Future.execute { c.close } }.each(&:wait!) + concurrently(clients, &:close) end end def test_many_clients with_puma_server do |port| - clients = 100.times.map { faye_client(port) } + clients = concurrently(100.times) { websocket_client(port) } - clients.map { |c| Concurrent::Future.execute { + concurrently(clients) do |c| assert_equal({ "type" => "welcome" }, c.read_message) # pop the first welcome message off the stack c.send_message command: "subscribe", identifier: JSON.generate(channel: "ClientTest::EchoChannel") assert_equal({ "identifier"=>'{"channel":"ClientTest::EchoChannel"}', "type"=>"confirm_subscription" }, c.read_message) c.send_message command: "message", identifier: JSON.generate(channel: "ClientTest::EchoChannel"), data: JSON.generate(action: "ding", message: "hello") assert_equal({ "identifier"=>'{"channel":"ClientTest::EchoChannel"}', "message"=>{ "dong"=>"hello" } }, c.read_message) - } }.each(&:wait!) + end - clients.map { |c| Concurrent::Future.execute { c.close } }.each(&:wait!) + concurrently(clients, &:close) end end def test_disappearing_client with_puma_server do |port| - c = faye_client(port) + c = websocket_client(port) assert_equal({ "type" => "welcome" }, c.read_message) # pop the first welcome message off the stack c.send_message command: "subscribe", identifier: JSON.generate(channel: "ClientTest::EchoChannel") assert_equal({ "identifier"=>"{\"channel\":\"ClientTest::EchoChannel\"}", "type"=>"confirm_subscription" }, c.read_message) c.send_message command: "message", identifier: JSON.generate(channel: "ClientTest::EchoChannel"), data: JSON.generate(action: "delay", message: "hello") c.close # disappear before write - c = faye_client(port) + c = websocket_client(port) assert_equal({ "type" => "welcome" }, c.read_message) # pop the first welcome message off the stack c.send_message command: "subscribe", identifier: JSON.generate(channel: "ClientTest::EchoChannel") assert_equal({ "identifier"=>"{\"channel\":\"ClientTest::EchoChannel\"}", "type"=>"confirm_subscription" }, c.read_message) @@ -240,7 +263,7 @@ class ClientTest < ActionCable::TestCase app = ActionCable.server identifier = JSON.generate(channel: "ClientTest::EchoChannel") - c = faye_client(port) + c = websocket_client(port) assert_equal({ "type" => "welcome" }, c.read_message) c.send_message command: "subscribe", identifier: identifier assert_equal({ "identifier"=>"{\"channel\":\"ClientTest::EchoChannel\"}", "type"=>"confirm_subscription" }, c.read_message) @@ -261,7 +284,7 @@ class ClientTest < ActionCable::TestCase def test_server_restart with_puma_server do |port| - c = faye_client(port) + c = websocket_client(port) assert_equal({ "type" => "welcome" }, c.read_message) c.send_message command: "subscribe", identifier: JSON.generate(channel: "ClientTest::EchoChannel") assert_equal({ "identifier"=>"{\"channel\":\"ClientTest::EchoChannel\"}", "type"=>"confirm_subscription" }, c.read_message) diff --git a/actioncable/test/connection/client_socket_test.rb b/actioncable/test/connection/client_socket_test.rb index 5043a63370..dff7fefbfb 100644 --- a/actioncable/test/connection/client_socket_test.rb +++ b/actioncable/test/connection/client_socket_test.rb @@ -33,8 +33,6 @@ class ActionCable::Connection::ClientSocketTest < ActionCable::TestCase end test "delegate socket errors to on_error handler" do - skip if ENV["FAYE"].present? - run_in_eventmachine do connection = open_connection @@ -49,8 +47,6 @@ class ActionCable::Connection::ClientSocketTest < ActionCable::TestCase end test "closes hijacked i/o socket at shutdown" do - skip if ENV["FAYE"].present? - run_in_eventmachine do connection = open_connection diff --git a/actioncable/test/connection/stream_test.rb b/actioncable/test/connection/stream_test.rb index 4128b32f15..36e1d3c095 100644 --- a/actioncable/test/connection/stream_test.rb +++ b/actioncable/test/connection/stream_test.rb @@ -34,8 +34,6 @@ class ActionCable::Connection::StreamTest < ActionCable::TestCase [ EOFError, Errno::ECONNRESET ].each do |closed_exception| test "closes socket on #{closed_exception}" do - skip if ENV["FAYE"].present? - run_in_eventmachine do connection = open_connection diff --git a/actioncable/test/server/base_test.rb b/actioncable/test/server/base_test.rb new file mode 100644 index 0000000000..f0a51c5a7d --- /dev/null +++ b/actioncable/test/server/base_test.rb @@ -0,0 +1,33 @@ +require "test_helper" +require "stubs/test_server" +require "active_support/core_ext/hash/indifferent_access" + +class BaseTest < ActiveSupport::TestCase + def setup + @server = ActionCable::Server::Base.new + @server.config.cable = { adapter: "async" }.with_indifferent_access + end + + class FakeConnection + def close + end + end + + test "#restart closes all open connections" do + conn = FakeConnection.new + @server.add_connection(conn) + + conn.expects(:close) + @server.restart + end + + test "#restart shuts down worker pool" do + @server.worker_pool.expects(:halt) + @server.restart + end + + test "#restart shuts down pub/sub adapter" do + @server.pubsub.expects(:shutdown) + @server.restart + end +end diff --git a/actioncable/test/stubs/test_server.rb b/actioncable/test/stubs/test_server.rb index b64ff33789..5bf2a151dc 100644 --- a/actioncable/test/stubs/test_server.rb +++ b/actioncable/test/stubs/test_server.rb @@ -10,12 +10,6 @@ class TestServer @logger = ActiveSupport::TaggedLogging.new ActiveSupport::Logger.new(StringIO.new) @config = OpenStruct.new(log_tags: [], subscription_adapter: subscription_adapter) - @config.use_faye = ENV["FAYE"].present? - @config.client_socket_class = if @config.use_faye - ActionCable::Connection::FayeClientSocket - else - ActionCable::Connection::ClientSocket - end @mutex = Monitor.new end @@ -25,10 +19,8 @@ class TestServer end def event_loop - @event_loop ||= if @config.use_faye - ActionCable::Connection::FayeEventLoop.new - else - ActionCable::Connection::StreamEventLoop.new + @event_loop ||= ActionCable::Connection::StreamEventLoop.new.tap do |loop| + loop.instance_variable_set(:@executor, Concurrent.global_io_executor) end end diff --git a/actioncable/test/subscription_adapter/common.rb b/actioncable/test/subscription_adapter/common.rb index 1538157995..3aa88c2caa 100644 --- a/actioncable/test/subscription_adapter/common.rb +++ b/actioncable/test/subscription_adapter/common.rb @@ -11,7 +11,7 @@ module CommonSubscriptionAdapterTest def setup server = ActionCable::Server::Base.new server.config.cable = cable_config.with_indifferent_access - server.config.use_faye = ENV["FAYE"].present? + server.config.logger = Logger.new(StringIO.new).tap { |l| l.level = Logger::UNKNOWN } adapter_klass = server.config.pubsub_adapter @@ -20,7 +20,7 @@ module CommonSubscriptionAdapterTest end def teardown - [@rx_adapter, @tx_adapter].uniq.each(&:shutdown) + [@rx_adapter, @tx_adapter].uniq.compact.each(&:shutdown) end def subscribe_as_queue(channel, adapter = @rx_adapter) diff --git a/actioncable/test/subscription_adapter/evented_redis_test.rb b/actioncable/test/subscription_adapter/evented_redis_test.rb index d6ca0e77cb..f316bc46ef 100644 --- a/actioncable/test/subscription_adapter/evented_redis_test.rb +++ b/actioncable/test/subscription_adapter/evented_redis_test.rb @@ -12,6 +12,14 @@ class EventedRedisAdapterTest < ActionCable::TestCase end def teardown + super + + # Ensure EM is shut down before we re-enable warnings + EventMachine.reactor_thread.tap do |thread| + EventMachine.stop + thread.join + end + $VERBOSE = @previous_verbose end diff --git a/actioncable/test/test_helper.rb b/actioncable/test/test_helper.rb index 39855ea252..a47032753b 100644 --- a/actioncable/test/test_helper.rb +++ b/actioncable/test/test_helper.rb @@ -13,41 +13,7 @@ end # Require all the stubs and models Dir[File.dirname(__FILE__) + "/stubs/*.rb"].each { |file| require file } -if ENV["FAYE"].present? - require "faye/websocket" - class << Faye::WebSocket - remove_method :ensure_reactor_running - - # We don't want Faye to start the EM reactor in tests because it makes testing much harder. - # We want to be able to start and stop EM loop in tests to make things simpler. - def ensure_reactor_running - # no-op - end - end -end - -module EventMachineConcurrencyHelpers - def wait_for_async - EM.run_deferred_callbacks - end - - def run_in_eventmachine - failure = nil - EM.run do - begin - yield - rescue => ex - failure = ex - ensure - wait_for_async - EM.stop if EM.reactor_running? - end - end - raise failure if failure - end -end - -module ConcurrentRubyConcurrencyHelpers +class ActionCable::TestCase < ActiveSupport::TestCase def wait_for_async wait_for_executor Concurrent.global_io_executor end @@ -56,18 +22,14 @@ module ConcurrentRubyConcurrencyHelpers yield wait_for_async end -end - -class ActionCable::TestCase < ActiveSupport::TestCase - if ENV["FAYE"].present? - include EventMachineConcurrencyHelpers - else - include ConcurrentRubyConcurrencyHelpers - end def wait_for_executor(executor) + # do not wait forever, wait 2s + timeout = 2 until executor.completed_task_count == executor.scheduled_task_count sleep 0.1 + timeout -= 0.1 + raise "Executor could not complete all tasks in 2 seconds" unless timeout > 0 end end end diff --git a/actionpack/CHANGELOG.md b/actionpack/CHANGELOG.md index e7b8e1b628..5ffbc2ea5d 100644 --- a/actionpack/CHANGELOG.md +++ b/actionpack/CHANGELOG.md @@ -1,3 +1,10 @@ +* Show an "unmatched constraints" error when params fail to match constraints + on a matched route, rather than a "missing keys" error. + + Fixes #26470. + + *Chris Carter* + * Fix adding implicitly rendered template digests to ETags. Fixes a case when modifying an implicitly rendered template for a diff --git a/actionpack/lib/action_controller/metal/renderers.rb b/actionpack/lib/action_controller/metal/renderers.rb index 15377ddcb9..f8a037189c 100644 --- a/actionpack/lib/action_controller/metal/renderers.rb +++ b/actionpack/lib/action_controller/metal/renderers.rb @@ -71,8 +71,6 @@ module ActionController # format.csv { render csv: @csvable, filename: @csvable.name } # end # end - # To use renderers and their mime types in more concise ways, see - # <tt>ActionController::MimeResponds::ClassMethods.respond_to</tt> def self.add(key, &block) define_method(_render_with_renderer_method_name(key), &block) RENDERERS << key.to_sym diff --git a/actionpack/lib/action_controller/test_case.rb b/actionpack/lib/action_controller/test_case.rb index 09f2a79d85..f4ec13c831 100644 --- a/actionpack/lib/action_controller/test_case.rb +++ b/actionpack/lib/action_controller/test_case.rb @@ -498,10 +498,6 @@ module ActionController parameters ||= {} - if format - parameters[:format] = format - end - @html_document = nil cookies.update(@request.cookies) @@ -521,6 +517,10 @@ module ActionController format ||= as end + if format + parameters[:format] = format + end + parameters = parameters.symbolize_keys generated_extras = @routes.generate_extras(parameters.merge(controller: controller_class_name, action: action.to_s)) diff --git a/actionpack/lib/action_dispatch/journey/formatter.rb b/actionpack/lib/action_dispatch/journey/formatter.rb index a289c34e8b..dc8b24b089 100644 --- a/actionpack/lib/action_dispatch/journey/formatter.rb +++ b/actionpack/lib/action_dispatch/journey/formatter.rb @@ -44,8 +44,12 @@ module ActionDispatch return [route.format(parameterized_parts), params] end + unmatched_keys = (missing_keys || []) & constraints.keys + missing_keys = (missing_keys || []) - unmatched_keys + message = "No route matches #{Hash[constraints.sort_by { |k,v| k.to_s }].inspect}" - message << " missing required keys: #{missing_keys.sort.inspect}" if missing_keys && !missing_keys.empty? + message << ", missing required keys: #{missing_keys.sort.inspect}" if missing_keys && !missing_keys.empty? + message << ", possible unmatched constraints: #{unmatched_keys.sort.inspect}" if unmatched_keys && !unmatched_keys.empty? raise ActionController::UrlGenerationError, message end diff --git a/actionpack/lib/action_dispatch/middleware/request_id.rb b/actionpack/lib/action_dispatch/middleware/request_id.rb index bd4c781267..1925ffd9dd 100644 --- a/actionpack/lib/action_dispatch/middleware/request_id.rb +++ b/actionpack/lib/action_dispatch/middleware/request_id.rb @@ -2,8 +2,9 @@ require "securerandom" require "active_support/core_ext/string/access" module ActionDispatch - # Makes a unique request id available to the action_dispatch.request_id env variable (which is then accessible through - # ActionDispatch::Request#uuid or the alias ActionDispatch::Request#request_id) and sends the same id to the client via the X-Request-Id header. + # Makes a unique request id available to the +action_dispatch.request_id+ env variable (which is then accessible + # through <tt>ActionDispatch::Request#request_id</tt> or the alias <tt>ActionDispatch::Request#uuid</tt>) and sends + # the same id to the client via the X-Request-Id header. # # The unique request id is either based on the X-Request-Id header in the request, which would typically be generated # by a firewall, load balancer, or the web server, or, if this header is not available, a random uuid. If the @@ -12,7 +13,7 @@ module ActionDispatch # The unique request id can be used to trace a request end-to-end and would typically end up being part of log files # from multiple pieces of the stack. class RequestId - X_REQUEST_ID = "X-Request-Id".freeze # :nodoc: + X_REQUEST_ID = "X-Request-Id".freeze #:nodoc: def initialize(app) @app = app diff --git a/actionpack/lib/action_dispatch/routing/route_set.rb b/actionpack/lib/action_dispatch/routing/route_set.rb index 5abf59402d..a1bc357c8b 100644 --- a/actionpack/lib/action_dispatch/routing/route_set.rb +++ b/actionpack/lib/action_dispatch/routing/route_set.rb @@ -210,7 +210,7 @@ module ActionDispatch } constraints = Hash[@route.requirements.merge(params).sort_by { |k,v| k.to_s }] message = "No route matches #{constraints.inspect}" - message << " missing required keys: #{missing_keys.sort.inspect}" + message << ", missing required keys: #{missing_keys.sort.inspect}" raise ActionController::UrlGenerationError, message end diff --git a/actionpack/test/controller/test_case_test.rb b/actionpack/test/controller/test_case_test.rb index 738d8bab6d..d929885aea 100644 --- a/actionpack/test/controller/test_case_test.rb +++ b/actionpack/test/controller/test_case_test.rb @@ -646,6 +646,11 @@ XML assert_equal 2, @request.request_parameters[:num_value] end + def test_using_as_json_sets_format_json + post :render_body, params: { bool_value: true, str_value: "string", num_value: 2 }, as: :json + assert_equal "json", @request.format + end + def test_mutating_content_type_headers_for_plain_text_files_sets_the_header @request.headers["Content-Type"] = "text/plain" post :render_body, params: { name: "foo.txt" } diff --git a/actionpack/test/dispatch/routing_test.rb b/actionpack/test/dispatch/routing_test.rb index 6ba52e37b6..9b6e060955 100644 --- a/actionpack/test/dispatch/routing_test.rb +++ b/actionpack/test/dispatch/routing_test.rb @@ -4684,22 +4684,25 @@ class TestUrlGenerationErrors < ActionDispatch::IntegrationTest include Routes.url_helpers - test "url helpers raise a helpful error message when generation fails" do + test "url helpers raise a 'missing keys' error for a nil param with optimized helpers" do url, missing = { action: "show", controller: "products", id: nil }, [:id] - message = "No route matches #{url.inspect} missing required keys: #{missing.inspect}" + message = "No route matches #{url.inspect}, missing required keys: #{missing.inspect}" - # Optimized url helper error = assert_raises(ActionController::UrlGenerationError) { product_path(nil) } assert_equal message, error.message + end + + test "url helpers raise a 'constraint failure' error for a nil param with non-optimized helpers" do + url, missing = { action: "show", controller: "products", id: nil }, [:id] + message = "No route matches #{url.inspect}, possible unmatched constraints: #{missing.inspect}" - # Non-optimized url helper error = assert_raises(ActionController::UrlGenerationError, message) { product_path(id: nil) } assert_equal message, error.message end - test "url helpers raise message with mixed parameters when generation fails " do + test "url helpers raise message with mixed parameters when generation fails" do url, missing = { action: "show", controller: "products", id: nil, "id"=>"url-tested" }, [:id] - message = "No route matches #{url.inspect} missing required keys: #{missing.inspect}" + message = "No route matches #{url.inspect}, possible unmatched constraints: #{missing.inspect}" # Optimized url helper error = assert_raises(ActionController::UrlGenerationError) { product_path(nil, "id"=>"url-tested") } diff --git a/actionpack/test/journey/router_test.rb b/actionpack/test/journey/router_test.rb index 2b99637f56..7b5916eb72 100644 --- a/actionpack/test/journey/router_test.rb +++ b/actionpack/test/journey/router_test.rb @@ -297,7 +297,7 @@ module ActionDispatch } request_parameters = primarty_parameters.merge(redirection_parameters).merge(missing_parameters) - message = "No route matches #{Hash[request_parameters.sort_by { |k,v|k.to_s }].inspect} missing required keys: #{[missing_key.to_sym].inspect}" + message = "No route matches #{Hash[request_parameters.sort_by { |k,v|k.to_s }].inspect}, missing required keys: #{[missing_key.to_sym].inspect}" error = assert_raises(ActionController::UrlGenerationError) do @formatter.generate( diff --git a/actionview/CHANGELOG.md b/actionview/CHANGELOG.md index 8bd4e1e56c..e93745c3bf 100644 --- a/actionview/CHANGELOG.md +++ b/actionview/CHANGELOG.md @@ -1,3 +1,21 @@ +* Render now accepts any keys for locals, including reserved words + + Only locals with valid variable names get set directly. Others + will still be available in local_assigns. + + Example of render with reserved words: + + ```erb + <%= render "example", class: "text-center", message: "Hello world!" %> + + <!-- _example.html.erb: --> + <%= tag.div class: local_assigns[:class] do %> + <p><%= message %></p> + <% end %> + ``` + + *Peter Schilling*, *Matthew Draper* + * Show cache hits and misses when rendering partials. Partials using the `cache` helper will show whether a render hit or missed diff --git a/actionview/lib/action_view/digestor.rb b/actionview/lib/action_view/digestor.rb index 2d6ad8f6d9..0658d8601d 100644 --- a/actionview/lib/action_view/digestor.rb +++ b/actionview/lib/action_view/digestor.rb @@ -6,6 +6,12 @@ module ActionView class Digestor @@digest_mutex = Mutex.new + module PerExecutionDigestCacheExpiry + def self.before(target) + ActionView::LookupContext::DetailsKey.clear + end + end + class << self # Supported options: # diff --git a/actionview/lib/action_view/railtie.rb b/actionview/lib/action_view/railtie.rb index dfb99f4ea9..42795ca2c7 100644 --- a/actionview/lib/action_view/railtie.rb +++ b/actionview/lib/action_view/railtie.rb @@ -40,7 +40,7 @@ module ActionView initializer "action_view.per_request_digest_cache" do |app| ActiveSupport.on_load(:action_view) do if app.config.consider_all_requests_local - app.executor.to_run { ActionView::LookupContext::DetailsKey.clear } + app.executor.to_run ActionView::Digestor::PerExecutionDigestCacheExpiry end end end diff --git a/actionview/lib/action_view/template.rb b/actionview/lib/action_view/template.rb index 513935cef0..c01dd1c028 100644 --- a/actionview/lib/action_view/template.rb +++ b/actionview/lib/action_view/template.rb @@ -1,5 +1,6 @@ require "active_support/core_ext/object/try" require "active_support/core_ext/kernel/singleton_class" +require "active_support/core_ext/module/delegation" require "thread" module ActionView @@ -324,8 +325,13 @@ module ActionView end def locals_code #:nodoc: + # Only locals with valid variable names get set directly. Others will + # still be available in local_assigns. + locals = @locals.to_set - Module::DELEGATION_RESERVED_METHOD_NAMES + locals = locals.grep(/\A(?![A-Z0-9])(?:[[:alnum:]_]|[^\0-\177])+\z/) + # Double assign to suppress the dreaded 'assigned but unused variable' warning - @locals.each_with_object("") { |key, code| code << "#{key} = #{key} = local_assigns[:#{key}];" } + locals.each_with_object("") { |key, code| code << "#{key} = #{key} = local_assigns[:#{key}];" } end def method_name #:nodoc: diff --git a/actionview/test/fixtures/test/render_file_inspect_local_assigns.erb b/actionview/test/fixtures/test/render_file_inspect_local_assigns.erb new file mode 100644 index 0000000000..aea5c351c5 --- /dev/null +++ b/actionview/test/fixtures/test/render_file_inspect_local_assigns.erb @@ -0,0 +1 @@ +<%= local_assigns.inspect.html_safe %>
\ No newline at end of file diff --git a/actionview/test/fixtures/test/render_file_unicode_local.erb b/actionview/test/fixtures/test/render_file_unicode_local.erb new file mode 100644 index 0000000000..cbfd040a76 --- /dev/null +++ b/actionview/test/fixtures/test/render_file_unicode_local.erb @@ -0,0 +1 @@ +<%= 🎃 %>
\ No newline at end of file diff --git a/actionview/test/fixtures/test/render_file_with_ruby_keyword_locals.erb b/actionview/test/fixtures/test/render_file_with_ruby_keyword_locals.erb new file mode 100644 index 0000000000..7e3fe6c6d9 --- /dev/null +++ b/actionview/test/fixtures/test/render_file_with_ruby_keyword_locals.erb @@ -0,0 +1 @@ +The class is <%= local_assigns[:class] %>
\ No newline at end of file diff --git a/actionview/test/template/compiled_templates_test.rb b/actionview/test/template/compiled_templates_test.rb index 7e3e5883b4..3ecac46d34 100644 --- a/actionview/test/template/compiled_templates_test.rb +++ b/actionview/test/template/compiled_templates_test.rb @@ -9,6 +9,25 @@ class CompiledTemplatesTest < ActiveSupport::TestCase assert_equal "This is nil: \n", render(template: "test/nil_return") end + def test_template_with_ruby_keyword_locals + assert_equal "The class is foo", + render(file: "test/render_file_with_ruby_keyword_locals", locals: { class: "foo" }) + end + + def test_template_with_invalid_identifier_locals + locals = { + foo: "bar", + Foo: "bar", + "d-a-s-h-e-s": "", + "white space": "", + } + assert_equal locals.inspect, render(file: "test/render_file_inspect_local_assigns", locals: locals) + end + + def test_template_with_unicode_identifier + assert_equal "🎂", render(file: "test/render_file_unicode_local", locals: { 🎃: "🎂" }) + end + def test_template_gets_recompiled_when_using_different_keys_in_local_assigns assert_equal "one", render(file: "test/render_file_with_locals_and_default") assert_equal "two", render(file: "test/render_file_with_locals_and_default", locals: { secret: "two" }) diff --git a/activemodel/lib/active_model/errors.rb b/activemodel/lib/active_model/errors.rb index 191039f598..72746e194e 100644 --- a/activemodel/lib/active_model/errors.rb +++ b/activemodel/lib/active_model/errors.rb @@ -280,7 +280,7 @@ module ActiveModel messages[attribute] = array.map { |message| full_message(attribute, message) } end else - messages.dup + without_default_proc(messages) end end diff --git a/activemodel/lib/active_model/type/float.rb b/activemodel/lib/active_model/type/float.rb index 94bb7e700c..4d0d2771a0 100644 --- a/activemodel/lib/active_model/type/float.rb +++ b/activemodel/lib/active_model/type/float.rb @@ -7,6 +7,15 @@ module ActiveModel :float end + def type_cast_for_schema(value) + return "::Float::NAN" if value.try(:nan?) + case value + when ::Float::INFINITY then "::Float::INFINITY" + when -::Float::INFINITY then "-::Float::INFINITY" + else super + end + end + alias serialize cast private diff --git a/activemodel/test/cases/errors_test.rb b/activemodel/test/cases/errors_test.rb index 3288b5543c..95ca1f3969 100644 --- a/activemodel/test/cases/errors_test.rb +++ b/activemodel/test/cases/errors_test.rb @@ -250,6 +250,16 @@ class ErrorsTest < ActiveModel::TestCase assert_equal({ name: ["cannot be blank"] }, person.errors.to_hash) end + test "to_hash returns a hash without default proc" do + person = Person.new + assert_nil person.errors.to_hash.default_proc + end + + test "as_json returns a hash without default proc" do + person = Person.new + assert_nil person.errors.as_json.default_proc + end + test "full_messages creates a list of error messages with the attribute name included" do person = Person.new person.errors.add(:name, "cannot be blank") diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index 4d0c1a4178..879c4a87cf 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -1,3 +1,7 @@ +* Made ActiveRecord consistently use `ActiveRecord::Type` (not `ActiveModel::Type`) + + *Iain Beeston* + * Serialize JSON attribute value `nil` as SQL `NULL`, not JSON `null` *Trung Duc Tran* diff --git a/activerecord/lib/active_record/associations/collection_association.rb b/activerecord/lib/active_record/associations/collection_association.rb index e5b3af8252..278c95e27b 100644 --- a/activerecord/lib/active_record/associations/collection_association.rb +++ b/activerecord/lib/active_record/associations/collection_association.rb @@ -309,15 +309,12 @@ module ActiveRecord def replace_on_target(record, index, skip_callbacks) callback(:before_add, record) unless skip_callbacks - was_loaded = loaded? yield(record) if block_given? - unless !was_loaded && loaded? - if index - @target[index] = record - else - @target << record - end + if index + @target[index] = record + else + append_record(record) end callback(:after_add, record) unless skip_callbacks @@ -514,6 +511,10 @@ module ActiveRecord load_target.select { |r| ids.include?(r.id.to_s) } end end + + def append_record(record) + @target << record unless @target.include?(record) + end end end end diff --git a/activerecord/lib/active_record/associations/has_many_through_association.rb b/activerecord/lib/active_record/associations/has_many_through_association.rb index d258eac0ed..1f264d325a 100644 --- a/activerecord/lib/active_record/associations/has_many_through_association.rb +++ b/activerecord/lib/active_record/associations/has_many_through_association.rb @@ -203,6 +203,10 @@ module ActiveRecord def invertible_for?(record) false end + + def append_record(record) + @target << record + end end end end diff --git a/activerecord/lib/active_record/attribute_methods/query.rb b/activerecord/lib/active_record/attribute_methods/query.rb index 10498f4322..05f0e974b6 100644 --- a/activerecord/lib/active_record/attribute_methods/query.rb +++ b/activerecord/lib/active_record/attribute_methods/query.rb @@ -19,7 +19,7 @@ module ActiveRecord if Numeric === value || value !~ /[^0-9]/ !value.to_i.zero? else - return false if ActiveModel::Type::Boolean::FALSE_VALUES.include?(value) + return false if ActiveRecord::Type::Boolean::FALSE_VALUES.include?(value) !value.blank? end elsif value.respond_to?(:zero?) diff --git a/activerecord/lib/active_record/attribute_methods/serialization.rb b/activerecord/lib/active_record/attribute_methods/serialization.rb index c70178cd2c..945192fe04 100644 --- a/activerecord/lib/active_record/attribute_methods/serialization.rb +++ b/activerecord/lib/active_record/attribute_methods/serialization.rb @@ -26,7 +26,7 @@ module ActiveRecord # ==== Parameters # # * +attr_name+ - The field name that should be serialized. - # * +class_name_or_coder+ - Optional, a coder object, which responds to `.load` / `.dump` + # * +class_name_or_coder+ - Optional, a coder object, which responds to +.load+ and +.dump+ # or a class name that the object type should be equal to. # # ==== Example diff --git a/activerecord/lib/active_record/connection_adapters/abstract/query_cache.rb b/activerecord/lib/active_record/connection_adapters/abstract/query_cache.rb index 6ca53c72ce..2f8a89e88e 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/query_cache.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/query_cache.rb @@ -65,7 +65,7 @@ module ActiveRecord if @query_cache_enabled && !locked?(arel) arel, binds = binds_from_relation arel, binds sql = to_sql(arel, binds) - cache_sql(sql, binds) { super(sql, name, binds, preparable: preparable) } + cache_sql(sql, name, binds) { super(sql, name, binds, preparable: preparable) } else super end @@ -73,11 +73,17 @@ module ActiveRecord private - def cache_sql(sql, binds) + def cache_sql(sql, name, binds) result = if @query_cache[sql].key?(binds) - ActiveSupport::Notifications.instrument("sql.active_record", - sql: sql, binds: binds, name: "CACHE", connection_id: object_id) + ActiveSupport::Notifications.instrument( + "sql.active_record", + sql: sql, + binds: binds, + name: name, + connection_id: object_id, + cached: true, + ) @query_cache[sql][binds] else @query_cache[sql][binds] = yield diff --git a/activerecord/lib/active_record/explain_subscriber.rb b/activerecord/lib/active_record/explain_subscriber.rb index 706b57842f..abd8cfc8f2 100644 --- a/activerecord/lib/active_record/explain_subscriber.rb +++ b/activerecord/lib/active_record/explain_subscriber.rb @@ -18,10 +18,13 @@ module ActiveRecord # # On the other hand, we want to monitor the performance of our real database # queries, not the performance of the access to the query cache. - IGNORED_PAYLOADS = %w(SCHEMA EXPLAIN CACHE) + IGNORED_PAYLOADS = %w(SCHEMA EXPLAIN) EXPLAINED_SQLS = /\A\s*(with|select|update|delete|insert)\b/i def ignore_payload?(payload) - payload[:exception] || IGNORED_PAYLOADS.include?(payload[:name]) || payload[:sql] !~ EXPLAINED_SQLS + payload[:exception] || + payload[:cached] || + IGNORED_PAYLOADS.include?(payload[:name]) || + payload[:sql] !~ EXPLAINED_SQLS end ActiveSupport::Notifications.subscribe("sql.active_record", new) diff --git a/activerecord/lib/active_record/integration.rb b/activerecord/lib/active_record/integration.rb index e4c7a55541..3c54c6048d 100644 --- a/activerecord/lib/active_record/integration.rb +++ b/activerecord/lib/active_record/integration.rb @@ -53,18 +53,21 @@ module ActiveRecord # # Person.find(5).cache_key(:updated_at, :last_reviewed_at) def cache_key(*timestamp_names) - case - when new_record? + if new_record? "#{model_name.cache_key}/new" - when timestamp_names.any? - timestamp = max_updated_column_timestamp(timestamp_names) - timestamp = timestamp.utc.to_s(cache_timestamp_format) - "#{model_name.cache_key}/#{id}-#{timestamp}" - when timestamp = max_updated_column_timestamp - timestamp = timestamp.utc.to_s(cache_timestamp_format) - "#{model_name.cache_key}/#{id}-#{timestamp}" else - "#{model_name.cache_key}/#{id}" + timestamp = if timestamp_names.any? + max_updated_column_timestamp(timestamp_names) + else + max_updated_column_timestamp + end + + if timestamp + timestamp = timestamp.utc.to_s(cache_timestamp_format) + "#{model_name.cache_key}/#{id}-#{timestamp}" + else + "#{model_name.cache_key}/#{id}" + end end end diff --git a/activerecord/lib/active_record/log_subscriber.rb b/activerecord/lib/active_record/log_subscriber.rb index f31931316c..b27e84a5be 100644 --- a/activerecord/lib/active_record/log_subscriber.rb +++ b/activerecord/lib/active_record/log_subscriber.rb @@ -35,6 +35,7 @@ module ActiveRecord return if IGNORE_PAYLOAD_NAMES.include?(payload[:name]) name = "#{payload[:name]} (#{event.duration.round(1)}ms)" + name = "CACHE #{name}" if payload[:cached] sql = payload[:sql] binds = nil diff --git a/activerecord/lib/active_record/migration.rb b/activerecord/lib/active_record/migration.rb index 05568039d8..627e93b5b6 100644 --- a/activerecord/lib/active_record/migration.rb +++ b/activerecord/lib/active_record/migration.rb @@ -1,4 +1,5 @@ require "set" +require "zlib" require "active_support/core_ext/module/attribute_accessors" require "active_support/core_ext/regexp" diff --git a/activerecord/lib/active_record/query_cache.rb b/activerecord/lib/active_record/query_cache.rb index 387dd8e9bd..c45c8c1697 100644 --- a/activerecord/lib/active_record/query_cache.rb +++ b/activerecord/lib/active_record/query_cache.rb @@ -34,16 +34,14 @@ module ActiveRecord def self.complete(enabled) ActiveRecord::Base.connection.clear_query_cache ActiveRecord::Base.connection.disable_query_cache! unless enabled + + unless ActiveRecord::Base.connected? && ActiveRecord::Base.connection.transaction_open? + ActiveRecord::Base.clear_active_connections! + end end def self.install_executor_hooks(executor = ActiveSupport::Executor) executor.register_hook(self) - - executor.to_complete do - unless ActiveRecord::Base.connected? && ActiveRecord::Base.connection.transaction_open? - ActiveRecord::Base.clear_active_connections! - end - end end end end diff --git a/activerecord/lib/active_record/relation/calculations.rb b/activerecord/lib/active_record/relation/calculations.rb index a887be8a20..e4676f79a5 100644 --- a/activerecord/lib/active_record/relation/calculations.rb +++ b/activerecord/lib/active_record/relation/calculations.rb @@ -112,10 +112,6 @@ module ActiveRecord # ... # end def calculate(operation, column_name) - if column_name.is_a?(Symbol) && attribute_alias?(column_name) - column_name = attribute_alias(column_name) - end - if has_include?(column_name) relation = construct_relation_for_association_calculations relation = relation.distinct if operation.to_s.downcase == "count" @@ -215,8 +211,8 @@ module ActiveRecord def aggregate_column(column_name) return column_name if Arel::Expressions === column_name - if @klass.column_names.include?(column_name.to_s) - Arel::Attribute.new(@klass.unscoped.table, column_name) + if @klass.has_attribute?(column_name.to_s) || @klass.attribute_alias?(column_name.to_s) + @klass.arel_attribute(column_name) else Arel.sql(column_name == :all ? "*" : column_name.to_s) end diff --git a/activerecord/lib/active_record/type.rb b/activerecord/lib/active_record/type.rb index 0b48d2186a..84373dddf2 100644 --- a/activerecord/lib/active_record/type.rb +++ b/activerecord/lib/active_record/type.rb @@ -1,4 +1,6 @@ require "active_model/type" +require "active_record/type/helpers" +require "active_record/type/value" require "active_record/type/internal/abstract_json" require "active_record/type/internal/timezone" @@ -48,7 +50,6 @@ module ActiveRecord end end - Helpers = ActiveModel::Type::Helpers BigInteger = ActiveModel::Type::BigInteger Binary = ActiveModel::Type::Binary Boolean = ActiveModel::Type::Boolean @@ -59,7 +60,6 @@ module ActiveRecord String = ActiveModel::Type::String Text = ActiveModel::Type::Text UnsignedInteger = ActiveModel::Type::UnsignedInteger - Value = ActiveModel::Type::Value register(:big_integer, Type::BigInteger, override: false) register(:binary, Type::Binary, override: false) diff --git a/activerecord/lib/active_record/type/helpers.rb b/activerecord/lib/active_record/type/helpers.rb new file mode 100644 index 0000000000..a32ccd4bc3 --- /dev/null +++ b/activerecord/lib/active_record/type/helpers.rb @@ -0,0 +1,5 @@ +module ActiveRecord + module Type + Helpers = ActiveModel::Type::Helpers + end +end diff --git a/activerecord/lib/active_record/type/internal/abstract_json.rb b/activerecord/lib/active_record/type/internal/abstract_json.rb index e19c5a14da..67028546e4 100644 --- a/activerecord/lib/active_record/type/internal/abstract_json.rb +++ b/activerecord/lib/active_record/type/internal/abstract_json.rb @@ -1,8 +1,8 @@ module ActiveRecord module Type module Internal # :nodoc: - class AbstractJson < ActiveModel::Type::Value # :nodoc: - include ActiveModel::Type::Helpers::Mutable + class AbstractJson < Type::Value # :nodoc: + include Type::Helpers::Mutable def type :json diff --git a/activerecord/lib/active_record/type/serialized.rb b/activerecord/lib/active_record/type/serialized.rb index ac9134bfcb..ca12c83b1a 100644 --- a/activerecord/lib/active_record/type/serialized.rb +++ b/activerecord/lib/active_record/type/serialized.rb @@ -1,7 +1,7 @@ module ActiveRecord module Type - class Serialized < DelegateClass(ActiveModel::Type::Value) # :nodoc: - include ActiveModel::Type::Helpers::Mutable + class Serialized < DelegateClass(Type::Value) # :nodoc: + include Type::Helpers::Mutable attr_reader :subtype, :coder diff --git a/activerecord/lib/active_record/type/value.rb b/activerecord/lib/active_record/type/value.rb new file mode 100644 index 0000000000..89ef29106b --- /dev/null +++ b/activerecord/lib/active_record/type/value.rb @@ -0,0 +1,5 @@ +module ActiveRecord + module Type + class Value < ActiveModel::Type::Value; end + end +end diff --git a/activerecord/test/cases/adapters/postgresql/transaction_test.rb b/activerecord/test/cases/adapters/postgresql/transaction_test.rb index d992e22305..00119f13bb 100644 --- a/activerecord/test/cases/adapters/postgresql/transaction_test.rb +++ b/activerecord/test/cases/adapters/postgresql/transaction_test.rb @@ -1,5 +1,6 @@ require "cases/helper" require "support/connection_helper" +require "concurrent/atomic/cyclic_barrier" module ActiveRecord class PostgresqlTransactionTest < ActiveRecord::PostgreSQLTestCase @@ -61,26 +62,28 @@ module ActiveRecord test "raises Deadlocked when a deadlock is encountered" do with_warning_suppression do assert_raises(ActiveRecord::Deadlocked) do + barrier = Concurrent::CyclicBarrier.new(2) + s1 = Sample.create value: 1 s2 = Sample.create value: 2 thread = Thread.new do Sample.transaction do s1.lock! - sleep 1 + barrier.wait s2.update_attributes value: 1 end end - sleep 0.5 - - Sample.transaction do - s2.lock! - sleep 1 - s1.update_attributes value: 2 + begin + Sample.transaction do + s2.lock! + barrier.wait + s1.update_attributes value: 2 + end + ensure + thread.join end - - thread.join end end end diff --git a/activerecord/test/cases/associations/has_many_associations_test.rb b/activerecord/test/cases/associations/has_many_associations_test.rb index fed59c2ab3..0ce67f971b 100644 --- a/activerecord/test/cases/associations/has_many_associations_test.rb +++ b/activerecord/test/cases/associations/has_many_associations_test.rb @@ -2461,9 +2461,12 @@ class HasManyAssociationsTest < ActiveRecord::TestCase end test "double insertion of new object to association when same association used in the after create callback of a new object" do - car = Car.create! - car.bulbs << TrickyBulb.new - assert_equal 1, car.bulbs.size + reset_callbacks(:save, Bulb) do + Bulb.after_save { |record| record.car.bulbs.to_a } + car = Car.create! + car.bulbs << Bulb.new + assert_equal 1, car.bulbs.size + end end def test_association_force_reload_with_only_true_is_deprecated @@ -2510,9 +2513,34 @@ class HasManyAssociationsTest < ActiveRecord::TestCase assert_no_queries { car.bulb_ids } end + def test_loading_association_in_validate_callback_doesnt_affect_persistence + reset_callbacks(:validation, Bulb) do + Bulb.after_validation { |m| m.car.bulbs.load } + + car = Car.create!(name: "Car") + bulb = car.bulbs.create! + + assert_equal [bulb], car.bulbs + end + end + private def force_signal37_to_load_all_clients_of_firm companies(:first_firm).clients_of_firm.load_target end + + def reset_callbacks(kind, klass) + old_callbacks = {} + old_callbacks[klass] = klass.send("_#{kind}_callbacks").dup + klass.subclasses.each do |subclass| + old_callbacks[subclass] = subclass.send("_#{kind}_callbacks").dup + end + yield + ensure + klass.send("_#{kind}_callbacks=", old_callbacks[klass]) + klass.subclasses.each do |subclass| + subclass.send("_#{kind}_callbacks=", old_callbacks[subclass]) + end + end end diff --git a/activerecord/test/cases/integration_test.rb b/activerecord/test/cases/integration_test.rb index 766917b196..00457965d7 100644 --- a/activerecord/test/cases/integration_test.rb +++ b/activerecord/test/cases/integration_test.rb @@ -172,4 +172,10 @@ class IntegrationTest < ActiveRecord::TestCase owner = owners(:blackbeard) assert_equal "owners/#{owner.id}-#{owner.happy_at.utc.to_s(:usec)}", owner.cache_key(:updated_at, :happy_at) end + + def test_cache_key_when_named_timestamp_is_nil + owner = owners(:blackbeard) + owner.happy_at = nil + assert_equal "owners/#{owner.id}", owner.cache_key(:happy_at) + end end diff --git a/activerecord/test/cases/schema_dumper_test.rb b/activerecord/test/cases/schema_dumper_test.rb index 57b1bc889a..8b604ba930 100644 --- a/activerecord/test/cases/schema_dumper_test.rb +++ b/activerecord/test/cases/schema_dumper_test.rb @@ -423,6 +423,13 @@ class SchemaDumperDefaultsTest < ActiveRecord::TestCase t.datetime :datetime_with_default, default: "2014-06-05 07:17:04" t.time :time_with_default, default: "07:17:04" end + + if current_adapter?(:PostgreSQLAdapter) + @connection.create_table :infinity_defaults, force: true do |t| + t.float :float_with_inf_default, default: Float::INFINITY + t.float :float_with_nan_default, default: Float::NAN + end + end end teardown do @@ -438,4 +445,11 @@ class SchemaDumperDefaultsTest < ActiveRecord::TestCase assert_match %r{t\.datetime\s+"datetime_with_default",\s+default: '2014-06-05 07:17:04'}, output assert_match %r{t\.time\s+"time_with_default",\s+default: '2000-01-01 07:17:04'}, output end + + def test_schema_dump_with_float_column_infinity_default + skip unless current_adapter?(:PostgreSQLAdapter) + output = dump_table_schema('infinity_defaults') + assert_match %r{t\.float\s+"float_with_inf_default",\s+default: ::Float::INFINITY}, output + assert_match %r{t\.float\s+"float_with_nan_default",\s+default: ::Float::NAN}, output + end end diff --git a/activerecord/test/cases/serialized_attribute_test.rb b/activerecord/test/cases/serialized_attribute_test.rb index bebd856faf..8e9514de7c 100644 --- a/activerecord/test/cases/serialized_attribute_test.rb +++ b/activerecord/test/cases/serialized_attribute_test.rb @@ -313,8 +313,8 @@ class SerializedAttributeTest < ActiveRecord::TestCase return if value.nil? value.gsub(" encoded", "") end - type = Class.new(ActiveModel::Type::Value) do - include ActiveModel::Type::Helpers::Mutable + type = Class.new(ActiveRecord::Type::Value) do + include ActiveRecord::Type::Helpers::Mutable def serialize(value) return if value.nil? diff --git a/activerecord/test/cases/test_case.rb b/activerecord/test/cases/test_case.rb index 60ac3e08a1..8eddc5a9ed 100644 --- a/activerecord/test/cases/test_case.rb +++ b/activerecord/test/cases/test_case.rb @@ -125,12 +125,9 @@ module ActiveRecord end def call(name, start, finish, message_id, values) - sql = values[:sql] - - # FIXME: this seems bad. we should probably have a better way to indicate - # the query was cached - return if "CACHE" == values[:name] + return if values[:cached] + sql = values[:sql] self.class.log_all << sql self.class.log << sql unless ignore.match?(sql) end diff --git a/activerecord/test/cases/type/date_time_test.rb b/activerecord/test/cases/type/date_time_test.rb index bc4900e1c2..6848619ece 100644 --- a/activerecord/test/cases/type/date_time_test.rb +++ b/activerecord/test/cases/type/date_time_test.rb @@ -3,7 +3,7 @@ require "models/task" module ActiveRecord module Type - class IntegerTest < ActiveRecord::TestCase + class DateTimeTest < ActiveRecord::TestCase def test_datetime_seconds_precision_applied_to_timestamp skip "This test is invalid if subsecond precision isn't supported" unless subsecond_precision_supported? p = Task.create!(starting: ::Time.now) diff --git a/activerecord/test/models/bulb.rb b/activerecord/test/models/bulb.rb index 3196207ac9..113d21cb84 100644 --- a/activerecord/test/models/bulb.rb +++ b/activerecord/test/models/bulb.rb @@ -50,9 +50,3 @@ class FailedBulb < Bulb throw(:abort) end end - -class TrickyBulb < Bulb - after_create do |record| - record.car.bulbs.to_a - end -end diff --git a/activesupport/lib/active_support/callbacks.rb b/activesupport/lib/active_support/callbacks.rb index 6d39be1c1f..890b1cd73b 100644 --- a/activesupport/lib/active_support/callbacks.rb +++ b/activesupport/lib/active_support/callbacks.rb @@ -63,6 +63,8 @@ module ActiveSupport included do extend ActiveSupport::DescendantsTracker + class_attribute :__callbacks, instance_writer: false + self.__callbacks ||= {} end CALLBACK_FILTER_TYPES = [:before, :after, :around] @@ -86,21 +88,57 @@ module ActiveSupport # run_callbacks :save do # save # end - def run_callbacks(kind, &block) - send "_run_#{kind}_callbacks", &block - end - - private + # + #-- + # + # As this method is used in many places, and often wraps large portions of + # user code, it has an additional design goal of minimizing its impact on + # the visible call stack. An exception from inside a :before or :after + # callback can be as noisy as it likes -- but when control has passed + # smoothly through and into the supplied block, we want as little evidence + # as possible that we were here. + def run_callbacks(kind) + callbacks = __callbacks[kind.to_sym] + + if callbacks.empty? + yield if block_given? + else + env = Filters::Environment.new(self, false, nil) + next_sequence = callbacks.compile + + invoke_sequence = Proc.new do + skipped = nil + while true + current, next_sequence = next_sequence, next_sequence.nested + current.invoke_before(env) + if current.final? + env.value = !env.halted && (!block_given? || yield) + elsif current.skip?(env) + (skipped ||= []) << current + next + else + expanded = current.expand_call_template(env, invoke_sequence) + expanded.shift.send(*expanded, &expanded.shift) + end + current.invoke_after(env) + skipped.pop.invoke_after(env) while skipped && skipped.first + break env.value + end + end - def __run_callbacks__(callbacks, &block) - if callbacks.empty? - yield if block_given? + # Common case: no 'around' callbacks defined + if next_sequence.final? + next_sequence.invoke_before(env) + env.value = !env.halted && (!block_given? || yield) + next_sequence.invoke_after(env) + env.value else - runner = callbacks.compile - e = Filters::Environment.new(self, false, nil, block) - runner.call(e).value + invoke_sequence.call end end + end + + private # A hook invoked every time a before callback is halted. # This can be overridden in ActiveSupport::Callbacks implementors in order @@ -118,16 +156,7 @@ module ActiveSupport end module Filters - Environment = Struct.new(:target, :halted, :value, :run_block) - - class End - def call(env) - block = env.run_block - env.value = !env.halted && (!block || block.call) - env - end - end - ENDING = End.new + Environment = Struct.new(:target, :halted, :value) class Before def self.build(callback_sequence, user_callback, user_conditions, chain_config, filter) @@ -246,51 +275,6 @@ module ActiveSupport end private_class_method :simple end - - class Around - def self.build(callback_sequence, user_callback, user_conditions, chain_config) - if user_conditions.any? - halting_and_conditional(callback_sequence, user_callback, user_conditions) - else - halting(callback_sequence, user_callback) - end - end - - def self.halting_and_conditional(callback_sequence, user_callback, user_conditions) - callback_sequence.around do |env, &run| - target = env.target - value = env.value - halted = env.halted - - if !halted && user_conditions.all? { |c| c.call(target, value) } - user_callback.call(target, value) { - run.call.value - } - env - else - run.call - end - end - end - private_class_method :halting_and_conditional - - def self.halting(callback_sequence, user_callback) - callback_sequence.around do |env, &run| - target = env.target - value = env.value - - if env.halted - run.call - else - user_callback.call(target, value) { - run.call.value - } - env - end - end - end - private_class_method :halting - end end class Callback #:nodoc:# @@ -349,64 +333,23 @@ module ActiveSupport # Wraps code with filter def apply(callback_sequence) user_conditions = conditions_lambdas - user_callback = make_lambda @filter + user_callback = CallTemplate.build(@filter, self) case kind when :before - Filters::Before.build(callback_sequence, user_callback, user_conditions, chain_config, @filter) + Filters::Before.build(callback_sequence, user_callback.make_lambda, user_conditions, chain_config, @filter) when :after - Filters::After.build(callback_sequence, user_callback, user_conditions, chain_config) + Filters::After.build(callback_sequence, user_callback.make_lambda, user_conditions, chain_config) when :around - Filters::Around.build(callback_sequence, user_callback, user_conditions, chain_config) + callback_sequence.around(user_callback, user_conditions) end end - private - - def invert_lambda(l) - lambda { |*args, &blk| !l.call(*args, &blk) } - end - - # Filters support: - # - # Symbols:: A method to call. - # Strings:: Some content to evaluate. - # Procs:: A proc to call with the object. - # Objects:: An object with a <tt>before_foo</tt> method on it to call. - # - # All of these objects are converted into a lambda and handled - # the same after this point. - def make_lambda(filter) - case filter - when Symbol - lambda { |target, _, &blk| target.send filter, &blk } - when String - l = eval "lambda { |value| #{filter} }" - lambda { |target, value| target.instance_exec(value, &l) } - when Conditionals::Value then filter - when ::Proc - if filter.arity > 1 - return lambda { |target, _, &block| - raise ArgumentError unless block - target.instance_exec(target, block, &filter) - } - end - - if filter.arity <= 0 - lambda { |target, _| target.instance_exec(&filter) } - else - lambda { |target, _| target.instance_exec(target, &filter) } - end - else - scopes = Array(chain_config[:scope]) - method_to_call = scopes.map { |s| public_send(s) }.join("_") - - lambda { |target, _, &blk| - filter.public_send method_to_call, target, &blk - } - end - end + def current_scopes + Array(chain_config[:scope]).map { |s| public_send(s) } + end + private def compute_identifier(filter) case filter when String, ::Proc @@ -417,17 +360,116 @@ module ActiveSupport end def conditions_lambdas - @if.map { |c| make_lambda c } + - @unless.map { |c| invert_lambda make_lambda c } + @if.map { |c| CallTemplate.build(c, self).make_lambda } + + @unless.map { |c| CallTemplate.build(c, self).inverted_lambda } end end + # A future invocation of user-supplied code (either as a callback, + # or a condition filter). + class CallTemplate # :nodoc: + def initialize(target, method, arguments, block) + @override_target = target + @method_name = method + @arguments = arguments + @override_block = block + end + + # Return the parts needed to make this call, with the given + # input values. + # + # Returns an array of the form: + # + # [target, block, method, *arguments] + # + # This array can be used as such: + # + # target.send(method, *arguments, &block) + # + # The actual invocation is left up to the caller to minimize + # call stack pollution. + def expand(target, value, block) + result = @arguments.map { |arg| + case arg + when :value; value + when :target; target + when :block; block || raise(ArgumentError) + end + } + + result.unshift @method_name + result.unshift @override_block || block + result.unshift @override_target || target + + # target, block, method, *arguments = result + # target.send(method, *arguments, &block) + result + end + + # Return a lambda that will make this call when given the input + # values. + def make_lambda + lambda do |target, value, &block| + c = expand(target, value, block) + c.shift.send(*c, &c.shift) + end + end + + # Return a lambda that will make this call when given the input + # values, but then return the boolean inverse of that result. + def inverted_lambda + lambda do |target, value, &block| + c = expand(target, value, block) + ! c.shift.send(*c, &c.shift) + end + end + + # Filters support: + # + # Symbols:: A method to call. + # Strings:: Some content to evaluate. + # Procs:: A proc to call with the object. + # Objects:: An object with a <tt>before_foo</tt> method on it to call. + # + # All of these objects are converted into a CallTemplate and handled + # the same after this point. + def self.build(filter, callback) + case filter + when Symbol + new(nil, filter, [], nil) + when String + new(nil, :instance_exec, [:value], compile_lambda(filter)) + when Conditionals::Value + new(filter, :call, [:target, :value], nil) + when ::Proc + if filter.arity > 1 + new(nil, :instance_exec, [:target, :block], filter) + elsif filter.arity > 0 + new(nil, :instance_exec, [:target], filter) + else + new(nil, :instance_exec, [], filter) + end + else + method_to_call = callback.current_scopes.join("_") + + new(filter, method_to_call, [:target], nil) + end + end + + def self.compile_lambda(filter) + eval("lambda { |value| #{filter} }") + end + end + # Execute before and after filters in a sequence instead of # chaining them with nested lambda calls, see: # https://github.com/rails/rails/issues/18011 - class CallbackSequence - def initialize(&call) - @call = call + class CallbackSequence # :nodoc: + def initialize(nested = nil, call_template = nil, user_conditions = nil) + @nested = nested + @call_template = call_template + @user_conditions = user_conditions + @before = [] @after = [] end @@ -442,19 +484,32 @@ module ActiveSupport self end - def around(&around) - CallbackSequence.new do |arg| - around.call(arg) { - call(arg) - } - end + def around(call_template, user_conditions) + CallbackSequence.new(self, call_template, user_conditions) + end + + def skip?(arg) + arg.halted || !@user_conditions.all? { |c| c.call(arg.target, arg.value) } + end + + def nested + @nested end - def call(arg) + def final? + !@call_template + end + + def expand_call_template(arg, block) + @call_template.expand(arg.target, arg.value, block) + end + + def invoke_before(arg) @before.each { |b| b.call(arg) } - value = @call.call(arg) + end + + def invoke_after(arg) @after.each { |a| a.call(arg) } - value end end @@ -503,7 +558,7 @@ module ActiveSupport def compile @callbacks || @mutex.synchronize do - final_sequence = CallbackSequence.new { |env| Filters::ENDING.call(env) } + final_sequence = CallbackSequence.new @callbacks ||= @chain.reverse.inject(final_sequence) do |callback_sequence, callback| callback.apply callback_sequence end @@ -747,12 +802,25 @@ module ActiveSupport options = names.extract_options! names.each do |name| - class_attribute "_#{name}_callbacks", instance_writer: false + name = name.to_sym + set_callbacks name, CallbackChain.new(name, options) module_eval <<-RUBY, __FILE__, __LINE__ + 1 def _run_#{name}_callbacks(&block) - __run_callbacks__(_#{name}_callbacks, &block) + run_callbacks #{name.inspect}, &block + end + + def self._#{name}_callbacks + get_callbacks(#{name.inspect}) + end + + def self._#{name}_callbacks=(value) + set_callbacks(#{name.inspect}, value) + end + + def _#{name}_callbacks + __callbacks[#{name.inspect}] end RUBY end @@ -761,11 +829,11 @@ module ActiveSupport protected def get_callbacks(name) # :nodoc: - send "_#{name}_callbacks" + __callbacks[name.to_sym] end def set_callbacks(name, callbacks) # :nodoc: - send "_#{name}_callbacks=", callbacks + self.__callbacks = __callbacks.merge(name.to_sym => callbacks) end def deprecated_false_terminator # :nodoc: diff --git a/activesupport/lib/active_support/core_ext/class/attribute.rb b/activesupport/lib/active_support/core_ext/class/attribute.rb index 9b4a9a9992..ba422f9071 100644 --- a/activesupport/lib/active_support/core_ext/class/attribute.rb +++ b/activesupport/lib/active_support/core_ext/class/attribute.rb @@ -20,7 +20,7 @@ class Class # Base.setting # => true # # In the above case as long as Subclass does not assign a value to setting - # by performing <tt>Subclass.setting = _something_ </tt>, <tt>Subclass.setting</tt> + # by performing <tt>Subclass.setting = _something_</tt>, <tt>Subclass.setting</tt> # would read value assigned to parent class. Once Subclass assigns a value then # the value assigned by Subclass would be returned. # diff --git a/activesupport/lib/active_support/core_ext/date_and_time/calculations.rb b/activesupport/lib/active_support/core_ext/date_and_time/calculations.rb index 792076a449..c614f14289 100644 --- a/activesupport/lib/active_support/core_ext/date_and_time/calculations.rb +++ b/activesupport/lib/active_support/core_ext/date_and_time/calculations.rb @@ -300,7 +300,7 @@ module DateAndTime end # Returns a Range representing the whole week of the current date/time. - # Week starts on start_day, default is <tt>Date.week_start</tt> or <tt>config.week_start</tt> when set. + # Week starts on start_day, default is <tt>Date.beginning_of_week</tt> or <tt>config.beginning_of_week</tt> when set. def all_week(start_day = Date.beginning_of_week) beginning_of_week(start_day)..end_of_week(start_day) end diff --git a/activesupport/lib/active_support/core_ext/date_and_time/compatibility.rb b/activesupport/lib/active_support/core_ext/date_and_time/compatibility.rb index 3f4e236ab7..db95ae0db5 100644 --- a/activesupport/lib/active_support/core_ext/date_and_time/compatibility.rb +++ b/activesupport/lib/active_support/core_ext/date_and_time/compatibility.rb @@ -12,7 +12,11 @@ module DateAndTime mattr_accessor(:preserve_timezone, instance_writer: false) { false } def to_time - preserve_timezone ? getlocal(utc_offset) : getlocal + if preserve_timezone + @_to_time_with_instance_offset ||= getlocal(utc_offset) + else + @_to_time_with_system_offset ||= getlocal + end end end end diff --git a/activesupport/lib/active_support/deprecation/instance_delegator.rb b/activesupport/lib/active_support/deprecation/instance_delegator.rb index 8efa6aabdc..6d390f3b37 100644 --- a/activesupport/lib/active_support/deprecation/instance_delegator.rb +++ b/activesupport/lib/active_support/deprecation/instance_delegator.rb @@ -6,6 +6,7 @@ module ActiveSupport module InstanceDelegator # :nodoc: def self.included(base) base.extend(ClassMethods) + base.singleton_class.prepend(OverrideDelegators) base.public_class_method :new end @@ -19,6 +20,18 @@ module ActiveSupport singleton_class.delegate(method_name, to: :instance) end end + + module OverrideDelegators # :nodoc: + def warn(message = nil, callstack = nil) + callstack ||= caller_locations(2) + super + end + + def deprecation_warning(deprecated_method_name, message = nil, caller_backtrace = nil) + caller_backtrace ||= caller_locations(2) + super + end + end end end end diff --git a/activesupport/lib/active_support/execution_wrapper.rb b/activesupport/lib/active_support/execution_wrapper.rb index 4c8b03c9df..3384d12d5b 100644 --- a/activesupport/lib/active_support/execution_wrapper.rb +++ b/activesupport/lib/active_support/execution_wrapper.rb @@ -19,6 +19,23 @@ module ActiveSupport set_callback(:complete, *args, &block) end + class RunHook < Struct.new(:hook) # :nodoc: + def before(target) + hook_state = target.send(:hook_state) + hook_state[hook] = hook.run + end + end + + class CompleteHook < Struct.new(:hook) # :nodoc: + def before(target) + hook_state = target.send(:hook_state) + if hook_state.key?(hook) + hook.complete hook_state[hook] + end + end + alias after before + end + # Register an object to be invoked during both the +run+ and # +complete+ steps. # @@ -29,19 +46,11 @@ module ActiveSupport # invoked in that situation.) def self.register_hook(hook, outer: false) if outer - run_args = [prepend: true] - complete_args = [:after] + to_run RunHook.new(hook), prepend: true + to_complete :after, CompleteHook.new(hook) else - run_args = complete_args = [] - end - - to_run(*run_args) do - hook_state[hook] = hook.run - end - to_complete(*complete_args) do - if hook_state.key?(hook) - hook.complete hook_state[hook] - end + to_run RunHook.new(hook) + to_complete CompleteHook.new(hook) end end diff --git a/activesupport/lib/active_support/lazy_load_hooks.rb b/activesupport/lib/active_support/lazy_load_hooks.rb index b84c7253a0..ae1897b886 100644 --- a/activesupport/lib/active_support/lazy_load_hooks.rb +++ b/activesupport/lib/active_support/lazy_load_hooks.rb @@ -15,9 +15,9 @@ module ActiveSupport # end # end # - # When the entirety of +activerecord/lib/active_record/base.rb+ has been + # When the entirety of +ActiveRecord::Base+ has been # evaluated then +run_load_hooks+ is invoked. The very last line of - # +activerecord/lib/active_record/base.rb+ is: + # +ActiveRecord::Base+ is: # # ActiveSupport.run_load_hooks(:active_record, ActiveRecord::Base) module LazyLoadHooks diff --git a/activesupport/lib/active_support/multibyte/unicode.rb b/activesupport/lib/active_support/multibyte/unicode.rb index 2159abef14..217919ccb8 100644 --- a/activesupport/lib/active_support/multibyte/unicode.rb +++ b/activesupport/lib/active_support/multibyte/unicode.rb @@ -30,36 +30,6 @@ module ActiveSupport HANGUL_NCOUNT = HANGUL_VCOUNT * HANGUL_TCOUNT HANGUL_SCOUNT = 11172 HANGUL_SLAST = HANGUL_SBASE + HANGUL_SCOUNT - HANGUL_JAMO_FIRST = 0x1100 - HANGUL_JAMO_LAST = 0x11FF - - # All the unicode whitespace - WHITESPACE = [ - (0x0009..0x000D).to_a, # White_Space # Cc [5] <control-0009>..<control-000D> - 0x0020, # White_Space # Zs SPACE - 0x0085, # White_Space # Cc <control-0085> - 0x00A0, # White_Space # Zs NO-BREAK SPACE - 0x1680, # White_Space # Zs OGHAM SPACE MARK - (0x2000..0x200A).to_a, # White_Space # Zs [11] EN QUAD..HAIR SPACE - 0x2028, # White_Space # Zl LINE SEPARATOR - 0x2029, # White_Space # Zp PARAGRAPH SEPARATOR - 0x202F, # White_Space # Zs NARROW NO-BREAK SPACE - 0x205F, # White_Space # Zs MEDIUM MATHEMATICAL SPACE - 0x3000, # White_Space # Zs IDEOGRAPHIC SPACE - ].flatten.freeze - - # BOM (byte order mark) can also be seen as whitespace, it's a - # non-rendering character used to distinguish between little and big - # endian. This is not an issue in utf-8, so it must be ignored. - LEADERS_AND_TRAILERS = WHITESPACE + [65279] # ZERO-WIDTH NO-BREAK SPACE aka BOM - - # Returns a regular expression pattern that matches the passed Unicode - # codepoints. - def self.codepoints_to_pattern(array_of_codepoints) #:nodoc: - array_of_codepoints.collect { |e| [e].pack "U*".freeze }.join("|".freeze) - end - TRAILERS_PAT = /(#{codepoints_to_pattern(LEADERS_AND_TRAILERS)})+\Z/u - LEADERS_PAT = /\A(#{codepoints_to_pattern(LEADERS_AND_TRAILERS)})+/u # Detect whether the codepoint is in a certain character class. Returns # +true+ when it's in the specified character class and +false+ otherwise. diff --git a/activesupport/lib/active_support/time_with_zone.rb b/activesupport/lib/active_support/time_with_zone.rb index c35588fbae..889f71c4f3 100644 --- a/activesupport/lib/active_support/time_with_zone.rb +++ b/activesupport/lib/active_support/time_with_zone.rb @@ -80,7 +80,7 @@ module ActiveSupport # Returns a <tt>Time</tt> instance of the simultaneous time in the system timezone. def localtime(utc_offset = nil) - @localtime ||= utc.getlocal(utc_offset) + utc.getlocal(utc_offset) end alias_method :getlocal, :localtime @@ -477,6 +477,8 @@ module ActiveSupport end def transfer_time_values_to_utc_constructor(time) + # avoid creating another Time object if possible + return time if time.instance_of?(::Time) && time.utc? ::Time.utc(time.year, time.month, time.day, time.hour, time.min, time.sec + time.subsec) end diff --git a/activesupport/test/callbacks_test.rb b/activesupport/test/callbacks_test.rb index b4e98edd84..783952c8c7 100644 --- a/activesupport/test/callbacks_test.rb +++ b/activesupport/test/callbacks_test.rb @@ -56,6 +56,8 @@ module CallbacksTest end class Person < Record + attr_accessor :save_fails + [:before_save, :after_save].each do |callback_method| callback_method_sym = callback_method.to_sym send(callback_method, callback_symbol(callback_method_sym)) @@ -67,7 +69,9 @@ module CallbacksTest end def save - run_callbacks :save + run_callbacks :save do + raise "inside save" if save_fails + end end end @@ -222,6 +226,7 @@ module CallbacksTest class AroundPerson < MySuper attr_reader :history + attr_accessor :save_fails set_callback :save, :before, :nope, if: :no set_callback :save, :before, :nope, unless: :yes @@ -285,6 +290,7 @@ module CallbacksTest def save run_callbacks :save do + raise "inside save" if save_fails @history << "running" end end @@ -402,6 +408,71 @@ module CallbacksTest end end + class CallStackTest < ActiveSupport::TestCase + def test_tidy_call_stack + around = AroundPerson.new + around.save_fails = true + + exception = (around.save rescue $!) + + # Make sure we have the exception we're expecting + assert_equal "inside save", exception.message + + call_stack = exception.backtrace_locations + call_stack.pop caller_locations(0).size + + # Yes, this looks like an implementation test, but it's the least + # obtuse way of asserting that there aren't a load of entries in + # the call stack for each callback. + # + # If you've renamed a method, or squeezed more lines out, go ahead + # and update this assertion. But if you're here because a + # refactoring added new lines, please reconsider. + + # As shown here, our current budget is one line for run_callbacks + # itself, plus N+1 lines where N is the number of :around + # callbacks that have been invoked, if there are any (plus + # whatever the callbacks do themselves, of course). + + assert_equal [ + "block in save", + "block in run_callbacks", + "tweedle_deedle", + "block in run_callbacks", + "w0tyes", + "block in run_callbacks", + "tweedle_dum", + "block in run_callbacks", + ("call" if RUBY_VERSION < "2.3"), + "run_callbacks", + "save" + ].compact, call_stack.map(&:label) + end + + def test_short_call_stack + person = Person.new + person.save_fails = true + + exception = (person.save rescue $!) + + # Make sure we have the exception we're expecting + assert_equal "inside save", exception.message + + call_stack = exception.backtrace_locations + call_stack.pop caller_locations(0).size + + # This budget much simpler: with no :around callbacks invoked, + # there should be just one line. run_callbacks yields directly + # back to its caller. + + assert_equal [ + "block in save", + "run_callbacks", + "save" + ], call_stack.map(&:label) + end + end + class AroundCallbackResultTest < ActiveSupport::TestCase def test_save_around around = AroundPersonResult.new diff --git a/activesupport/test/executor_test.rb b/activesupport/test/executor_test.rb index 0b56ea008f..d409216206 100644 --- a/activesupport/test/executor_test.rb +++ b/activesupport/test/executor_test.rb @@ -158,6 +158,61 @@ class ExecutorTest < ActiveSupport::TestCase assert_equal :some_state, supplied_state end + def test_hook_insertion_order + invoked = [] + supplied_state = [] + + hook_class = Class.new do + attr_accessor :letter + + define_method(:initialize) do |letter| + self.letter = letter + end + + define_method(:run) do + invoked << :"run_#{letter}" + :"state_#{letter}" + end + + define_method(:complete) do |state| + invoked << :"complete_#{letter}" + supplied_state << state + end + end + + executor.register_hook(hook_class.new(:a)) + executor.register_hook(hook_class.new(:b)) + executor.register_hook(hook_class.new(:c), outer: true) + executor.register_hook(hook_class.new(:d)) + + executor.wrap {} + + assert_equal [:run_c, :run_a, :run_b, :run_d, :complete_a, :complete_b, :complete_d, :complete_c], invoked + assert_equal [:state_a, :state_b, :state_d, :state_c], supplied_state + end + + def test_class_serial_is_unaffected + hook = Class.new do + define_method(:run) do + nil + end + + define_method(:complete) do |state| + nil + end + end.new + + executor.register_hook(hook) + + before = RubyVM.stat(:class_serial) + executor.wrap {} + executor.wrap {} + executor.wrap {} + after = RubyVM.stat(:class_serial) + + assert_equal before, after + end + def test_separate_classes_can_wrap other_executor = Class.new(ActiveSupport::Executor) diff --git a/guides/source/2_2_release_notes.md b/guides/source/2_2_release_notes.md index c6bac34d18..ac5833e069 100644 --- a/guides/source/2_2_release_notes.md +++ b/guides/source/2_2_release_notes.md @@ -45,7 +45,6 @@ The internal documentation of Rails, in the form of code comments, has been impr * [A Guide to Testing Rails Applications](testing.html) * [Securing Rails Applications](security.html) * [Debugging Rails Applications](debugging_rails_applications.html) -* [Performance Testing Rails Applications](performance_testing.html) * [The Basics of Creating Rails Plugins](plugins.html) All told, the Guides provide tens of thousands of words of guidance for beginning and intermediate Rails developers. diff --git a/guides/source/5_0_release_notes.md b/guides/source/5_0_release_notes.md index 50886a57a7..cc332cbf97 100644 --- a/guides/source/5_0_release_notes.md +++ b/guides/source/5_0_release_notes.md @@ -583,7 +583,7 @@ Please refer to the [Changelog][active-record] for detailed changes. * Removed support for the legacy `mysql` database adapter from core. Most users should be able to use `mysql2`. It will be converted to a separate gem when when we find someone - to maintain it. ([Pull Request 1](https://github.com/rails/rails/pull/22642)], + to maintain it. ([Pull Request 1](https://github.com/rails/rails/pull/22642), [Pull Request 2](https://github.com/rails/rails/pull/22715)) * Removed support for the `protected_attributes` gem. diff --git a/guides/source/action_cable_overview.md b/guides/source/action_cable_overview.md index 4b9a22101a..a66a8ff484 100644 --- a/guides/source/action_cable_overview.md +++ b/guides/source/action_cable_overview.md @@ -422,7 +422,7 @@ App.cable.subscriptions.create "AppearanceChannel", buttonSelector = "[data-behavior~=appear_away]" install: -> - $(document).on "page:change.appearance", => + $(document).on "turbolinks:load.appearance", => @appear() $(document).on "click.appearance", buttonSelector, => diff --git a/guides/source/active_record_querying.md b/guides/source/active_record_querying.md index bbe1b0decc..38b1ffc4c8 100644 --- a/guides/source/active_record_querying.md +++ b/guides/source/active_record_querying.md @@ -81,7 +81,6 @@ The methods are: * `reorder` * `reverse_order` * `select` -* `distinct` * `where` Finder methods that return a collection, such as `where` and `group`, return an instance of `ActiveRecord::Relation`. Methods that find a single entity, such as `find` and `first`, return a single instance of the model. diff --git a/guides/source/active_support_instrumentation.md b/guides/source/active_support_instrumentation.md index 03af3cf819..3fc9d9bfa9 100644 --- a/guides/source/active_support_instrumentation.md +++ b/guides/source/active_support_instrumentation.md @@ -231,12 +231,13 @@ Active Record ### sql.active_record -| Key | Value | -| ---------------- | --------------------- | -| `:sql` | SQL statement | -| `:name` | Name of the operation | -| `:connection_id` | `self.object_id` | -| `:binds` | Bind parameters | +| Key | Value | +| ---------------- | ---------------------------------------- | +| `:sql` | SQL statement | +| `:name` | Name of the operation | +| `:connection_id` | `self.object_id` | +| `:binds` | Bind parameters | +| `:cached` | `true` is added when cached queries used | INFO. The adapters will add their own data as well. diff --git a/guides/source/configuring.md b/guides/source/configuring.md index fbf3c27957..3df8a0ed26 100644 --- a/guides/source/configuring.md +++ b/guides/source/configuring.md @@ -1296,7 +1296,7 @@ evented file system monitor to detect changes when `config.cache_classes` is ```ruby group :development do - gem 'listen', '~> 3.0.4' + gem 'listen', '>= 3.0.5', '< 3.2' end ``` diff --git a/guides/source/testing.md b/guides/source/testing.md index 98847fde18..0ac5121b12 100644 --- a/guides/source/testing.md +++ b/guides/source/testing.md @@ -1317,8 +1317,8 @@ end This test is pretty simple and only asserts that the job get the work done as expected. -By default, `ActiveJob::TestCase` will set the queue adapter to `:async` so that -your jobs are performed in an async fashion. It will also ensure that all previously performed +By default, `ActiveJob::TestCase` will set the queue adapter to `:test` so that +your jobs are performed inline. It will also ensure that all previously performed and enqueued jobs are cleared before any test run so you can safely assume that no jobs have already been executed in the scope of each test. diff --git a/railties/CHANGELOG.md b/railties/CHANGELOG.md index b03c87e9ba..b488e4ed8e 100644 --- a/railties/CHANGELOG.md +++ b/railties/CHANGELOG.md @@ -1,3 +1,7 @@ +* Allow the use of listen's 3.1.x branch + + *Esteban Santana Santana* + * Run `Minitest.after_run` hooks when running `rails test`. *Michael Grosser* diff --git a/railties/lib/rails/commands/server/server_command.rb b/railties/lib/rails/commands/server/server_command.rb index 4349dfdc71..14cf72f483 100644 --- a/railties/lib/rails/commands/server/server_command.rb +++ b/railties/lib/rails/commands/server/server_command.rb @@ -21,7 +21,7 @@ module Rails def option_parser(options) # :nodoc: OptionParser.new do |opts| - opts.banner = "Usage: rails server [mongrel, thin etc] [options]" + opts.banner = "Usage: rails server [puma, thin etc] [options]" opts.separator "" opts.separator "Options:" diff --git a/railties/lib/rails/generators/rails/app/templates/Gemfile b/railties/lib/rails/generators/rails/app/templates/Gemfile index 7af5fcd3c1..422217286c 100644 --- a/railties/lib/rails/generators/rails/app/templates/Gemfile +++ b/railties/lib/rails/generators/rails/app/templates/Gemfile @@ -39,7 +39,7 @@ group :development do <%- end -%> <%- end -%> <% if depend_on_listen? -%> - gem 'listen', '~> 3.0.5' + gem 'listen', '>= 3.0.5', '< 3.2' <% end -%> <% if spring_install? -%> # Spring speeds up development by keeping your application running in the background. Read more: https://github.com/rails/spring diff --git a/railties/lib/rails/generators/rails/plugin/templates/bin/test.tt b/railties/lib/rails/generators/rails/plugin/templates/bin/test.tt index 62b94618fd..c0fbb84a93 100644 --- a/railties/lib/rails/generators/rails/plugin/templates/bin/test.tt +++ b/railties/lib/rails/generators/rails/plugin/templates/bin/test.tt @@ -5,4 +5,6 @@ require 'rails/test_unit/minitest_plugin' Rails::TestUnitReporter.executable = 'bin/test' -exit Minitest.run(ARGV) +Minitest.run_via[:rails] = true + +require "active_support/testing/autorun" diff --git a/railties/test/generators/plugin_test_runner_test.rb b/railties/test/generators/plugin_test_runner_test.rb index 04b4b10254..7a10a2afa9 100644 --- a/railties/test/generators/plugin_test_runner_test.rb +++ b/railties/test/generators/plugin_test_runner_test.rb @@ -86,6 +86,12 @@ class PluginTestRunnerTest < ActiveSupport::TestCase assert_match(%r{cannot load such file.+test/not_exists\.rb}, error) end + def test_executed_only_once + create_test_file "foo" + result = run_test_command("test/foo_test.rb") + assert_equal 1, result.scan(/1 runs, 1 assertions, 0 failures/).length + end + private def plugin_path "#{@destination_root}/bukkits" |