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
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ Every owned Ruby file must enable inline RBS with `# rbs_inline: enabled`; annot

Write Minitest files as `test/**/*_test.rb`. Start behavioral changes with a focused failing test. Exercise locking, leases, fencing, and claiming against real database adapters. Synchronize races with queues, barriers, or condition variables instead of arbitrary sleeps. Host-app actor tests should include `SolidObjects::TestHelper` rather than rely on transactional tests.

Watch the test fail before making it pass, and quote the observed failure in the pull request. A test that has never failed has not been shown to test anything, and the ways it can pass while proving nothing are not exotic: a regression test written after the fix, a double that lacks the `ensure` its real collaborator runs, a mock that ignores the signal under test, or a second guard that covers for the one being removed. When a change fixes a defect, revert the fix and confirm the test fails for the expected reason rather than some other one.

Run code in the process and environment it actually runs in. A runtime role gets only what `require "solid_objects"` defines, so verify it through `solid_objects start` rather than in a test process that has already loaded the constant it needs; `test/integration/load_contract_test.rb` enforces that boundary and is the place to record a deliberate exception. Browser modules go through a real browser as well as jsdom. Adapter behavior goes through the real database and every supported client, and because a skipped test looks exactly like a passing one in the summary line, check the skip count when a change touches an adapter.

## Commit & Pull Request Guidelines

Use concise imperative subjects, preferably under 50 characters; use prefixes such as `fix:`, `ci:`, or `chore:`. Pull requests should explain API, correctness, security, migration, and compatibility effects; list exact validation commands; link issues; and include screenshots for UI or reactive ERB changes. Never bypass hooks or add AI attribution.
Expand Down
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
# Changelog

## 0.10.2 - 2026-08-10

- Load the mailbox when the gem is required. `SolidObjects::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: uninitialized constant SolidObjects::ReminderScheduler::Mailbox`.
Reminders never fired and effect result messages never delivered, while the
supervisor replaced the dying role over and over. Nothing caught it because
every test process has already loaded the constant through some other path.
- Add a load contract test that asks a fresh process what `require
"solid_objects"` actually defines, and fails when a file stops being loaded
unless it is listed as deliberately deferred with a reason. This is the class
of bug that only appears in the standalone worker.
- Run a due reminder through a real `solid_objects start` worker in the test
suite, rather than only in process.

## 0.10.1 - 2026-08-10

- Support Trilogy. Adapter selection matched the client name rather than 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.10.1)
solid_objects (0.10.2)
actioncable (>= 8.0)
actionpack (>= 8.0)
actionview (>= 8.0)
Expand Down Expand Up @@ -383,7 +383,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.10.1)
solid_objects (0.10.2)
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
6 changes: 5 additions & 1 deletion docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@
status, which works on all three adapters; a future version may add narrow
ready/claimed membership tables for very large outboxes, as messages already
have
- One-shot and recurring reminders with `:latest` or `:all` catch-up
- One-shot and recurring reminders with `:latest` or `:all` catch-up, exercised
through a real `solid_objects start` worker as well as in process. They were
listed here while broken in that worker: the scheduler reached a constant the
caller path happened to load, so reminders never fired in production and
every in-process test still passed
- Durable observable invalidations, scalar Turbo replacement, keyed ERB
components, signed component locals, and authorized replace or morph refresh
- Batched component refreshes: components sharing a signed `batch:` collapse to
Expand Down
5 changes: 5 additions & 0 deletions lib/solid_objects.rb
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@
require "solid_objects/commit_action_registry"
require "solid_objects/lease"
require "solid_objects/lease_renewer"
# The reminder scheduler and the effect executor enqueue through the mailbox,
# and both run in the standalone worker where nothing else has loaded it. It
# was reachable only through the caller path, so requiring the gem was not
# enough to run a role that uses it.
require "solid_objects/mailbox"
require "solid_objects/worker"
require "solid_objects/effect_executor"
require "solid_objects/reminder_scheduler"
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.10.1"
VERSION = "0.10.2"
end
31 changes: 31 additions & 0 deletions test/dummy/prepare_cli_reminder.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# frozen_string_literal: true

ENV["RAILS_ENV"] = "test"

require_relative "config/environment"
require_relative "../../db/migrate/20260805000000_create_solid_objects_tables"
require_relative "../../db/migrate/20260806000000_add_state_revision_to_solid_objects_instances"

CreateSolidObjectsTables.new.migrate(:up)
AddStateRevisionToSolidObjectsInstances.new.migrate(:up)

# A reminder that is already due, so the scheduler claims and enqueues it on
# its first pass rather than waiting.
instance = SolidObjects::Instance.create!(
actor_type: "CliWorkerActor",
actor_id: "reminder-in-worker",
state: {},
state_version: 1
)
reminder = SolidObjects::Reminder.create!(
instance:,
actor_type: instance.actor_type,
actor_id: instance.actor_id,
name: "deliver_push",
message_name: "complete",
arguments: {},
next_run_at: 1.minute.ago,
status: "scheduled"
)

puts reminder.id
64 changes: 64 additions & 0 deletions test/integration/cli_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,39 @@ class CLITest < ActiveSupport::TestCase
assert_raises(SolidObjects::Unauthorized) { command.prune_messages }
end

# A role that only the standalone worker runs can depend on a constant that
# the caller path happens to load first. In this process everything is
# already loaded, so the failure is only reachable from a real worker.
test "a due reminder fires in the standalone worker" do
Dir.mktmpdir("solid-objects-reminder") do |directory|
completed = File.join(directory, "completed")
environment = worker_environment(directory, completed)
dummy_root = File.expand_path("../dummy", __dir__)
prepare(environment, dummy_root, "prepare_cli_reminder.rb")

_input, output, error_output, wait_thread = Open3.popen3(
environment,
"bundle", "exec", "solid_objects", "start",
"--workers", "1",
"--reminder-schedulers", "1",
"--effect-workers", "0",
"--broadcast-workers", "0",
chdir: dummy_root
)
fired = wait_for_file(completed, timeout: 20)
# The pipes only reach EOF once the process is gone, so reading them for
# the failure message has to wait until after it is stopped.
Process.kill("TERM", wait_thread.pid) if wait_thread.alive?
wait_thread.join
diagnosis = [ output.read, error_output.read ].join("\n")

assert fired, "the scheduler should have enqueued the due reminder:\n#{diagnosis}"
ensure
Process.kill("TERM", wait_thread.pid) if wait_thread&.alive?
wait_thread&.join
end
end

# Deny-by-default means an unconfigured host hits this on its first CLI
# command, so it is the most likely thing a new adopter ever sees.
test "a denied command reports the policy rather than a backtrace" do
Expand Down Expand Up @@ -121,4 +154,35 @@ class CLITest < ActiveSupport::TestCase
wait_thread&.join
end
end

private

def worker_environment(directory, probe)
{
"BUNDLE_GEMFILE" => File.expand_path("../../Gemfile", __dir__),
"RAILS_ENV" => "test",
"SOLID_OBJECTS_CLI_WORKER_PROBE" => probe,
"SOLID_OBJECTS_DUMMY_DATABASE" => File.join(directory, "dummy.sqlite3")
}
end

def prepare(environment, dummy_root, script)
_output, error_output, status = Open3.capture3(
environment,
Gem.ruby,
File.join(dummy_root, script),
chdir: dummy_root
)
assert status.success?, error_output
end

def wait_for_file(path, timeout:)
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
until Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
return true if File.exist?(path)

sleep 0.1
end
false
end
end
83 changes: 83 additions & 0 deletions test/integration/load_contract_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# frozen_string_literal: true

require "test_helper"
require "open3"

# Runtime roles run in `solid_objects start`, a process that requires the gem
# and nothing else. A constant reached only through the caller path resolves
# fine in this test process, where everything is already loaded, and raises
# NameError in that worker. Asking a fresh process what `require
# "solid_objects"` actually defines is the only way to see the difference.
class LoadContractTest < ActiveSupport::TestCase
# Each of these is deliberately not loaded by requiring the gem. Anything
# else that stops being loaded is a role waiting to fail in production, so
# this list is the place to argue that a role never reaches it.
DEFERRED = {
"application_actor_loader" => "used only by the CLI",
"caller_process" => "the caller path, required by SolidObjects.caller_process",
"cli" => "loaded by exe/solid_objects, and pulls in thor",
"client" => "the caller path, required by SolidObjects.client",
"doctor" => "an operator tool, loaded by the doctor command",
"errors" => "defines error classes individually, so no SolidObjects::Errors exists",
"sync_diagnostics" => "the caller path, required with the client",
"synchronous_invocation" => "the caller path, required with the client",
"test_helper" => "opt-in, required by host application tests"
}.freeze

test "requiring the gem defines everything a runtime role reaches for" do
assert_equal DEFERRED.keys.sort, undefined_after_require,
"a file that stopped being loaded is only safe if no runtime role reaches it"
end

# The reported failure: two roles enqueue through the mailbox, and requiring
# the gem did not define it.
test "the mailbox is defined by requiring the gem" do
refute_includes undefined_after_require, "mailbox",
"the reminder scheduler and effect executor enqueue through it"
end

private

# Computed inside the fresh process, where both the file list and what the
# require actually defined are available.
def undefined_after_require
@undefined_after_require ||= begin
output, error_output, status = Open3.capture3(
{ "BUNDLE_GEMFILE" => gem_root("Gemfile") },
Gem.ruby,
"-e",
probe,
chdir: gem_root(".")
)
assert status.success?, error_output
output.split("\n").sort
end
end

def probe
<<~RUBY
require "solid_objects"

def classify(path)
path.split("/").map { |part| part.split("_").map(&:capitalize).join }.join("::")
end

undefined = Dir.glob("**/*.rb", base: "lib/solid_objects").filter_map do |path|
file = path.delete_suffix(".rb")
next if file == "version"

begin
Object.const_get("SolidObjects::" + classify(file))
nil
rescue NameError
file
end
end
puts undefined
RUBY
end

def gem_root(path)
File.expand_path("../../#{path}", __dir__)
end
end
Loading