Skip to content

Commit 5259fbd

Browse files
committed
fix: load the mailbox when the gem is required
Mailbox was reachable only through the caller path, which loads it as a side effect of SolidObjects.client. The reminder scheduler and the effect executor enqueue through it directly and run in solid_objects start, a process that never calls the client, so both raised NameError on every attempt. Reminders never fired and effect result messages never delivered, while the supervisor replaced the dying role over and over. Every test passed because a test process has already loaded the constant through some other path. That is the whole shape of the bug, so the regression test runs a due reminder through a real worker, and a load contract test asks a fresh process what requiring the gem actually defines. A file that stops being loaded now fails that test unless it is listed as deliberately deferred with a reason. The roadmap listed reminders as implemented and tested while they were broken in the only process that runs them.
1 parent efbe93a commit 5259fbd

8 files changed

Lines changed: 209 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,23 @@
11
# Changelog
22

3+
## 0.10.2 - 2026-08-10
4+
5+
- Load the mailbox when the gem is required. `SolidObjects::Mailbox` was
6+
reachable only through the caller path, which loads it as a side effect of
7+
`SolidObjects.client`. The reminder scheduler and the effect executor enqueue
8+
through it directly and run in `solid_objects start`, a process that never
9+
calls the client, so both raised
10+
`NameError: uninitialized constant SolidObjects::ReminderScheduler::Mailbox`.
11+
Reminders never fired and effect result messages never delivered, while the
12+
supervisor replaced the dying role over and over. Nothing caught it because
13+
every test process has already loaded the constant through some other path.
14+
- Add a load contract test that asks a fresh process what `require
15+
"solid_objects"` actually defines, and fails when a file stops being loaded
16+
unless it is listed as deliberately deferred with a reason. This is the class
17+
of bug that only appears in the standalone worker.
18+
- Run a due reminder through a real `solid_objects start` worker in the test
19+
suite, rather than only in process.
20+
321
## 0.10.1 - 2026-08-10
422

523
- Support Trilogy. Adapter selection matched the client name rather than 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.10.1)
4+
solid_objects (0.10.2)
55
actioncable (>= 8.0)
66
actionpack (>= 8.0)
77
actionview (>= 8.0)
@@ -383,7 +383,7 @@ CHECKSUMS
383383
rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d
384384
ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
385385
securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
386-
solid_objects (0.10.1)
386+
solid_objects (0.10.2)
387387
sqlite3 (2.9.5-aarch64-linux-gnu) sha256=78075b6337d3d182c6d2b4691049ed45cd220826160c9ea18946bf6a1de200dc
388388
sqlite3 (2.9.5-aarch64-linux-musl) sha256=18c801185deb4adc01ddb281e8f672a39e3d1729979ca91e39439cd3eac0402d
389389
sqlite3 (2.9.5-arm-linux-gnu) sha256=1bdfca0c7d63998c60b0f4a8e3c8df2d33800ccc4abd2d612eddbbbc92a4c48b

docs/roadmap.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@
1818
status, which works on all three adapters; a future version may add narrow
1919
ready/claimed membership tables for very large outboxes, as messages already
2020
have
21-
- One-shot and recurring reminders with `:latest` or `:all` catch-up
21+
- One-shot and recurring reminders with `:latest` or `:all` catch-up, exercised
22+
through a real `solid_objects start` worker as well as in process. They were
23+
listed here while broken in that worker: the scheduler reached a constant the
24+
caller path happened to load, so reminders never fired in production and
25+
every in-process test still passed
2226
- Durable observable invalidations, scalar Turbo replacement, keyed ERB
2327
components, signed component locals, and authorized replace or morph refresh
2428
- Batched component refreshes: components sharing a signed `batch:` collapse to

lib/solid_objects.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,11 @@
5555
require "solid_objects/commit_action_registry"
5656
require "solid_objects/lease"
5757
require "solid_objects/lease_renewer"
58+
# The reminder scheduler and the effect executor enqueue through the mailbox,
59+
# and both run in the standalone worker where nothing else has loaded it. It
60+
# was reachable only through the caller path, so requiring the gem was not
61+
# enough to run a role that uses it.
62+
require "solid_objects/mailbox"
5863
require "solid_objects/worker"
5964
require "solid_objects/effect_executor"
6065
require "solid_objects/reminder_scheduler"

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.10.1"
4+
VERSION = "0.10.2"
55
end

test/dummy/prepare_cli_reminder.rb

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# frozen_string_literal: true
2+
3+
ENV["RAILS_ENV"] = "test"
4+
5+
require_relative "config/environment"
6+
require_relative "../../db/migrate/20260805000000_create_solid_objects_tables"
7+
require_relative "../../db/migrate/20260806000000_add_state_revision_to_solid_objects_instances"
8+
9+
CreateSolidObjectsTables.new.migrate(:up)
10+
AddStateRevisionToSolidObjectsInstances.new.migrate(:up)
11+
12+
# A reminder that is already due, so the scheduler claims and enqueues it on
13+
# its first pass rather than waiting.
14+
instance = SolidObjects::Instance.create!(
15+
actor_type: "CliWorkerActor",
16+
actor_id: "reminder-in-worker",
17+
state: {},
18+
state_version: 1
19+
)
20+
reminder = SolidObjects::Reminder.create!(
21+
instance:,
22+
actor_type: instance.actor_type,
23+
actor_id: instance.actor_id,
24+
name: "deliver_push",
25+
message_name: "complete",
26+
arguments: {},
27+
next_run_at: 1.minute.ago,
28+
status: "scheduled"
29+
)
30+
31+
puts reminder.id

test/integration/cli_test.rb

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,39 @@ class CLITest < ActiveSupport::TestCase
4444
assert_raises(SolidObjects::Unauthorized) { command.prune_messages }
4545
end
4646

47+
# A role that only the standalone worker runs can depend on a constant that
48+
# the caller path happens to load first. In this process everything is
49+
# already loaded, so the failure is only reachable from a real worker.
50+
test "a due reminder fires in the standalone worker" do
51+
Dir.mktmpdir("solid-objects-reminder") do |directory|
52+
completed = File.join(directory, "completed")
53+
environment = worker_environment(directory, completed)
54+
dummy_root = File.expand_path("../dummy", __dir__)
55+
prepare(environment, dummy_root, "prepare_cli_reminder.rb")
56+
57+
_input, output, error_output, wait_thread = Open3.popen3(
58+
environment,
59+
"bundle", "exec", "solid_objects", "start",
60+
"--workers", "1",
61+
"--reminder-schedulers", "1",
62+
"--effect-workers", "0",
63+
"--broadcast-workers", "0",
64+
chdir: dummy_root
65+
)
66+
fired = wait_for_file(completed, timeout: 20)
67+
# The pipes only reach EOF once the process is gone, so reading them for
68+
# the failure message has to wait until after it is stopped.
69+
Process.kill("TERM", wait_thread.pid) if wait_thread.alive?
70+
wait_thread.join
71+
diagnosis = [ output.read, error_output.read ].join("\n")
72+
73+
assert fired, "the scheduler should have enqueued the due reminder:\n#{diagnosis}"
74+
ensure
75+
Process.kill("TERM", wait_thread.pid) if wait_thread&.alive?
76+
wait_thread&.join
77+
end
78+
end
79+
4780
# Deny-by-default means an unconfigured host hits this on its first CLI
4881
# command, so it is the most likely thing a new adopter ever sees.
4982
test "a denied command reports the policy rather than a backtrace" do
@@ -121,4 +154,35 @@ class CLITest < ActiveSupport::TestCase
121154
wait_thread&.join
122155
end
123156
end
157+
158+
private
159+
160+
def worker_environment(directory, probe)
161+
{
162+
"BUNDLE_GEMFILE" => File.expand_path("../../Gemfile", __dir__),
163+
"RAILS_ENV" => "test",
164+
"SOLID_OBJECTS_CLI_WORKER_PROBE" => probe,
165+
"SOLID_OBJECTS_DUMMY_DATABASE" => File.join(directory, "dummy.sqlite3")
166+
}
167+
end
168+
169+
def prepare(environment, dummy_root, script)
170+
_output, error_output, status = Open3.capture3(
171+
environment,
172+
Gem.ruby,
173+
File.join(dummy_root, script),
174+
chdir: dummy_root
175+
)
176+
assert status.success?, error_output
177+
end
178+
179+
def wait_for_file(path, timeout:)
180+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
181+
until Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
182+
return true if File.exist?(path)
183+
184+
sleep 0.1
185+
end
186+
false
187+
end
124188
end
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# frozen_string_literal: true
2+
3+
require "test_helper"
4+
require "open3"
5+
6+
# Runtime roles run in `solid_objects start`, a process that requires the gem
7+
# and nothing else. A constant reached only through the caller path resolves
8+
# fine in this test process, where everything is already loaded, and raises
9+
# NameError in that worker. Asking a fresh process what `require
10+
# "solid_objects"` actually defines is the only way to see the difference.
11+
class LoadContractTest < ActiveSupport::TestCase
12+
# Each of these is deliberately not loaded by requiring the gem. Anything
13+
# else that stops being loaded is a role waiting to fail in production, so
14+
# this list is the place to argue that a role never reaches it.
15+
DEFERRED = {
16+
"application_actor_loader" => "used only by the CLI",
17+
"caller_process" => "the caller path, required by SolidObjects.caller_process",
18+
"cli" => "loaded by exe/solid_objects, and pulls in thor",
19+
"client" => "the caller path, required by SolidObjects.client",
20+
"doctor" => "an operator tool, loaded by the doctor command",
21+
"errors" => "defines error classes individually, so no SolidObjects::Errors exists",
22+
"sync_diagnostics" => "the caller path, required with the client",
23+
"synchronous_invocation" => "the caller path, required with the client",
24+
"test_helper" => "opt-in, required by host application tests"
25+
}.freeze
26+
27+
test "requiring the gem defines everything a runtime role reaches for" do
28+
assert_equal DEFERRED.keys.sort, undefined_after_require,
29+
"a file that stopped being loaded is only safe if no runtime role reaches it"
30+
end
31+
32+
# The reported failure: two roles enqueue through the mailbox, and requiring
33+
# the gem did not define it.
34+
test "the mailbox is defined by requiring the gem" do
35+
refute_includes undefined_after_require, "mailbox",
36+
"the reminder scheduler and effect executor enqueue through it"
37+
end
38+
39+
private
40+
41+
# Computed inside the fresh process, where both the file list and what the
42+
# require actually defined are available.
43+
def undefined_after_require
44+
@undefined_after_require ||= begin
45+
output, error_output, status = Open3.capture3(
46+
{ "BUNDLE_GEMFILE" => gem_root("Gemfile") },
47+
Gem.ruby,
48+
"-e",
49+
probe,
50+
chdir: gem_root(".")
51+
)
52+
assert status.success?, error_output
53+
output.split("\n").sort
54+
end
55+
end
56+
57+
def probe
58+
<<~RUBY
59+
require "solid_objects"
60+
61+
def classify(path)
62+
path.split("/").map { |part| part.split("_").map(&:capitalize).join }.join("::")
63+
end
64+
65+
undefined = Dir.glob("**/*.rb", base: "lib/solid_objects").filter_map do |path|
66+
file = path.delete_suffix(".rb")
67+
next if file == "version"
68+
69+
begin
70+
Object.const_get("SolidObjects::" + classify(file))
71+
nil
72+
rescue NameError
73+
file
74+
end
75+
end
76+
puts undefined
77+
RUBY
78+
end
79+
80+
def gem_root(path)
81+
File.expand_path("../../#{path}", __dir__)
82+
end
83+
end

0 commit comments

Comments
 (0)