Skip to content

Commit 208ca03

Browse files
authored
Merge pull request #53 from cardmagic/fix/register-actors-in-web-processes
fix: register application actors in every process
2 parents 68f340f + 260881d commit 208ca03

12 files changed

Lines changed: 167 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,27 @@
11
# Changelog
22

3-
## Unreleased
3+
## 0.14.1 - 2026-08-24
4+
5+
- Register application actors in every process that boots the application.
6+
The engine now loads the host application's `app/actors` directories from a
7+
`to_prepare` hook, which previously only the `solid_objects start` process
8+
did. An actor registers itself as a side effect of its class loading, so a
9+
lazily loading web process began with an empty registry. `ActorChannel`
10+
looks the actor up by name, and the resulting `UnknownActorType` reached the
11+
rescue that rejects the subscription: a Cable subscription for a real actor
12+
was rejected in any web process that had not yet rendered that actor, and
13+
the page kept a card that never updated. `ComponentsController` resolves the
14+
same way through `ActorSnapshot`. `Transmission.receive` already carried a
15+
registry-miss retry for this reason, and it stays as a guard for a host that
16+
reaches the gem without the engine.
17+
- Report why a Cable subscription was rejected. Every reject path in
18+
`ActorChannel#subscribed` now emits `solid_objects.subscription.rejected`
19+
with a `reason`, the actor identity, and the `error_class` where an
20+
exception caused it. Five conditions previously collapsed into one silent
21+
`reject`, which is invisible from the browser and left nothing in the log to
22+
distinguish an unregistered actor type from a tampered token. Exception
23+
messages stay out of the payload, because a component or payload failure can
24+
carry actor state.
425

526
- State where `async` waits when no worker runs. The `async` section of the
627
README and the runtime section of `docs/operations.md` now say that the

Gemfile.lock

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: .
33
specs:
4-
solid_objects (0.14.0)
4+
solid_objects (0.14.1)
55
actioncable (>= 7.1)
66
actionpack (>= 7.1)
77
actionview (>= 7.1)
@@ -384,7 +384,7 @@ CHECKSUMS
384384
rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d
385385
ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
386386
securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
387-
solid_objects (0.14.0)
387+
solid_objects (0.14.1)
388388
sqlite3 (2.9.5-aarch64-linux-gnu) sha256=78075b6337d3d182c6d2b4691049ed45cd220826160c9ea18946bf6a1de200dc
389389
sqlite3 (2.9.5-aarch64-linux-musl) sha256=18c801185deb4adc01ddb281e8f672a39e3d1729979ca91e39439cd3eac0402d
390390
sqlite3 (2.9.5-arm-linux-gnu) sha256=1bdfca0c7d63998c60b0f4a8e3c8df2d33800ccc4abd2d612eddbbbc92a4c48b

README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1123,10 +1123,12 @@ and marks process rows stopped on graceful shutdown. A hard-killed worker's
11231123
claimed turn is recovered after its process heartbeat or activation lease
11241124
becomes stale.
11251125

1126-
Before any role starts, the CLI loads actors from the host application's
1127-
`app/actors` directories through Rails' main autoloader. This works when
1128-
development eager loading is disabled and does not require actor references in
1129-
an initializer.
1126+
The engine loads actors from the host application's `app/actors` directories
1127+
through Rails' main autoloader, in every process that boots the application.
1128+
This works when eager loading is disabled and does not require actor
1129+
references in an initializer. A web process therefore resolves an actor by
1130+
name for a Cable subscription or a component render without having loaded that
1131+
class through an earlier request.
11301132

11311133
See the [operations guide](docs/operations.md) for monitoring, reconciliation,
11321134
shutdown, retention, and backup guidance.

docs/operations.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,18 @@ The message is durable and waits for the first process that runs the roles. A
3838
direct call or an explicit `sync` needs no running role, because the caller's
3939
own path executes it.
4040

41-
The command loads the host application's `app/actors` directories before
41+
The engine loads the host application's `app/actors` directories in every
42+
process that boots the application, and the command repeats that load before
4243
starting any runtime role, even when Rails eager loading is disabled. Actors in
4344
the conventional directory do not need initializer references. The targeted
4445
loader participates in Rails preparation callbacks so a development reload can
4546
replace a registered actor class without loading unrelated application code.
4647

48+
An actor registers itself as its class loads, and a web process resolves
49+
actors by name for Cable subscriptions and component renders. Loading them in
50+
every process is what lets a freshly booted web process serve a live card for
51+
an actor no request in that process has rendered yet.
52+
4753
Inspect process records and clean stale ownership:
4854

4955
```bash
@@ -194,6 +200,15 @@ transaction rejection, commit-action start/completion/failure, effect and
194200
broadcast enqueue/completion, reminder enqueue, actor destruction/expiration,
195201
retention pruning, process cleanup, and supervisor lifecycle.
196202

203+
`solid_objects.subscription.rejected` reports a rejected Cable subscription.
204+
A rejection closes the socket and leaves the page holding a stale card, and
205+
the browser cannot say which of the conditions applied. The event carries the
206+
`reason`, the actor identity, and the `error_class` where an exception caused
207+
it. The reason is one of `unregistered_actor_type`, `invalid_stream_token`,
208+
`invalid_component_token`, `malformed_component_registration`,
209+
`missing_subscription_parameter`, or `unauthorized`. Exception messages are
210+
excluded, because a component or payload failure can carry actor state.
211+
197212
`solid_objects.reminder.replaced` reports a `schedule` call that moved an alarm
198213
already armed under the same name on the same actor, carrying the actor
199214
identity, reminder `name`, `previous_run_at`, and `next_run_at`. Reminders are

docs/roadmap.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,13 @@
126126
untested end to end, which is how a raising payload block came to reject the
127127
subscription; it is now covered and confined, and the payload authorization
128128
context is resolved through `payload_authorization_context` rather than
129-
handing the block a raw Cable connection.
129+
handing the block a raw Cable connection. Actor registration in a web process
130+
was assumed rather than arranged: an actor registered only as a side effect
131+
of its class loading, and only the worker CLI loaded the host's `app/actors`,
132+
so a lazily loading web process rejected subscriptions for actors it could
133+
serve until some earlier request happened to load the class. The engine now
134+
loads them in every process, and a rejected subscription reports which
135+
condition caused it instead of closing the socket silently.
130136
- Backpressure: mailbox/payload/state/result caps and fair yields exist;
131137
distributed per-actor rate limits and global admission control do not.
132138
- Administration: `SolidObjects::Web` is a mountable Rack dashboard covering

lib/solid_objects/actor_channel.rb

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@
44

55
module SolidObjects
66
class ActorChannel < ActionCable::Channel::Base
7+
REJECT_REASONS = {
8+
UnknownActorType => "unregistered_actor_type",
9+
InvalidStreamToken => "invalid_stream_token",
10+
InvalidComponentToken => "invalid_component_token",
11+
JSON::ParserError => "malformed_component_registration",
12+
KeyError => "missing_subscription_parameter"
13+
}.freeze
14+
715
# @rbs () -> void
816
def subscribed
917
identity = StreamToken.verify(params.fetch("token"))
@@ -15,7 +23,9 @@ def subscribed
1523
actor_id:,
1624
authorization_context: connection
1725
)
18-
return reject unless authorized
26+
unless authorized
27+
return reject_and_report("unauthorized", actor_type:, actor_id:)
28+
end
1929

2030
@reference = Reference.new(actor_type:, actor_id:)
2131
@scalar_observables = identity["observables"]
@@ -43,8 +53,8 @@ def subscribed
4353
JSON::ParserError,
4454
InvalidStreamToken,
4555
InvalidComponentToken,
46-
UnknownActorType
47-
reject
56+
UnknownActorType => error
57+
reject_and_report(reject_reason(error), actor_type:, actor_id:, error:)
4858
end
4959

5060
private
@@ -54,6 +64,26 @@ def subscribed
5464
:scalar_observables,
5565
:payload_names
5666

67+
# The exception message stays out of the payload, because a component or
68+
# payload error can carry actor state into logs.
69+
# @rbs (String, actor_type: String?, actor_id: String?, ?error: Exception?) -> void
70+
def reject_and_report(reason, actor_type:, actor_id:, error: nil)
71+
SolidObjects.instrument(
72+
:"subscription.rejected",
73+
reason:,
74+
actor_type:,
75+
actor_id:,
76+
error_class: error&.class&.name
77+
)
78+
reject
79+
end
80+
81+
# @rbs (Exception) -> String
82+
def reject_reason(error)
83+
match = REJECT_REASONS.find { |error_class, _| error.is_a?(error_class) }
84+
match ? match.last : "invalid_subscription"
85+
end
86+
5787
# @rbs (String) -> void
5888
def receive_broadcast(stream)
5989
invalidation = TurboStreamRenderer.invalidation(stream)

lib/solid_objects/engine.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ class Engine < ::Rails::Engine
1515
SolidObjects::LogSubscriber.install
1616
end
1717

18+
initializer "solid_objects.actors" do |application|
19+
application.config.to_prepare { ApplicationActorLoader.new.call }
20+
end
21+
1822
initializer "solid_objects.database", after: :load_config_initializers do
1923
ActiveSupport.on_load(:active_record) do
2024
require RECORD_PATH

lib/solid_objects/version.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# rbs_inline: enabled
22

33
module SolidObjects
4-
VERSION = "0.14.0"
4+
VERSION = "0.14.1"
55
end

sig/generated/lib/solid_objects/actor_channel.rbs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
module SolidObjects
44
class ActorChannel < ActionCable::Channel::Base
5+
REJECT_REASONS: untyped
6+
57
# @rbs () -> void
68
def subscribed: () -> void
79

@@ -15,6 +17,14 @@ module SolidObjects
1517

1618
attr_reader payload_names: untyped
1719

20+
# The exception message stays out of the payload, because a component or
21+
# payload error can carry actor state into logs.
22+
# @rbs (String, actor_type: String?, actor_id: String?, ?error: Exception?) -> void
23+
def reject_and_report: (String, actor_type: String?, actor_id: String?, ?error: Exception?) -> void
24+
25+
# @rbs (Exception) -> String
26+
def reject_reason: (Exception) -> String
27+
1828
# @rbs (String) -> void
1929
def receive_broadcast: (String) -> void
2030

test/dummy/actor_registry_check.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# frozen_string_literal: true
2+
3+
ENV["RAILS_ENV"] = "test"
4+
5+
require_relative "config/environment"
6+
7+
# A lazily loading web process. Nothing here names the actor class, the way
8+
# nothing in a freshly booted Passenger worker names it until some request
9+
# happens to render or address that actor.
10+
puts SolidObjects.registry.registered?("CliWorkerActor") ? "registered" : "unregistered"

0 commit comments

Comments
 (0)