Skip to content

Commit 26d243c

Browse files
committed
fix: serialize role replacement with shutdown
The monitor could pass its started check immediately before stop cleared the flag, then swap in a replacement while shutdown walked the component list, leaving a role that never received request_shutdown alive after stop returned. Flip the flag and perform the swap under one lock, so a replacement either completes before shutdown reads the list or never starts.
1 parent 14f857e commit 26d243c

3 files changed

Lines changed: 34 additions & 6 deletions

File tree

lib/solid_objects/supervisor.rb

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ class Supervisor
77
# @rbs @monitor: Thread?
88
# @rbs @started: bool
99
# @rbs @cleaned_up_at: Float
10+
# @rbs @lifecycle: Thread::Mutex
1011

1112
# @rbs (?worker_count: Integer, ?effect_worker_count: Integer, ?broadcast_worker_count: Integer, ?reminder_scheduler_count: Integer) -> void
1213
def initialize(
@@ -25,6 +26,7 @@ def initialize(
2526
@monitor = nil
2627
@started = false
2728
@cleaned_up_at = nil
29+
@lifecycle = Thread::Mutex.new
2830
end
2931

3032
# @rbs () -> void
@@ -50,7 +52,10 @@ def stop
5052
return unless @started
5153

5254
begin
53-
@started = false
55+
# Flipping the flag under the same lock replacement takes means a
56+
# replacement either completes before shutdown reads the component
57+
# list, or never starts.
58+
@lifecycle.synchronize { @started = false }
5459
@monitor&.join(SolidObjects.configuration.supervisor_monitor_interval * 2)
5560
components.each(&:request_shutdown)
5661
join_until_timeout
@@ -101,14 +106,20 @@ def replace_dead_roles
101106
components.each_with_index do |component, index|
102107
thread = threads[index]
103108
next if thread&.alive?
104-
break unless @started
105109

106-
replacement = component.class.new
107-
components[index] = replacement
108-
threads[index] = supervise(replacement)
110+
replaced = @lifecycle.synchronize do
111+
next false unless @started
112+
113+
replacement = component.class.new
114+
components[index] = replacement
115+
threads[index] = supervise(replacement)
116+
replacement
117+
end
118+
break unless replaced
119+
109120
SolidObjects.instrument(
110121
:"supervisor.role_replaced",
111-
role: replacement.class.name,
122+
role: replaced.class.name,
112123
error_class: thread_error(thread)
113124
)
114125
end

sig/generated/lib/solid_objects/supervisor.rbs

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

33
module SolidObjects
44
class Supervisor
5+
@lifecycle: Thread::Mutex
6+
57
@cleaned_up_at: Float
68

79
@started: bool

test/integration/supervisor_replacement_test.rb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,21 @@ def initialize(**) = super(crashes: 0)
102102
assert_equal runs_at_stop, role.runs.size
103103
end
104104

105+
test "no role outlives shutdown when replacement races it" do
106+
10.times do
107+
CrashingRole.shared_runs = nil
108+
role = CrashingRole.new
109+
supervisor = supervisor_for(role)
110+
supervisor.start
111+
Timeout.timeout(10) { sleep 0.001 until role.runs.size >= 1 }
112+
113+
supervisor.stop
114+
115+
live = supervisor.instance_variable_get(:@threads).select(&:alive?)
116+
assert_empty live, "a replacement started during shutdown must not survive it"
117+
end
118+
end
119+
105120
test "instruments a replacement" do
106121
events = []
107122
subscription = ActiveSupport::Notifications.subscribe("solid_objects.supervisor.role_replaced") do |event|

0 commit comments

Comments
 (0)