Skip to content

Commit 9ab4ed0

Browse files
committed
fix: isolate doctor probe caller process
Register a dedicated caller process for the round-trip probe and pass it to SynchronousInvocation, so an overlapping application call can no longer adopt the probe registry and have its activation ownership and claimed messages released when the probe cleans up. Removes the caller process accessors the previous ownership check needed.
1 parent d3bd7cf commit 9ab4ed0

9 files changed

Lines changed: 85 additions & 60 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22

33
## 0.5.1 - 2026-08-07
44

5-
- Keep the doctor round-trip probe from stopping and deleting a caller process
6-
the application registered, which released its activations and unclaimed its
7-
messages.
5+
- Run the doctor round-trip probe on a dedicated caller process, and accept an
6+
explicit process registry in `SynchronousInvocation`, so the probe can no
7+
longer stop and delete a shared application caller process, release its
8+
activations, and unclaim its messages.
89
- Report doctor probe cleanup failures as a failed or warned check instead of
910
raising a database lock error out of the command and leaking the probe
1011
caller process.

docs/operations.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,13 @@ so the schema check compares the required shape instead of a fixed timestamp.
1515
Warnings such as an all-deny neutral policy do not fail the command because a
1616
context-aware production policy may correctly deny the probe.
1717

18-
The round-trip probe cleans up after itself. It removes its temporary actor,
19-
and removes the caller process record only when the probe registered it, so
20-
running the doctor inside a process that already serves synchronous calls
21-
leaves that caller process and its activations untouched. A database busy
22-
enough to block cleanup reports a failed or warned check rather than raising
23-
out of the command.
18+
The round-trip probe runs on its own dedicated caller process rather than the
19+
shared application caller process, and removes that record together with its
20+
temporary actor when it finishes. Running the doctor inside a process that
21+
already serves synchronous calls therefore leaves the application caller
22+
process, its activations, and its claimed messages untouched, including when an
23+
application call overlaps the probe. A database busy enough to block cleanup
24+
reports a failed or warned check rather than raising out of the command.
2425

2526
## Runtime
2627

lib/solid_objects/caller_process.rb

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,6 @@ def process_registry
2626
end
2727
end
2828

29-
# @rbs () -> String?
30-
def process_record_id
31-
mutex.synchronize do
32-
reset_after_fork
33-
registry&.process_record&.id
34-
end
35-
end
36-
3729
# @rbs () -> bool
3830
def stop
3931
mutex.synchronize do
@@ -44,20 +36,6 @@ def stop
4436
end
4537
end
4638

47-
# @rbs (String) -> bool
48-
def delete_process_record(process_id)
49-
mutex.synchronize do
50-
reset_after_fork
51-
process_record = registry&.process_record
52-
return false unless process_record&.id == process_id
53-
54-
registry.stop
55-
process_record.delete
56-
@registry = nil
57-
true
58-
end
59-
end
60-
6139
private
6240

6341
attr_reader :mutex, :registry

lib/solid_objects/doctor.rb

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -215,9 +215,9 @@ def check_runtime
215215
# @rbs () -> Check
216216
def check_sync_round_trip
217217
actor_id = SecureRandom.uuid
218-
caller_process_id = SolidObjects.caller_process.process_record_id
219-
check = run_sync_probe(actor_id)
220-
leftovers = remove_probe_records(actor_id:, caller_process_id:)
218+
probe_registry = ProcessRegistry.new
219+
check = run_sync_probe(actor_id, probe_registry)
220+
leftovers = remove_probe_records(actor_id:, probe_registry:)
221221
return check if leftovers.empty? || check.failed?
222222

223223
warn_check(
@@ -226,31 +226,31 @@ def check_sync_round_trip
226226
)
227227
end
228228

229-
# @rbs (String) -> Check
230-
def run_sync_probe(actor_id)
229+
# @rbs (String, ProcessRegistry) -> Check
230+
def run_sync_probe(actor_id, probe_registry)
231+
probe_registry.register(kind: "caller", metadata: { execution: "doctor" })
231232
value = SecureRandom.hex(8)
232233
message_reference = Mailbox.new.enqueue(
233234
ProbeActor.ref(actor_id),
234235
:ping,
235236
{ value: },
236237
kind: "sync"
237238
)
238-
result = SynchronousInvocation.new.call(message_reference, timeout: 5.seconds)
239+
result = SynchronousInvocation
240+
.new(process_registry: probe_registry)
241+
.call(message_reference, timeout: 5.seconds)
239242
raise Error, "unexpected round-trip result" unless result == value
240243

241244
pass(:sync_round_trip, "durable synchronous actor call completed without a worker")
242245
rescue => error
243246
fail_check(:sync_round_trip, "#{error.class}: #{error.message}")
244247
end
245248

246-
# @rbs (actor_id: String, caller_process_id: String?) -> Array[String]
247-
def remove_probe_records(actor_id:, caller_process_id:)
249+
# @rbs (actor_id: String, probe_registry: ProcessRegistry) -> Array[String]
250+
def remove_probe_records(actor_id:, probe_registry:)
248251
leftovers = []
249252
leftovers << "probe actor" unless delete_probe_actor(actor_id)
250-
probe_process_id = SolidObjects.caller_process.process_record_id
251-
return leftovers if probe_process_id.nil? || probe_process_id == caller_process_id
252-
253-
leftovers << "probe caller process" unless delete_probe_caller_process(probe_process_id)
253+
leftovers << "probe caller process" unless delete_probe_caller_process(probe_registry)
254254
leftovers
255255
end
256256

@@ -262,9 +262,13 @@ def delete_probe_actor(actor_id)
262262
false
263263
end
264264

265-
# @rbs (String) -> bool
266-
def delete_probe_caller_process(process_id)
267-
SolidObjects.caller_process.delete_process_record(process_id)
265+
# @rbs (ProcessRegistry) -> bool
266+
def delete_probe_caller_process(probe_registry)
267+
process_record = probe_registry.process_record
268+
return true unless process_record
269+
270+
probe_registry.stop
271+
process_record.delete
268272
true
269273
rescue
270274
false

lib/solid_objects/synchronous_invocation.rb

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@
66

77
module SolidObjects
88
class SynchronousInvocation
9+
# @rbs @dedicated_process_registry: ProcessRegistry?
10+
11+
# @rbs (?process_registry: ProcessRegistry?) -> void
12+
def initialize(process_registry: nil)
13+
@dedicated_process_registry = process_registry
14+
end
15+
916
# @rbs (MessageReference, timeout: Numeric) -> untyped
1017
def call(message_reference, timeout:)
1118
return call_before_deadline(message_reference, timeout:) if SyncDeadline.active?
@@ -92,9 +99,17 @@ def raise_rejection(message)
9299
)
93100
end
94101

102+
# @rbs () -> ProcessRegistry
103+
def process_registry
104+
dedicated_registry = @dedicated_process_registry
105+
return SolidObjects.caller_process.process_registry unless dedicated_registry
106+
107+
dedicated_registry.tap(&:heartbeat)
108+
end
109+
95110
# @rbs (Message, deadline: Float) -> Integer
96111
def assist(message, deadline:)
97-
process_registry = SolidObjects.caller_process.process_registry
112+
process_registry = self.process_registry
98113
activation = ActivationManager
99114
.new(owner_id: process_registry.process_record.id)
100115
.claim(instance_id: message.instance_id)

sig/generated/lib/solid_objects/caller_process.rbs

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,9 @@ module SolidObjects
1616
# @rbs () -> ProcessRegistry
1717
def process_registry: () -> ProcessRegistry
1818

19-
# @rbs () -> String?
20-
def process_record_id: () -> String?
21-
2219
# @rbs () -> bool
2320
def stop: () -> bool
2421

25-
# @rbs (String) -> bool
26-
def delete_process_record: (String) -> bool
27-
2822
private
2923

3024
attr_reader mutex: untyped

sig/generated/lib/solid_objects/doctor.rbs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -78,17 +78,17 @@ module SolidObjects
7878
# @rbs () -> Check
7979
def check_sync_round_trip: () -> Check
8080

81-
# @rbs (String) -> Check
82-
def run_sync_probe: (String) -> Check
81+
# @rbs (String, ProcessRegistry) -> Check
82+
def run_sync_probe: (String, ProcessRegistry) -> Check
8383

84-
# @rbs (actor_id: String, caller_process_id: String?) -> Array[String]
85-
def remove_probe_records: (actor_id: String, caller_process_id: String?) -> Array[String]
84+
# @rbs (actor_id: String, probe_registry: ProcessRegistry) -> Array[String]
85+
def remove_probe_records: (actor_id: String, probe_registry: ProcessRegistry) -> Array[String]
8686

8787
# @rbs (String) -> bool
8888
def delete_probe_actor: (String) -> bool
8989

90-
# @rbs (String) -> bool
91-
def delete_probe_caller_process: (String) -> bool
90+
# @rbs (ProcessRegistry) -> bool
91+
def delete_probe_caller_process: (ProcessRegistry) -> bool
9292

9393
# @rbs (Check, Check) -> bool
9494
def ready_for_round_trip?: (Check, Check) -> bool

sig/generated/lib/solid_objects/synchronous_invocation.rbs

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

33
module SolidObjects
44
class SynchronousInvocation
5+
@dedicated_process_registry: ProcessRegistry?
6+
7+
# @rbs (?process_registry: ProcessRegistry?) -> void
8+
def initialize: (?process_registry: ProcessRegistry?) -> void
9+
510
# @rbs (MessageReference, timeout: Numeric) -> untyped
611
def call: (MessageReference, timeout: Numeric) -> untyped
712

@@ -22,6 +27,9 @@ module SolidObjects
2227
# @rbs (Message) -> bot
2328
def raise_rejection: (Message) -> bot
2429

30+
# @rbs () -> ProcessRegistry
31+
def process_registry: () -> ProcessRegistry
32+
2533
# @rbs (Message, deadline: Float) -> Integer
2634
def assist: (Message, deadline: Float) -> Integer
2735

test/integration/doctor_test.rb

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,19 @@ class DoctorTest < ActiveSupport::TestCase
2121
assert_empty SolidObjects::Process.where(kind: "caller")
2222
end
2323

24+
test "runs its probe on a caller process the application cannot adopt" do
25+
SolidObjects.caller_process.define_singleton_method(:process_registry) do
26+
raise "the doctor probe must not share the application caller process"
27+
end
28+
29+
report = SolidObjects::Doctor.new.call
30+
31+
assert_equal :pass, report.check(:sync_round_trip).status
32+
assert_empty SolidObjects::Process.where(kind: "caller")
33+
ensure
34+
SolidObjects.reset_caller_process!
35+
end
36+
2437
test "preserves a caller process the application registered before the probe" do
2538
existing_record = SolidObjects.caller_process.process_registry.process_record
2639

@@ -39,10 +52,11 @@ class DoctorTest < ActiveSupport::TestCase
3952
skip unless SolidObjects::Record.connection.adapter_name.match?(/sqlite/i)
4053
lock = hold_sqlite_write_lock
4154

42-
report = SolidObjects::Doctor.new.call
55+
report = without_sqlite_busy_wait { SolidObjects::Doctor.new.call }
4356

4457
refute report.healthy?
4558
assert_equal :fail, report.check(:sync_round_trip).status
59+
assert_match(/database is locked/, report.check(:sync_round_trip).message)
4660
ensure
4761
release_sqlite_write_lock(lock) if lock
4862
end
@@ -157,6 +171,16 @@ def hold_sqlite_write_lock
157171
[ thread, release ]
158172
end
159173

174+
def without_sqlite_busy_wait
175+
SolidObjects::Record.connection_pool.with_connection do |connection|
176+
previous_timeout = connection.select_value("PRAGMA busy_timeout").to_i
177+
connection.execute("PRAGMA busy_timeout = 0")
178+
yield
179+
ensure
180+
connection.execute("PRAGMA busy_timeout = #{previous_timeout}") if previous_timeout
181+
end
182+
end
183+
160184
def release_sqlite_write_lock(lock)
161185
thread, release = lock
162186
release << true

0 commit comments

Comments
 (0)