diff --git a/CHANGELOG.md b/CHANGELOG.md index 92b679d..277b3d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,27 @@ # Changelog -## Unreleased +## 0.14.1 - 2026-08-24 + +- Register application actors in every process that boots the application. + The engine now loads the host application's `app/actors` directories from a + `to_prepare` hook, which previously only the `solid_objects start` process + did. An actor registers itself as a side effect of its class loading, so a + lazily loading web process began with an empty registry. `ActorChannel` + looks the actor up by name, and the resulting `UnknownActorType` reached the + rescue that rejects the subscription: a Cable subscription for a real actor + was rejected in any web process that had not yet rendered that actor, and + the page kept a card that never updated. `ComponentsController` resolves the + same way through `ActorSnapshot`. `Transmission.receive` already carried a + registry-miss retry for this reason, and it stays as a guard for a host that + reaches the gem without the engine. +- Report why a Cable subscription was rejected. Every reject path in + `ActorChannel#subscribed` now emits `solid_objects.subscription.rejected` + with a `reason`, the actor identity, and the `error_class` where an + exception caused it. Five conditions previously collapsed into one silent + `reject`, which is invisible from the browser and left nothing in the log to + distinguish an unregistered actor type from a tampered token. Exception + messages stay out of the payload, because a component or payload failure can + carry actor state. - State where `async` waits when no worker runs. The `async` section of the README and the runtime section of `docs/operations.md` now say that the diff --git a/Gemfile.lock b/Gemfile.lock index 774973d..d1bc2cc 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - solid_objects (0.14.0) + solid_objects (0.14.1) actioncable (>= 7.1) actionpack (>= 7.1) actionview (>= 7.1) @@ -384,7 +384,7 @@ CHECKSUMS rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 - solid_objects (0.14.0) + solid_objects (0.14.1) sqlite3 (2.9.5-aarch64-linux-gnu) sha256=78075b6337d3d182c6d2b4691049ed45cd220826160c9ea18946bf6a1de200dc sqlite3 (2.9.5-aarch64-linux-musl) sha256=18c801185deb4adc01ddb281e8f672a39e3d1729979ca91e39439cd3eac0402d sqlite3 (2.9.5-arm-linux-gnu) sha256=1bdfca0c7d63998c60b0f4a8e3c8df2d33800ccc4abd2d612eddbbbc92a4c48b diff --git a/README.md b/README.md index 69050f1..f615ee2 100644 --- a/README.md +++ b/README.md @@ -1123,10 +1123,12 @@ and marks process rows stopped on graceful shutdown. A hard-killed worker's claimed turn is recovered after its process heartbeat or activation lease becomes stale. -Before any role starts, the CLI loads actors from the host application's -`app/actors` directories through Rails' main autoloader. This works when -development eager loading is disabled and does not require actor references in -an initializer. +The engine loads actors from the host application's `app/actors` directories +through Rails' main autoloader, in every process that boots the application. +This works when eager loading is disabled and does not require actor +references in an initializer. A web process therefore resolves an actor by +name for a Cable subscription or a component render without having loaded that +class through an earlier request. See the [operations guide](docs/operations.md) for monitoring, reconciliation, shutdown, retention, and backup guidance. diff --git a/docs/operations.md b/docs/operations.md index f1dd8dc..552e03a 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -38,12 +38,18 @@ The message is durable and waits for the first process that runs the roles. A direct call or an explicit `sync` needs no running role, because the caller's own path executes it. -The command loads the host application's `app/actors` directories before +The engine loads the host application's `app/actors` directories in every +process that boots the application, and the command repeats that load before starting any runtime role, even when Rails eager loading is disabled. Actors in the conventional directory do not need initializer references. The targeted loader participates in Rails preparation callbacks so a development reload can replace a registered actor class without loading unrelated application code. +An actor registers itself as its class loads, and a web process resolves +actors by name for Cable subscriptions and component renders. Loading them in +every process is what lets a freshly booted web process serve a live card for +an actor no request in that process has rendered yet. + Inspect process records and clean stale ownership: ```bash @@ -194,6 +200,15 @@ transaction rejection, commit-action start/completion/failure, effect and broadcast enqueue/completion, reminder enqueue, actor destruction/expiration, retention pruning, process cleanup, and supervisor lifecycle. +`solid_objects.subscription.rejected` reports a rejected Cable subscription. +A rejection closes the socket and leaves the page holding a stale card, and +the browser cannot say which of the conditions applied. The event carries the +`reason`, the actor identity, and the `error_class` where an exception caused +it. The reason is one of `unregistered_actor_type`, `invalid_stream_token`, +`invalid_component_token`, `malformed_component_registration`, +`missing_subscription_parameter`, or `unauthorized`. Exception messages are +excluded, because a component or payload failure can carry actor state. + `solid_objects.reminder.replaced` reports a `schedule` call that moved an alarm already armed under the same name on the same actor, carrying the actor identity, reminder `name`, `previous_run_at`, and `next_run_at`. Reminders are diff --git a/docs/roadmap.md b/docs/roadmap.md index fe19564..9811b11 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -126,7 +126,13 @@ untested end to end, which is how a raising payload block came to reject the subscription; it is now covered and confined, and the payload authorization context is resolved through `payload_authorization_context` rather than - handing the block a raw Cable connection. + handing the block a raw Cable connection. Actor registration in a web process + was assumed rather than arranged: an actor registered only as a side effect + of its class loading, and only the worker CLI loaded the host's `app/actors`, + so a lazily loading web process rejected subscriptions for actors it could + serve until some earlier request happened to load the class. The engine now + loads them in every process, and a rejected subscription reports which + condition caused it instead of closing the socket silently. - Backpressure: mailbox/payload/state/result caps and fair yields exist; distributed per-actor rate limits and global admission control do not. - Administration: `SolidObjects::Web` is a mountable Rack dashboard covering diff --git a/lib/solid_objects/actor_channel.rb b/lib/solid_objects/actor_channel.rb index 14c7b72..7bbf4d8 100644 --- a/lib/solid_objects/actor_channel.rb +++ b/lib/solid_objects/actor_channel.rb @@ -4,6 +4,14 @@ module SolidObjects class ActorChannel < ActionCable::Channel::Base + REJECT_REASONS = { + UnknownActorType => "unregistered_actor_type", + InvalidStreamToken => "invalid_stream_token", + InvalidComponentToken => "invalid_component_token", + JSON::ParserError => "malformed_component_registration", + KeyError => "missing_subscription_parameter" + }.freeze + # @rbs () -> void def subscribed identity = StreamToken.verify(params.fetch("token")) @@ -15,7 +23,9 @@ def subscribed actor_id:, authorization_context: connection ) - return reject unless authorized + unless authorized + return reject_and_report("unauthorized", actor_type:, actor_id:) + end @reference = Reference.new(actor_type:, actor_id:) @scalar_observables = identity["observables"] @@ -43,8 +53,8 @@ def subscribed JSON::ParserError, InvalidStreamToken, InvalidComponentToken, - UnknownActorType - reject + UnknownActorType => error + reject_and_report(reject_reason(error), actor_type:, actor_id:, error:) end private @@ -54,6 +64,26 @@ def subscribed :scalar_observables, :payload_names + # The exception message stays out of the payload, because a component or + # payload error can carry actor state into logs. + # @rbs (String, actor_type: String?, actor_id: String?, ?error: Exception?) -> void + def reject_and_report(reason, actor_type:, actor_id:, error: nil) + SolidObjects.instrument( + :"subscription.rejected", + reason:, + actor_type:, + actor_id:, + error_class: error&.class&.name + ) + reject + end + + # @rbs (Exception) -> String + def reject_reason(error) + match = REJECT_REASONS.find { |error_class, _| error.is_a?(error_class) } + match ? match.last : "invalid_subscription" + end + # @rbs (String) -> void def receive_broadcast(stream) invalidation = TurboStreamRenderer.invalidation(stream) diff --git a/lib/solid_objects/engine.rb b/lib/solid_objects/engine.rb index 26ddaef..312105f 100644 --- a/lib/solid_objects/engine.rb +++ b/lib/solid_objects/engine.rb @@ -15,6 +15,10 @@ class Engine < ::Rails::Engine SolidObjects::LogSubscriber.install end + initializer "solid_objects.actors" do |application| + application.config.to_prepare { ApplicationActorLoader.new.call } + end + initializer "solid_objects.database", after: :load_config_initializers do ActiveSupport.on_load(:active_record) do require RECORD_PATH diff --git a/lib/solid_objects/version.rb b/lib/solid_objects/version.rb index 3f78538..37e672b 100644 --- a/lib/solid_objects/version.rb +++ b/lib/solid_objects/version.rb @@ -1,5 +1,5 @@ # rbs_inline: enabled module SolidObjects - VERSION = "0.14.0" + VERSION = "0.14.1" end diff --git a/sig/generated/lib/solid_objects/actor_channel.rbs b/sig/generated/lib/solid_objects/actor_channel.rbs index db13c39..e6d45a2 100644 --- a/sig/generated/lib/solid_objects/actor_channel.rbs +++ b/sig/generated/lib/solid_objects/actor_channel.rbs @@ -2,6 +2,8 @@ module SolidObjects class ActorChannel < ActionCable::Channel::Base + REJECT_REASONS: untyped + # @rbs () -> void def subscribed: () -> void @@ -15,6 +17,14 @@ module SolidObjects attr_reader payload_names: untyped + # The exception message stays out of the payload, because a component or + # payload error can carry actor state into logs. + # @rbs (String, actor_type: String?, actor_id: String?, ?error: Exception?) -> void + def reject_and_report: (String, actor_type: String?, actor_id: String?, ?error: Exception?) -> void + + # @rbs (Exception) -> String + def reject_reason: (Exception) -> String + # @rbs (String) -> void def receive_broadcast: (String) -> void diff --git a/test/dummy/actor_registry_check.rb b/test/dummy/actor_registry_check.rb new file mode 100644 index 0000000..8c751fa --- /dev/null +++ b/test/dummy/actor_registry_check.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +ENV["RAILS_ENV"] = "test" + +require_relative "config/environment" + +# A lazily loading web process. Nothing here names the actor class, the way +# nothing in a freshly booted Passenger worker names it until some request +# happens to render or address that actor. +puts SolidObjects.registry.registered?("CliWorkerActor") ? "registered" : "unregistered" diff --git a/test/integration/actor_channel_test.rb b/test/integration/actor_channel_test.rb index 68aed27..fc9ff92 100644 --- a/test/integration/actor_channel_test.rb +++ b/test/integration/actor_channel_test.rb @@ -247,6 +247,46 @@ def stream_from(_broadcasting, callback = nil, coder: nil, &block) assert_no_streams end + test "reports an unregistered actor type as the reject reason" do + SolidObjects.configuration.authorize_subscription = ->(**) { true } + reference = SolidObjects::Reference.new( + actor_type: "unregistered-channel-actor", + actor_id: "actor-1" + ) + rejections = [] + handle = ActiveSupport::Notifications.subscribe( + "solid_objects.subscription.rejected" + ) { |event| rejections << event.payload } + + subscribe token: SolidObjects::StreamToken.generate(reference) + + assert subscription.rejected? + assert_equal 1, rejections.length + assert_equal "unregistered-channel-actor", rejections.first.fetch(:actor_type) + assert_equal "actor-1", rejections.first.fetch(:actor_id) + assert_equal "SolidObjects::UnknownActorType", rejections.first.fetch(:error_class) + ensure + ActiveSupport::Notifications.unsubscribe(handle) if handle + end + + test "reports a failed host authorization as the reject reason" do + reference = ChannelActor.ref("actor-1") + SolidObjects.configuration.authorize_subscription = ->(**) { false } + rejections = [] + handle = ActiveSupport::Notifications.subscribe( + "solid_objects.subscription.rejected" + ) { |event| rejections << event.payload } + + subscribe token: SolidObjects::StreamToken.generate(reference) + + assert subscription.rejected? + assert_equal 1, rejections.length + assert_equal "unauthorized", rejections.first.fetch(:reason) + assert_nil rejections.first[:error_class] + ensure + ActiveSupport::Notifications.unsubscribe(handle) if handle + end + test "refreshes a component once when several dependencies change in one turn" do reference = ChannelActor.ref("actor-1") SolidObjects.configuration.authorize_subscription = ->(**) { true } diff --git a/test/integration/engine_test.rb b/test/integration/engine_test.rb index f471a79..207246c 100644 --- a/test/integration/engine_test.rb +++ b/test/integration/engine_test.rb @@ -16,6 +16,22 @@ class EngineTest < ActiveSupport::TestCase assert_equal "solid_objects_dummy_booted", output.strip end + # An actor registers itself as a side effect of its class loading. A web + # process resolves actors by name for Cable subscriptions and component + # renders, so a lazily loading process that boots with an empty registry + # rejects a live subscription for an actor it is able to serve. + test "registers application actors in a process that does not eager load" do + command = [ + Gem.ruby, + File.expand_path("../dummy/actor_registry_check.rb", __dir__) + ] + + output, error_output, status = Open3.capture3(*command) + + assert status.success?, error_output + assert_equal "registered", output.strip + end + test "resolves the component endpoint from the engine mount" do command = [ Gem.ruby,