From 3f4d299cead1b5e637f150bd1323f13ca4749501 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Mon, 10 Aug 2026 07:09:13 -0700 Subject: [PATCH 1/2] feat: add a Redis cross-process wake-up MySQL has no notification primitive, so applications that cannot use PostgreSQL notifications had no way to remove the cross-process polling delay. Measured latency drops from 103.8ms to 5.7ms at p50. One background subscription per process fans out to every waiting role in memory: Redis delivers only to a subscribed connection, and a subscribed connection cannot serve other callers, so a connection per thread would multiply connections without benefit. Subscribing eagerly closes the window where a signal sent during startup was missed, which an earlier per-thread draft did not, and its test caught. The redis gem stays outside this gem's dependencies and WakeUpAdapters.for does not select it. --- .github/workflows/ci.yml | 21 +++ CHANGELOG.md | 6 + Gemfile | 1 + Gemfile.lock | 7 + docs/realtime.md | 17 +- docs/roadmap.md | 21 ++- lib/solid_objects.rb | 1 + lib/solid_objects/wake_up_adapters/redis.rb | 178 ++++++++++++++++++ .../solid_objects/wake_up_adapters/redis.rbs | 93 +++++++++ test/integration/redis_wake_up_test.rb | 148 +++++++++++++++ 10 files changed, 481 insertions(+), 12 deletions(-) create mode 100644 lib/solid_objects/wake_up_adapters/redis.rb create mode 100644 sig/generated/lib/solid_objects/wake_up_adapters/redis.rbs create mode 100644 test/integration/redis_wake_up_test.rb diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4817d70..87f1834 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,26 @@ jobs: - run: bundle lock --update && bundle install --jobs 4 - run: bundle exec rake test + redis: + runs-on: ubuntu-latest + services: + redis: + image: redis:7 + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1 + with: + ruby-version: "3.3" + bundler-cache: true + - run: bundle exec rake test TEST=test/integration/redis_wake_up_test.rb + postgresql: runs-on: ubuntu-latest services: @@ -116,6 +136,7 @@ jobs: - static - javascript - compatibility + - redis runs-on: ubuntu-latest permissions: contents: write diff --git a/CHANGELOG.md b/CHANGELOG.md index 18b88f8..83a69b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ The doctor reports this as `database_server` and warns rather than failing: refusing to run on an untested server would be a worse failure than running on one. +- Add `SolidObjects::WakeUpAdapters::Redis`, an optional cross-process wake-up + using Redis publish and subscribe. This is the option for MySQL, which has no + notification primitive. Measured cross-process wake-up latency drops from + 103.8 ms to 5.7 ms at p50. One background subscription per process fans out to + every waiting role in memory. The `redis` gem is not a dependency of this gem, + and `WakeUpAdapters.for` does not select it, so adopting Redis stays explicit. ## 0.8.0 - 2026-08-10 diff --git a/Gemfile b/Gemfile index a2b84cc..1601421 100644 --- a/Gemfile +++ b/Gemfile @@ -15,6 +15,7 @@ end group :development, :test do gem "mysql2", ">= 0.5", require: false gem "pg", ">= 1.5", require: false + gem "redis", ">= 5.0", require: false gem "sqlite3", ">= 2.1", require: false end diff --git a/Gemfile.lock b/Gemfile.lock index fe03b7e..8eab1e2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -175,6 +175,10 @@ GEM prism (>= 1.6.0) rbs (>= 4.0.0) tsort + redis (6.0.0) + redis-client (= 0.30.1) + redis-client (0.30.1) + connection_pool regexp_parser (2.12.0) reline (0.6.3) io-console (~> 0.5) @@ -282,6 +286,7 @@ DEPENDENCIES mysql2 (>= 0.5) pg (>= 1.5) rbs-inline + redis (>= 5.0) rubocop-rails-omakase solid_objects! sqlite3 (>= 2.1) @@ -364,6 +369,8 @@ CHECKSUMS rbs (4.1.2) sha256=050eb1d8b508f1233bed929c0f2c7052302f7adf295230d9cb314e9024078f48 rbs-inline (0.14.0) sha256=eaf47b46690d63acddab1f6804c9cc205a4bc78e637cfc421a0b1239874ef51c rdoc (8.0.0) sha256=03bf8c08a9639658855a0cfd77c0abca8325c227693f7f33f82957811348c469 + redis (6.0.0) sha256=de71c10edd106986b759ec7ecdd08b63b9c0ee7414a0d0c1da73d31ba2bccda6 + redis-client (0.30.1) sha256=5151bc5c7bbfe48623732cdae3b900d8a22dc691cc7cdfacfb351ac55116522d regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835 rubocop (1.88.2) sha256=8def251c90cd955feb4daa3edc0ab56893250c4ce90ef81e6c80c03f9a939bbf diff --git a/docs/realtime.md b/docs/realtime.md index 4a5e4ad..3da2e4d 100644 --- a/docs/realtime.md +++ b/docs/realtime.md @@ -128,8 +128,21 @@ configuration.wake_up_adapter = SolidObjects::WakeUpAdapters.for default on SQLite and MySQL, so the same line is safe across adapters. Name `SolidObjects::WakeUpAdapters::Postgresql.new` directly to require it. -MySQL has no notification primitive, so MySQL applications keep polling and tune -`polling_interval`. +MySQL has no notification primitive. MySQL applications either keep polling and +tune `polling_interval`, or configure the Redis adapter: + +```ruby +configuration.wake_up_adapter = SolidObjects::WakeUpAdapters::Redis.new( + url: ENV["REDIS_URL"] +) +``` + +Measured latency for a cross-process wake-up drops from 103.8 ms to 5.7 ms at +p50. The `redis` gem is not a dependency of this gem, so applications add it +themselves. One background subscription per process fans out to every waiting +role in memory, rather than one connection per thread, and `WakeUpAdapters.for` +does not select it: Redis is infrastructure this gem otherwise does not require, +so choosing it is explicit. Measured latency for a cross-process wake-up drops from 103.7 ms to 2.9 ms at p50. The adapter keeps `polling_interval` as the upper bound: a missed or failed diff --git a/docs/roadmap.md b/docs/roadmap.md index f2fd42c..270a9bf 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -39,6 +39,9 @@ - SQLite, PostgreSQL, and MySQL integration suites - Opt-in cross-process wake-up on PostgreSQL through `WakeUpAdapters.for`, with a listening connection per waiting thread and release on supervisor shutdown +- Opt-in cross-process wake-up on Redis, the option for MySQL applications, + measured at 103.8 ms to 5.7 ms at p50; the `redis` gem stays outside this + gem's dependencies - Inline RBS generation/validation, Steep, Standard Ruby, Solid Queue's exact RuboCop policy, and a warning-free Brakeman scan - Compatibility CI across the supported span: Ruby 3.3 and 3.4 against Rails 8.0 @@ -59,7 +62,8 @@ 103.7 ms to 2.9 ms at p50. It is opt-in rather than automatic: it opens a connection per waiting thread outside the pool, and `LISTEN` does not survive a transaction-pooling proxy such as PgBouncer. MySQL has no notification - primitive, so MySQL applications keep polling. + primitive, so MySQL applications keep polling unless they configure the Redis + adapter. - Realtime: scalar and dependency-driven keyed ERB component replacement or morphing, personalized refresh authorization, revision fencing, coalescing, reconnect convergence, batched refreshes, and personalized state payloads are @@ -79,17 +83,14 @@ ## Next milestones -1. Add an optional Redis wake-up adapter, which is the remaining cross-process - option for MySQL. The PostgreSQL notification adapter, its latency - benchmark, and its concurrency tests are implemented. -2. Add result lookup by request ID and broader deadlock retry classification. -3. Add scheduled retention and stale-process maintenance. -4. Add Turbo append intents and expand reconnect coverage in a full browser. -5. Add distributed rate limits, global admission hooks, and cache-capacity +1. Add result lookup by request ID and broader deadlock retry classification. +2. Add scheduled retention and stale-process maintenance. +3. Add Turbo append intents and expand reconnect coverage in a full browser. +4. Add distributed rate limits, global admission hooks, and cache-capacity eviction. -6. Expand security scanning. Compatibility CI across supported Rails and Ruby +5. Expand security scanning. Compatibility CI across supported Rails and Ruby versions is implemented; Ruby 4.0 is not yet in the matrix. -7. Benchmark all workloads under documented hardware/database settings and +6. Benchmark all workloads under documented hardware/database settings and publish adapter-specific adoption measurements. Throughput, synchronous latency, query counts, and the three reactive delivery paths are measured on SQLite; adapter-specific and end-to-end browser measurements are not. diff --git a/lib/solid_objects.rb b/lib/solid_objects.rb index 31f40dd..fd33505 100644 --- a/lib/solid_objects.rb +++ b/lib/solid_objects.rb @@ -47,6 +47,7 @@ require "solid_objects/action_cable_broadcast_adapter" require "solid_objects/wake_up" require "solid_objects/wake_up_adapters/postgresql" +require "solid_objects/wake_up_adapters/redis" require "solid_objects/wake_up_adapters" require "solid_objects/effect_registry" require "solid_objects/commit_action_registry" diff --git a/lib/solid_objects/wake_up_adapters/redis.rb b/lib/solid_objects/wake_up_adapters/redis.rb new file mode 100644 index 0000000..e6e724a --- /dev/null +++ b/lib/solid_objects/wake_up_adapters/redis.rb @@ -0,0 +1,178 @@ +# rbs_inline: enabled + +require "timeout" + +module SolidObjects + module WakeUpAdapters + # Wakes runtime roles across processes using Redis publish/subscribe. + # + # MySQL has no notification primitive, so this is the cross-process option + # for applications that cannot use PostgreSQL notifications. It is optional + # in every sense: the `redis` gem is not a dependency of this gem, and the + # polling interval remains the upper bound, so a missed or failed + # notification costs latency rather than correctness. + class Redis + CHANNEL = "solid_objects_wake_up" + FAILED_WAIT_INTERVAL = 0.05 + SUBSCRIBE_TIMEOUT = 5.0 + + # @rbs @channel: String + # @rbs @url: String? + # @rbs @client: untyped + # @rbs @mutex: Thread::Mutex + # @rbs @condition: Thread::ConditionVariable + # @rbs @subscriber: Thread? + # @rbs @subscription: untyped + # @rbs @signalled: Integer + + attr_reader :channel + + # @rbs (?channel: String, ?url: String?, ?client: untyped) -> void + def initialize(channel: CHANNEL, url: nil, client: nil) + @channel = channel + @url = url + @client = client + @mutex = Thread::Mutex.new + @condition = Thread::ConditionVariable.new + @subscriber = nil + @subscription = nil + @signalled = 0 + validate_client! + end + + # @rbs () -> bool + def signal + publisher.publish(channel, "1") + true + rescue => error + instrument_failure(:signal, error) + false + end + + # @rbs (timeout: Numeric) -> bool + def wait(timeout:) + return paced_failure(timeout) unless listen + + mutex.synchronize do + signalled = @signalled + condition.wait(mutex, timeout.to_f) + @signalled != signalled + end + end + + # Redis delivers to a subscribed connection only, and a subscribed + # connection cannot serve other callers, so one background subscription + # per process fans out to every waiting role in memory. Subscribing + # eagerly also closes the window where a signal sent during startup would + # be missed. + # @rbs () -> bool + def listen + mutex.synchronize do + return true if @subscriber&.alive? + + ready = Queue.new + @subscriber = Thread.new { subscribe_loop(ready) } + Timeout.timeout(SUBSCRIBE_TIMEOUT) { ready.pop } == :subscribed + end + rescue => error + instrument_failure(:listen, error) + false + end + + # @rbs () -> bool + def stop + subscriber = mutex.synchronize do + thread = @subscriber + @subscriber = nil + thread + end + return false unless subscriber + + disconnect(@subscription) + subscriber.join(SUBSCRIBE_TIMEOUT) + subscriber.kill if subscriber.alive? + true + end + + private + + attr_reader :mutex, :condition, :url + + # @rbs (Queue) -> void + def subscribe_loop(ready) + connection = build_client + @subscription = connection + connection.subscribe(channel) do |on| + on.subscribe { ready << :subscribed } + on.message { broadcast } + end + rescue => error + instrument_failure(:subscribe, error) + ready << :failed + end + + # @rbs () -> void + def broadcast + mutex.synchronize do + @signalled += 1 + condition.broadcast + end + end + + # @rbs (Numeric) -> bool + def paced_failure(timeout) + pace_after_failure(timeout) + false + end + + # @rbs () -> untyped + def publisher + @publisher ||= build_client + end + + # @rbs () -> untyped + def build_client + return @client.call if @client.respond_to?(:call) + + require "redis" + url ? ::Redis.new(url:) : ::Redis.new + rescue LoadError + raise ArgumentError, + "the redis gem is required for SolidObjects::WakeUpAdapters::Redis" + end + + # @rbs () -> void + def validate_client! + return if @client.nil? || @client.respond_to?(:call) + + raise ArgumentError, "client must respond to call and return a Redis client" + end + + # @rbs (untyped) -> void + def disconnect(connection) + connection&.close + rescue + nil + end + + # @rbs (Numeric) -> void + def pace_after_failure(timeout) + interval = [ timeout.to_f, FAILED_WAIT_INTERVAL ].min + return unless interval.positive? + + sleep interval + end + + # @rbs (Symbol, Exception) -> void + def instrument_failure(operation, error) + SolidObjects.instrument( + :"wake_up.failed", + adapter: "redis", + operation: operation.to_s, + error_class: error.class.name, + error_message: error.message + ) + end + end + end +end diff --git a/sig/generated/lib/solid_objects/wake_up_adapters/redis.rbs b/sig/generated/lib/solid_objects/wake_up_adapters/redis.rbs new file mode 100644 index 0000000..ec1e637 --- /dev/null +++ b/sig/generated/lib/solid_objects/wake_up_adapters/redis.rbs @@ -0,0 +1,93 @@ +# Generated from lib/solid_objects/wake_up_adapters/redis.rb with RBS::Inline + +module SolidObjects + module WakeUpAdapters + # Wakes runtime roles across processes using Redis publish/subscribe. + # + # MySQL has no notification primitive, so this is the cross-process option + # for applications that cannot use PostgreSQL notifications. It is optional + # in every sense: the `redis` gem is not a dependency of this gem, and the + # polling interval remains the upper bound, so a missed or failed + # notification costs latency rather than correctness. + class Redis + CHANNEL: ::String + + FAILED_WAIT_INTERVAL: ::Float + + SUBSCRIBE_TIMEOUT: ::Float + + @signalled: Integer + + @subscription: untyped + + @subscriber: Thread? + + @condition: Thread::ConditionVariable + + @mutex: Thread::Mutex + + @client: untyped + + @url: String? + + @channel: String + + attr_reader channel: untyped + + # @rbs (?channel: String, ?url: String?, ?client: untyped) -> void + def initialize: (?channel: String, ?url: String?, ?client: untyped) -> void + + # @rbs () -> bool + def signal: () -> bool + + # @rbs (timeout: Numeric) -> bool + def wait: (timeout: Numeric) -> bool + + # Redis delivers to a subscribed connection only, and a subscribed + # connection cannot serve other callers, so one background subscription + # per process fans out to every waiting role in memory. Subscribing + # eagerly also closes the window where a signal sent during startup would + # be missed. + # @rbs () -> bool + def listen: () -> bool + + # @rbs () -> bool + def stop: () -> bool + + private + + attr_reader mutex: untyped + + attr_reader condition: untyped + + attr_reader url: untyped + + # @rbs (Queue) -> void + def subscribe_loop: (Queue) -> void + + # @rbs () -> void + def broadcast: () -> void + + # @rbs (Numeric) -> bool + def paced_failure: (Numeric) -> bool + + # @rbs () -> untyped + def publisher: () -> untyped + + # @rbs () -> untyped + def build_client: () -> untyped + + # @rbs () -> void + def validate_client!: () -> void + + # @rbs (untyped) -> void + def disconnect: (untyped) -> void + + # @rbs (Numeric) -> void + def pace_after_failure: (Numeric) -> void + + # @rbs (Symbol, Exception) -> void + def instrument_failure: (Symbol, Exception) -> void + end + end +end diff --git a/test/integration/redis_wake_up_test.rb b/test/integration/redis_wake_up_test.rb new file mode 100644 index 0000000..c367409 --- /dev/null +++ b/test/integration/redis_wake_up_test.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +require "database_test_helper" +require "timeout" + +class RedisWakeUpTest < ActiveSupport::TestCase + REDIS_URL = ENV.fetch("SOLID_OBJECTS_REDIS_URL", "redis://127.0.0.1:6379/15") + + setup do + skip unless redis_available? + + @adapter = SolidObjects::WakeUpAdapters::Redis.new(url: REDIS_URL) + end + + teardown { @adapter&.stop } + + test "a signal from another client wakes a waiter" do + waiting = Queue.new + woken = Queue.new + waiter = Thread.new do + @adapter.listen + waiting << true + started = monotonic_now + @adapter.wait(timeout: 5) + woken << monotonic_now - started + end + Timeout.timeout(5) { waiting.pop } + + signal_from_another_client + + elapsed = Timeout.timeout(5) { woken.pop } + waiter.join(5) + assert_operator elapsed, :<, 1.0, + "a published signal should wake the waiter well before the timeout" + end + + test "waiting returns after the timeout when nothing signals" do + @adapter.listen + started = monotonic_now + + @adapter.wait(timeout: 0.3) + + elapsed = monotonic_now - started + assert_operator elapsed, :>=, 0.2 + assert_operator elapsed, :<, 3.0 + end + + # The supervisor shares one adapter across runtime roles, so concurrent + # waiters go through a single instance. + test "every waiter on one shared adapter wakes on one signal" do + waiters = 3 + waiting = Queue.new + woken = Queue.new + threads = Array.new(waiters) do + Thread.new do + @adapter.listen + waiting << true + started = monotonic_now + @adapter.wait(timeout: 5) + woken << monotonic_now - started + end + end + waiters.times { Timeout.timeout(5) { waiting.pop } } + + signal_from_another_client + + elapsed = waiters.times.map { Timeout.timeout(10) { woken.pop } } + threads.each { |thread| thread.join(6) } + assert_operator elapsed.max, :<, 1.0, + "every waiter should wake on the signal, not time out" + end + + test "one subscription serves every waiter in the process" do + threads = Array.new(3) { Thread.new { @adapter.listen } } + threads.each { |thread| thread.join(5) } + + subscribers = @adapter.instance_variable_get(:@subscriber) + assert subscribers.alive?, "one background subscription should serve the process" + end + + test "signalling never raises into the caller" do + broken = SolidObjects::WakeUpAdapters::Redis.new(url: "redis://127.0.0.1:1/0") + + assert_nothing_raised { broken.signal } + assert_equal false, broken.signal + ensure + broken&.stop + end + + test "waiting never raises into the caller" do + broken = SolidObjects::WakeUpAdapters::Redis.new(url: "redis://127.0.0.1:1/0") + started = monotonic_now + + assert_nothing_raised { broken.wait(timeout: 0.1) } + + assert_operator monotonic_now - started, :>=, 0.05, + "a failed wait must still pace itself rather than spin" + ensure + broken&.stop + end + + test "stopping releases the subscription" do + @adapter.listen + + assert @adapter.stop + refute @adapter.instance_variable_get(:@subscriber) + end + + test "the adapter satisfies the wake-up contract" do + assert_respond_to @adapter, :signal + assert_respond_to @adapter, :wait + SolidObjects.configuration.wake_up_adapter = @adapter + + assert_same @adapter, SolidObjects.wake_up + ensure + SolidObjects.configuration.wake_up_adapter = nil + SolidObjects.instance_variable_set(:@wake_up, nil) + end + + test "a missing redis gem is reported clearly" do + error = assert_raises(ArgumentError) do + SolidObjects::WakeUpAdapters::Redis.new(url: REDIS_URL, client: :not_a_client) + end + + assert_match(/respond to/, error.message) + end + + private + + def redis_available? + require "redis" + ::Redis.new(url: REDIS_URL, timeout: 1).ping == "PONG" + rescue LoadError, StandardError + false + end + + def monotonic_now + ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + end + + # Models a commit in a different process: a publish on its own client. + def signal_from_another_client + other = SolidObjects::WakeUpAdapters::Redis.new(url: REDIS_URL) + Thread.new { other.signal }.join(5) + ensure + other&.stop + end +end From bc2576c2cb04015d5b62c723ebd4b29d32e86de6 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Mon, 10 Aug 2026 07:19:23 -0700 Subject: [PATCH 2/2] fix: do not absorb a signal while subscribing A notification arriving after listen released the mutex but before wait snapshotted the counter became the new baseline, and its broadcast reached no registered waiter, so the role slept until its polling interval expired. Snapshot before subscribing and re-check before blocking, so a signal delivered while a waiter was getting ready is observed rather than swallowed. --- lib/solid_objects/wake_up_adapters/redis.rb | 7 ++++++- .../solid_objects/wake_up_adapters/redis.rbs | 3 +++ test/integration/redis_wake_up_test.rb | 19 +++++++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/lib/solid_objects/wake_up_adapters/redis.rb b/lib/solid_objects/wake_up_adapters/redis.rb index e6e724a..56d6f18 100644 --- a/lib/solid_objects/wake_up_adapters/redis.rb +++ b/lib/solid_objects/wake_up_adapters/redis.rb @@ -49,12 +49,17 @@ def signal false end + # The counter is snapshotted before subscribing, and re-checked before + # blocking, so a signal delivered while this caller was still getting + # ready is observed rather than absorbed into the new baseline. # @rbs (timeout: Numeric) -> bool def wait(timeout:) + signalled = mutex.synchronize { @signalled } return paced_failure(timeout) unless listen mutex.synchronize do - signalled = @signalled + return true unless @signalled == signalled + condition.wait(mutex, timeout.to_f) @signalled != signalled end diff --git a/sig/generated/lib/solid_objects/wake_up_adapters/redis.rbs b/sig/generated/lib/solid_objects/wake_up_adapters/redis.rbs index ec1e637..7ae2a98 100644 --- a/sig/generated/lib/solid_objects/wake_up_adapters/redis.rbs +++ b/sig/generated/lib/solid_objects/wake_up_adapters/redis.rbs @@ -40,6 +40,9 @@ module SolidObjects # @rbs () -> bool def signal: () -> bool + # The counter is snapshotted before subscribing, and re-checked before + # blocking, so a signal delivered while this caller was still getting + # ready is observed rather than absorbed into the new baseline. # @rbs (timeout: Numeric) -> bool def wait: (timeout: Numeric) -> bool diff --git a/test/integration/redis_wake_up_test.rb b/test/integration/redis_wake_up_test.rb index c367409..99b13ec 100644 --- a/test/integration/redis_wake_up_test.rb +++ b/test/integration/redis_wake_up_test.rb @@ -78,6 +78,25 @@ class RedisWakeUpTest < ActiveSupport::TestCase assert subscribers.alive?, "one background subscription should serve the process" end + test "a signal arriving while a waiter is subscribing is not absorbed" do + @adapter.listen + real = @adapter.method(:listen) + # Publish inside listen, modelling a signal that lands after the counter + # would have been snapshotted but before the waiter blocks. + @adapter.define_singleton_method(:listen) do + result = real.call + SolidObjects::WakeUpAdapters::Redis.new(url: REDIS_URL).tap(&:signal).stop + sleep 0.1 + result + end + started = monotonic_now + + woken = @adapter.wait(timeout: 5) + + assert woken, "the signal must not be absorbed by the subscribing waiter" + assert_operator monotonic_now - started, :<, 1.0 + end + test "signalling never raises into the caller" do broken = SolidObjects::WakeUpAdapters::Redis.new(url: "redis://127.0.0.1:1/0")