Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 2 additions & 2 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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
Expand Down
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 16 additions & 1 deletion docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 33 additions & 3 deletions lib/solid_objects/actor_channel.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand All @@ -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"]
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions lib/solid_objects/engine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lib/solid_objects/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# rbs_inline: enabled

module SolidObjects
VERSION = "0.14.0"
VERSION = "0.14.1"
end
10 changes: 10 additions & 0 deletions sig/generated/lib/solid_objects/actor_channel.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

module SolidObjects
class ActorChannel < ActionCable::Channel::Base
REJECT_REASONS: untyped

# @rbs () -> void
def subscribed: () -> void

Expand All @@ -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

Expand Down
10 changes: 10 additions & 0 deletions test/dummy/actor_registry_check.rb
Original file line number Diff line number Diff line change
@@ -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"
40 changes: 40 additions & 0 deletions test/integration/actor_channel_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
16 changes: 16 additions & 0 deletions test/integration/engine_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading