Skip to content

fix: register application actors in every process - #53

Merged
cardmagic merged 1 commit into
mainfrom
fix/register-actors-in-web-processes
Aug 24, 2026
Merged

fix: register application actors in every process#53
cardmagic merged 1 commit into
mainfrom
fix/register-actors-in-web-processes

Conversation

@cardmagic

Copy link
Copy Markdown
Owner

The defect

An actor registers itself as a side effect of its class loading. Only the
solid_objects start process loaded the host application's app/actors:
ApplicationActorLoader had exactly two callers, cli.rb and
transmission.rb. The engine registered four initializers (configuration,
database, helpers, assets) and none of them installed the loader.

So a lazily loading web process boots with an empty registry.
ActorChannel#subscribed resolves the actor by name:

SolidObjects.registry.fetch(actor_type)   # raises UnknownActorType

and UnknownActorType is one of the five exceptions in the rescue at the
bottom of the method, which calls a bare reject. A Passenger worker that had
not yet rendered that actor rejected a valid subscription for an actor it was
able to serve, and the page kept a card that never updated.
ComponentsController resolves the same way through ActorSnapshot.

The 0.14.0 changelog names the same hazard on the ingest path: a registry-miss
retry was added to Transmission.receive "because a lazy-loading web process
has no other reason to have loaded the target class." The Cable path never got
the equivalent.

The failing test

The dummy application sets config.eager_load = false, which makes it the
right harness. test/dummy/actor_registry_check.rb boots it in a separate
process and names no actor class, the way nothing in a fresh web process names
one until a request does. Before the fix:

EngineTest#test_registers_application_actors_in_a_process_that_does_not_eager_load:
Expected: "registered"
  Actual: "unregistered"

The fix

initializer "solid_objects.actors" do |application|
  application.config.to_prepare { ApplicationActorLoader.new.call }
end

That is the initializer a host would otherwise have to write itself, moved
into the engine. to_prepare also means a development reload re-registers a
replaced actor class, which is why ApplicationActorLoader#install already
used it for the worker.

Transmission.receive keeps its retry as a guard for a host that reaches the
gem without the engine.

The second defect: the reject said nothing

Five conditions collapsed into one bare reject. A rejection closes the
socket, so the browser cannot report which one fired, and nothing was logged
or instrumented. That is the reason this took so long to find from the
outside.

Every reject path now emits solid_objects.subscription.rejected carrying a
reason, the actor identity, and the error_class where an exception caused
it:

reason condition
unregistered_actor_type UnknownActorType, the defect above
invalid_stream_token tampered token, unknown observable or payload name
invalid_component_token tampered component registration
malformed_component_registration JSON::ParserError on params["components"]
missing_subscription_parameter KeyError, no token
unauthorized authorize_subscription returned false

Exception messages stay out of the payload, matching the existing rule on
payload_broadcast_failed: a component or payload failure can carry actor
state, and a log line is the wrong place for it.

One implementation note worth recording. The helper is named
reject_and_report, not reject_subscription, because
ActionCable::Channel::Base already defines reject_subscription and calls it
with no arguments from subscribe_to_channel. Shadowing it turned seven
existing tests into ArgumentError: wrong number of arguments (given 0, expected 1; required keywords: actor_type, actor_id), which is how the
collision surfaced.

Host-side workaround, no longer needed after this ships

# config/initializers/solid_objects_actor_loading.rb
Rails.application.config.to_prepare do
  SolidObjects::ApplicationActorLoader.new.call
end

Also in this PR

  • Version bumped to 0.14.1, with CHANGELOG.md and Gemfile.lock, matching
    solid-objects-js 0.14.1.
  • README.md and docs/operations.md now say the engine loads app/actors
    in every process that boots the application, not only the CLI, and
    docs/operations.md documents the new event and its reasons.
  • docs/roadmap.md records the discovery next to the payload-block precedent
    in the same paragraph: actor registration in a web process was assumed
    rather than arranged.

Effects

  • API: one new instrumentation event. No public method signatures change.
  • Correctness: a valid subscription that was rejected in a lazily loading web
    process is now served.
  • Security: unchanged. authorize_subscription still runs on every
    subscription and still denies by default; the new event adds actor identity
    and an error class to logs, and deliberately no exception messages.
  • Migration and compatibility: none. Applications carrying the workaround
    initializer above can drop it, and keeping it is harmless.

Validation

  • bundle exec rake (test, standard, rubocop, rbs, steep, security): pass,
    581 runs, 1898 assertions, 0 failures, 0 errors, 15 skips.
  • The engine test quoted above fails on origin/main and passes here.

An actor registers itself as a side effect of its class loading, and only
the solid_objects start process loaded the host application's app/actors.
A lazily loading web process therefore booted with an empty registry.

ActorChannel#subscribed resolves the actor by name, so UnknownActorType
reached the rescue that rejects the subscription. A Passenger worker that
had not yet rendered that actor rejected a valid subscription for an
actor it could serve, and the page kept a card that never updated.
ComponentsController resolves the same way through ActorSnapshot.
Transmission.receive already carried a registry-miss retry, added in
0.14.0 because "a lazy-loading web process has no other reason to have
loaded the target class"; the same miss went unhandled on the Cable path.

The engine now loads app/actors from a to_prepare hook, which is what a
host would otherwise have to write in an initializer. The dummy app,
which sets eager_load = false, is the harness: a boot that names no
actor class reports the actor as registered.

The reject was also silent. Five conditions collapsed into one bare
reject, which closes the socket without a log line, so an operator
cannot tell an unregistered actor type from a tampered token. Every path
now emits solid_objects.subscription.rejected with a reason, the actor
identity, and the error class where an exception caused it. Exception
messages stay out, because a component or payload failure can carry
actor state.

The reporting helper is reject_and_report, not reject_subscription:
ActionCable::Channel::Base already defines reject_subscription and calls
it with no arguments from subscribe_to_channel, which the test suite
caught as ArgumentError across seven cases.

Bumps the version to 0.14.1 with the changelog and the lockfile.
@cardmagic
cardmagic merged commit 208ca03 into main Aug 24, 2026
40 checks passed
@cardmagic
cardmagic deleted the fix/register-actors-in-web-processes branch August 24, 2026 23:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant