phronomy 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,119 +1,57 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- # Singleton event loop that manages all FSMSession instances.
4
+ # Runtime-owned event loop that manages all FSMSession instances.
5
5
  #
6
- # A single background thread reads from a global {Phronomy::Concurrency::AsyncQueue} and
7
- # dispatches events to their target FSMSession. IO work (LLM calls, tool
8
- # calls) must be dispatched via +Runtime.instance.spawn+ or
9
- # +BlockingAdapterPool+, then post results back to the loop via
10
- # {EventLoop#post}.
6
+ # A dedicated real thread reads from a Runtime-local AsyncQueue and dispatches
7
+ # events to their target FSMSession. The EventLoop is created at most once by
8
+ # its owning Runtime and is never restarted after shutdown.
11
9
  #
12
- # Always active all Workflow and Agent invocations use the EventLoop.
13
- #
14
- # == Threading exception (see ADR-010 Rule 2)
15
- #
16
- # +EventLoop+ is a **deliberate exception** to Phronomy's cooperative-first
17
- # concurrency model. Its dispatch loop is an infinite +while @running+ loop
18
- # that must never block the framework's own event processing.
19
- # Running it on a shared scheduler task would consume the scheduler, preventing
20
- # other tasks from running. Therefore {#start} creates a dedicated
21
- # {Runtime::ThreadScheduler} — this is correct and intentional per ADR-010.
22
- # No other framework component should do the same; see the ADR-010 checklist.
23
- #
24
- # == Handler constraints
25
- #
26
- # Handlers dispatched by the EventLoop run **on the EventLoop thread**.
27
- # They must not:
28
- #
29
- # * Perform blocking operations directly (database queries, LLM calls, HTTP
30
- # requests). Schedule blocking work via +Runtime.instance.spawn+ or
31
- # +BlockingAdapterPool+, then post results back with {#post}.
32
- # * Call +Workflow#invoke+ (or any synchronous +invoke+) from within a
33
- # handler. That method would block waiting for the EventLoop to process
34
- # events, causing a deadlock. Use the async pattern: post a follow-up
35
- # event instead.
36
- #
37
- # == Fork safety
38
- #
39
- # +EventLoop.instance+ is lazily initialized. The background thread is not
40
- # created until the first call, so Puma worker forking does not duplicate the
41
- # thread. No +after_fork+ hook is required.
42
- #
43
- # == Deadlock warning
44
- #
45
- # Do NOT call +Workflow#invoke+ (in EventLoop mode) from within a workflow
46
- # entry action. The entry action runs on the EventLoop thread; a nested
47
- # +invoke+ would block waiting for the same thread to process events →
48
- # deadlock. Use the async pattern instead: schedule work via
49
- # +Runtime.instance.spawn+ or +BlockingAdapterPool+, then post events back
50
- # via +Phronomy::EventLoop.instance.post(...)+.
10
+ # FSMSession handlers run on the EventLoop thread. They must not perform
11
+ # blocking work or call synchronous invoke APIs from that thread.
51
12
  class EventLoop
52
- # Sentinel target_id for EventLoop management events (:start, :finished, :halted, :error).
53
- # Events with this target_id are processed directly by the EventLoop and never
54
- # routed to any FSMSession via handle(event).
55
13
  SYSTEM_CHANNEL_ID = "__event_loop__"
56
14
 
57
- # Returns the singleton instance, creating and starting it on first call.
58
- def self.instance
59
- @instance ||= new.tap(&:start)
60
- end
15
+ STOP = Object.new.freeze
16
+ private_constant :STOP
61
17
 
62
- # Returns true when called from within the EventLoop dispatch task.
63
- # Uses a task-local key set by the Runtime-spawned dispatch task so that
64
- # the check works correctly for both thread-based and future fiber-based
65
- # scheduler backends.
66
- # @return [Boolean]
18
+ # @param runtime [Phronomy::Runtime] owning Runtime
67
19
  # @api private
68
- def self.current?
69
- Phronomy::Task.current&.name == "event-loop"
70
- end
20
+ def initialize(runtime:)
21
+ @runtime = runtime
22
+ @queue = Phronomy::Concurrency::AsyncQueue.new
23
+ @fsms = {}
24
+ @waiting = {}
25
+
26
+ @lifecycle_mutex = Mutex.new
27
+ @idle_cond = ConditionVariable.new
28
+ @shutdown_mutex = Mutex.new
29
+ @state = :running
30
+ @outstanding_sessions = 0
31
+ @cancel_requested = false
32
+ @shutdown_status = nil
71
33
 
72
- # Stops and destroys the singleton. Primarily used in tests.
73
- # @api private
74
- def self.reset!
75
- @instance&.stop
76
- @instance = nil
77
- end
78
-
79
- def initialize
80
- @queue = Phronomy::Concurrency::AsyncQueue.new # global event queue (thread-safe; no Mutex needed)
81
- @fsms = {} # { id => FSMSession } — EventLoop thread only
82
- @waiting = {} # { id => completion_queue } — EventLoop thread only
83
- # Mutex-backed FSM count for drain-mode shutdown.
84
- @fsm_count_mutex = Mutex.new
85
- @fsm_count_cond = ConditionVariable.new
86
- @fsm_count = 0
87
- # Token cancelled when shutdown is requested; new child sessions receive it.
88
- @shutdown_token = Phronomy::Concurrency::CancellationToken.new
89
- # Fairness metrics (EventLoop thread only, except where noted)
90
34
  @lag_mutex = Mutex.new
91
35
  @last_lag_ns = 0
92
36
  @max_lag_ns = 0
93
37
  @dispatch_count = 0
94
38
  @total_lag_ns = 0
39
+
40
+ @task = @runtime.__spawn_event_loop_service { run_loop }
95
41
  end
96
42
 
97
- # Returns the most recently measured event-loop lag in seconds.
98
- # Lag is the wall-clock time between {#post} and the moment the event
99
- # is dequeued for dispatch. Thread-safe.
100
43
  # @return [Float]
101
44
  # @api private
102
45
  def last_lag_seconds
103
46
  @lag_mutex.synchronize { @last_lag_ns } / 1_000_000_000.0
104
47
  end
105
48
 
106
- # Returns the maximum event-loop lag seen since the loop was started.
107
- # Thread-safe.
108
49
  # @return [Float]
109
50
  # @api private
110
51
  def max_lag_seconds
111
52
  @lag_mutex.synchronize { @max_lag_ns } / 1_000_000_000.0
112
53
  end
113
54
 
114
- # Returns the mean event-loop lag across all dispatched events since the
115
- # loop was started. Returns 0.0 when no events have been dispatched.
116
- # Thread-safe.
117
55
  # @return [Float]
118
56
  # @api private
119
57
  def average_lag_seconds
@@ -124,234 +62,335 @@ module Phronomy
124
62
  end
125
63
  end
126
64
 
127
- # Registers an FSMSession for execution and returns a completion queue.
128
- #
129
- # The session and its completion queue are handed off to the EventLoop thread
130
- # via the queue payload, so +@fsms+ and +@waiting+ are exclusively written
131
- # and read by the EventLoop thread. No Mutex is required.
65
+ # Registers an FSMSession and returns its completion queue.
132
66
  #
133
- # The caller blocks on +completion_queue.pop+ to receive the final context
134
- # (WorkflowContext) once the workflow finishes or halts. If an error occurred,
135
- # the popped value will be an Exception — callers are responsible for re-raising it.
67
+ # +outstanding_sessions+ is incremented before the :start event is enqueued,
68
+ # so shutdown also accounts for accepted sessions that have not yet been
69
+ # dispatched.
136
70
  #
137
71
  # @param fsm_session [Phronomy::FSMSession]
138
- # @return [Phronomy::Concurrency::AsyncQueue] resolves to final/halted context, or an Exception
72
+ # @param completion [Phronomy::Task, nil]
73
+ # @return [Phronomy::Concurrency::AsyncQueue, Phronomy::Task]
139
74
  # @api private
140
75
  def register(fsm_session, completion: nil)
141
- if Phronomy::EventLoop.current? && !completion.is_a?(Phronomy::Task)
76
+ if current? && !completion.is_a?(Phronomy::Task)
142
77
  raise Phronomy::Error,
143
- "Cannot call Workflow#invoke (EventLoop mode) from within an EventLoop " \
144
- "entry action. Schedule work via Runtime.instance.spawn or " \
145
- "BlockingAdapterPool, then post events back via " \
146
- "Phronomy::EventLoop.instance.post(...) instead."
78
+ "Cannot call synchronous Workflow#invoke from an EventLoop action. " \
79
+ "Schedule work asynchronously instead."
147
80
  end
148
81
 
149
82
  completion_queue = completion || Phronomy::Concurrency::AsyncQueue.new
150
- # When called from a DeterministicScheduler Fiber (e.g. :fiber backend),
151
- # mark the queue so that _pop_cooperative uses track_blocking_await.
152
- # This prevents run_until_idle from exiting before the EventLoop thread
153
- # (a different OS thread where Scheduler.current is nil) pushes the result.
154
83
  scheduler = Phronomy::Runtime::Scheduler.current
155
- completion_queue.expect_cross_thread_push(scheduler) if scheduler && completion_queue.respond_to?(:expect_cross_thread_push)
156
- # Pass both session and completion_queue in the event payload so that the
157
- # EventLoop thread is the sole writer of @fsms and @waiting.
158
- # Use SYSTEM_CHANNEL_ID so the management event is never routed to an FSM.
159
- @queue.push([Event.new(type: :start, target_id: SYSTEM_CHANNEL_ID,
160
- payload: {session: fsm_session, completion: completion_queue}),
161
- Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)])
84
+ if scheduler && completion_queue.respond_to?(:expect_cross_thread_push)
85
+ completion_queue.expect_cross_thread_push(scheduler)
86
+ end
87
+
88
+ event = Phronomy::Event.new(
89
+ type: :start,
90
+ target_id: SYSTEM_CHANNEL_ID,
91
+ payload: {session: fsm_session, completion: completion_queue}
92
+ )
93
+
94
+ @lifecycle_mutex.synchronize do
95
+ ensure_accepting_registrations!
96
+ @outstanding_sessions += 1
97
+ begin
98
+ @queue.push([event, monotonic_nanoseconds])
99
+ rescue
100
+ @outstanding_sessions -= 1
101
+ @idle_cond.broadcast if @outstanding_sessions.zero?
102
+ raise
103
+ end
104
+ end
105
+
162
106
  completion_queue
163
107
  end
164
108
 
165
- # Posts an event to the loop. Safe to call from any thread (including IO threads).
166
- # The current monotonic clock time is recorded so that the EventLoop can
167
- # measure the dispatch lag when it dequeues the event.
109
+ # Posts an event. Returns false after stopping begins.
110
+ #
111
+ # Async completion callbacks may race with shutdown, so rejection is a
112
+ # boolean result rather than an exception.
168
113
  #
169
- # @note **Handler constraint**: do not perform blocking operations or call
170
- # +Workflow#invoke+ directly from within the handler that processes a
171
- # posted event. Handlers run on the EventLoop thread; blocking there
172
- # stalls all session processing. For blocking work, post a new event
173
- # after the result is ready.
174
114
  # @param event [Phronomy::Event]
115
+ # @return [Boolean]
175
116
  # @api private
176
117
  def post(event)
177
- @queue.push([event, Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)])
118
+ @lifecycle_mutex.synchronize do
119
+ return false unless accepting_events?
120
+
121
+ @queue.push([event, monotonic_nanoseconds])
122
+ end
123
+ true
178
124
  end
179
125
 
180
- # Starts the EventLoop dispatch task under {Runtime} ownership.
181
- #
182
- # The dispatch loop runs as a {Phronomy::Task} so that {Runtime#shutdown}
183
- # can drain it together with all other in-flight tasks. The task is named
184
- # +"event-loop"+ so that {.current?} can identify it via
185
- # +Task.current&.name+.
186
- # @return [self]
126
+ # Returns true only for this EventLoop's dispatcher Task.
127
+ # @api private
128
+ def current?
129
+ Phronomy::Task.current.equal?(@task)
130
+ end
131
+
132
+ # @return [Symbol]
133
+ # @api private
134
+ def state
135
+ @lifecycle_mutex.synchronize { @state }
136
+ end
137
+
138
+ # Stops external admission at the Runtime boundary while allowing already
139
+ # accepted work to complete on this EventLoop.
187
140
  # @api private
188
- def start
189
- return self if @task&.alive?
190
-
191
- # Reset shutdown state so the loop can be restarted after a stop.
192
- @shutdown_token = Phronomy::Concurrency::CancellationToken.new
193
- @fsm_count_mutex.synchronize { @fsm_count = 0 }
194
- @running = true
195
- # The dispatch loop must always run in a real background thread.
196
- # A cooperative scheduler (FakeScheduler/ImmediateBackend) executes tasks
197
- # synchronously on the caller's thread, which would block forever inside
198
- # the run_loop infinite loop. Create a dedicated Runtime with
199
- # ThreadScheduler to guarantee async execution regardless of the global
200
- # runtime_backend setting.
201
- thread_runtime = Phronomy::Runtime.new(scheduler: Phronomy::Runtime::ThreadScheduler.new)
202
- @task = thread_runtime.spawn(name: "event-loop") do
203
- run_loop
141
+ def begin_draining
142
+ @lifecycle_mutex.synchronize do
143
+ @state = :draining if @state == :running
204
144
  end
205
145
  self
206
146
  end
207
147
 
208
- # Stops the EventLoop dispatch task.
148
+ # @return [Boolean]
149
+ # @api private
150
+ def idle?
151
+ @lifecycle_mutex.synchronize { @outstanding_sessions.zero? }
152
+ end
153
+
154
+ # Waits until no queued :start or active session remains.
155
+ # @param deadline [Numeric] absolute monotonic deadline
156
+ # @return [Boolean] false on timeout
157
+ # @api private
158
+ def wait_until_idle(deadline)
159
+ @lifecycle_mutex.synchronize do
160
+ until @outstanding_sessions.zero?
161
+ remaining = deadline - monotonic_now
162
+ return false if remaining <= 0
163
+
164
+ @idle_cond.wait(@lifecycle_mutex, remaining)
165
+ end
166
+ true
167
+ end
168
+ end
169
+
170
+ # Runtime-only terminal shutdown.
209
171
  #
210
- # Sends a cooperative shutdown sentinel to the event queue so that the
211
- # dispatch task can finish any in-flight handler before exiting. Waits up
212
- # to +timeout+ seconds for a clean shutdown; if the task is still alive
213
- # afterwards it is cancelled (cooperative cancellation via {Task#cancel!}).
172
+ # On graceful timeout the dispatcher is cancelled. Queue cleanup is only
173
+ # performed after the dispatcher is confirmed dead, so there is never more
174
+ # than one queue consumer.
214
175
  #
215
- # @param timeout [Numeric] seconds to wait for cooperative shutdown. Defaults
216
- # to +Phronomy.configuration.event_loop_stop_grace_seconds+ (5 s).
217
- # @param drain [Boolean] when +true+, wait for all active FSMSessions to
218
- # complete before signalling the loop to stop. Bounded by +timeout+.
219
- # Defaults to +false+.
220
- # @param force_kill [Boolean] deprecated — retained for backward compatibility.
221
- # When +true+, the dispatch task is cancelled via {Task#cancel!} if it does
222
- # not stop within +timeout+. +Thread#kill+ is no longer used; cooperative
223
- # cancellation (raising {CancellationError}) replaces it.
224
- # @return [Symbol] shutdown status:
225
- # - +:clean+ — loop exited cooperatively with no active sessions discarded
226
- # - +:drained_with_discards+ — drain mode requested but sessions remained;
227
- # they were discarded and the loop was stopped
228
- # - +:timeout+ — the task did not stop in time and +force_kill:+ is +false+
229
- # - +:force_killed+ — the task was cancelled because it did not stop in time
176
+ # @param deadline [Numeric] absolute monotonic deadline
177
+ # @param cancel_grace [Numeric] seconds to wait after Task#cancel!
178
+ # @return [Symbol] :terminated, :cancelled, :cancel_timeout, or :failed
230
179
  # @api private
231
- def stop(timeout: Phronomy.configuration.event_loop_stop_grace_seconds, drain: false, force_kill: false)
232
- @shutdown_token.cancel!
233
- status = :clean
234
-
235
- if drain
236
- # Wait for active sessions to finish, bounded by timeout.
237
- deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
238
- @fsm_count_mutex.synchronize do
239
- while @fsm_count > 0
240
- remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
241
- break if remaining <= 0
242
- @fsm_count_cond.wait(@fsm_count_mutex, remaining)
243
- end
244
- status = :drained_with_discards if @fsm_count > 0
180
+ def shutdown(deadline:, cancel_grace:)
181
+ @shutdown_mutex.synchronize do
182
+ return @shutdown_status if @shutdown_status
183
+
184
+ if state == :failed
185
+ join_until(deadline)
186
+ @shutdown_status = :failed
187
+ return @shutdown_status
245
188
  end
246
- end
247
189
 
248
- @running = false
249
- @queue.push(:__stop__) # unblock queue.pop so the task can see @running = false
250
- begin
251
- @task&.join(timeout)
252
- rescue
253
- # Task may have terminated with an error (e.g. simulated crash in tests).
254
- # Suppress the re-raise so the cleanup below always runs.
255
- nil
256
- end
257
- if @task&.alive?
258
- if force_kill
259
- Phronomy.configuration.logger&.warn(
260
- "[Phronomy] EventLoop task did not stop within #{timeout}s; cancelling. " \
261
- "This is a last resort — check for blocking operations in event handlers."
262
- )
263
- @task.cancel!
264
- status = :force_killed
190
+ begin_draining
191
+ if wait_until_idle(deadline)
192
+ begin_stopping_if_idle
193
+ join_until(deadline)
194
+ end
195
+
196
+ @shutdown_status = if task_alive?
197
+ cancel_and_cleanup(cancel_grace)
198
+ elsif state == :failed
199
+ :failed
265
200
  else
266
- Phronomy.configuration.logger&.warn(
267
- "[Phronomy] EventLoop task did not stop within #{timeout}s; abandoning " \
268
- "(force_kill: false). Check for blocking operations in event handlers."
269
- )
270
- status = :timeout
201
+ finalize_terminated(:terminated)
271
202
  end
272
203
  end
273
- @task = nil
274
- status
204
+ end
205
+
206
+ # @return [Boolean]
207
+ # @api private
208
+ def task_alive?
209
+ @task&.alive? || false
275
210
  end
276
211
 
277
212
  private
278
213
 
279
214
  def run_loop
280
- while @running
215
+ loop do
281
216
  item = @queue.pop
282
- # :__stop__ is used purely as an unblock signal for @queue.pop; the
283
- # actual stop condition is @running == false (set before the push).
284
- # Treating it as `next` instead of `break` prevents a stale sentinel
285
- # (left by a previous stop call that raced with thread start) from
286
- # immediately terminating a freshly restarted EventLoop.
287
- next if item == :__stop__
288
-
289
- # item is [event, posted_at_ns] — unwrap and measure lag
217
+ break if item.equal?(STOP)
218
+
290
219
  event, posted_at_ns = item
291
- dequeued_at_ns = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
220
+ dequeued_at_ns = monotonic_nanoseconds
292
221
  lag_ns = dequeued_at_ns - posted_at_ns
293
222
  update_lag_metrics(lag_ns)
294
223
  check_starvation_lag(lag_ns, event)
295
224
 
296
225
  dispatch_start_ns = dequeued_at_ns
297
- if event.target_id == SYSTEM_CHANNEL_ID
298
- # Management channel: lifecycle events processed directly by EventLoop.
299
- # Never routed to any FSMSession.
300
- case event.type
301
- when :finished, :halted, :error
302
- # session_id is carried in the payload so the FSM can use its own ID
303
- # as the FSM-facing target_id for other events.
304
- session_id = event.payload[:session_id]
305
- @fsms.delete(session_id)
306
- cq = @waiting.delete(session_id)
307
- complete_waiter(cq, event.payload[:result])
308
- # Decrement active FSM count and signal drain waiters.
309
- @fsm_count_mutex.synchronize do
310
- @fsm_count -= 1
311
- @fsm_count_cond.signal if @fsm_count <= 0
312
- end
313
-
314
- when :start
315
- # session and completion_queue arrive together in the payload so that
316
- # this thread is the sole writer of @fsms and @waiting.
317
- # completion may be nil for fire-and-forget child sessions (AgentFSM).
318
- session = event.payload[:session]
319
- cq = event.payload[:completion]
320
-
321
- # When shutdown has been requested, reject new sessions with a
322
- # CancellationError rather than starting new LLM calls that would
323
- # be interrupted by force-kill.
324
- if @shutdown_token.cancelled? && cq
325
- complete_waiter(cq, Phronomy::CancellationError.new("EventLoop is shutting down"))
326
- next
327
- end
328
-
329
- @fsms[session.id] = session
330
- @waiting[session.id] = cq if cq
331
- @fsm_count_mutex.synchronize { @fsm_count += 1 }
332
- session.start
333
- end
226
+ dispatch(event)
227
+ check_dispatch_time(dispatch_start_ns, event)
228
+ end
229
+ rescue Phronomy::CancellationError => error
230
+ unless shutdown_cancel_requested?
231
+ notify_unexpected_dispatcher_failure(error)
232
+ raise
233
+ end
234
+ rescue => error
235
+ notify_unexpected_dispatcher_failure(error)
236
+ raise
237
+ ensure
238
+ @lifecycle_mutex.synchronize { @idle_cond.broadcast }
239
+ end
334
240
 
241
+ def dispatch(event)
242
+ if event.target_id == SYSTEM_CHANNEL_ID
243
+ dispatch_management(event)
244
+ else
245
+ fsm = @fsms[event.target_id]
246
+ if fsm
247
+ fsm.handle(event)
335
248
  else
336
- # FSM channel: route to the target FSMSession by target_id.
337
- fsm = @fsms[event.target_id]
338
- if fsm
339
- fsm.handle(event)
340
- else
341
- # Warn when an event is dropped due to an unknown target_id so that
342
- # mis-typed IDs and handler-deregistration races are visible.
343
- warn "[Phronomy::EventLoop] Dropped event #{event.type.inspect} — " \
344
- "no handler for target_id #{event.target_id.inspect}"
345
- end
249
+ warn "[Phronomy::EventLoop] Dropped event #{event.type.inspect} " \
250
+ "no handler for target_id #{event.target_id.inspect}"
346
251
  end
252
+ end
253
+ end
347
254
 
348
- # Check how long this dispatch took; warn if it exceeds the threshold.
349
- check_dispatch_time(dispatch_start_ns, event)
255
+ def dispatch_management(event)
256
+ case event.type
257
+ when :finished, :halted, :error
258
+ session_id = event.payload[:session_id]
259
+ session = @fsms.delete(session_id)
260
+ waiter = @waiting.delete(session_id)
261
+ complete_waiter(waiter, event.payload[:result])
262
+ decrement_outstanding if session
263
+ when :start
264
+ session = event.payload[:session]
265
+ waiter = event.payload[:completion]
266
+ @fsms[session.id] = session
267
+ @waiting[session.id] = waiter if waiter
268
+ session.start
269
+ end
270
+ end
271
+
272
+ def begin_stopping_if_idle
273
+ @lifecycle_mutex.synchronize do
274
+ return false unless @state == :draining
275
+ return false unless @outstanding_sessions.zero?
276
+
277
+ @state = :stopping
278
+ @queue.push(STOP)
279
+ true
280
+ end
281
+ end
282
+
283
+ def cancel_and_cleanup(cancel_grace)
284
+ task = @task
285
+ @lifecycle_mutex.synchronize do
286
+ @state = :stopping unless @state == :failed
287
+ @cancel_requested = true
288
+ end
289
+
290
+ task&.cancel!
291
+ begin
292
+ task&.join(cancel_grace)
293
+ rescue
294
+ nil
295
+ end
296
+
297
+ if task&.alive?
298
+ @lifecycle_mutex.synchronize { @state = :failed }
299
+ return :cancel_timeout
300
+ end
301
+
302
+ return :failed if state == :failed
303
+
304
+ cleanup_abandoned_work(
305
+ Phronomy::CancellationError.new("Runtime shutdown timed out")
306
+ )
307
+ finalize_terminated(:cancelled)
308
+ end
309
+
310
+ # Called only after the dispatcher is confirmed dead.
311
+ def cleanup_abandoned_work(error)
312
+ drain_queued_items.each do |item|
313
+ next if item.equal?(STOP)
314
+
315
+ event, = item
316
+ next unless event.target_id == SYSTEM_CHANNEL_ID && event.type == :start
317
+
318
+ complete_waiter(event.payload[:completion], error)
319
+ end
320
+
321
+ @waiting.values.each { |waiter| complete_waiter(waiter, error) }
322
+ @waiting.clear
323
+ @fsms.clear
324
+ @lifecycle_mutex.synchronize do
325
+ @outstanding_sessions = 0
326
+ @idle_cond.broadcast
327
+ end
328
+ end
329
+
330
+ def drain_queued_items
331
+ items = []
332
+ loop do
333
+ item = @queue.pop(timeout: 0)
334
+ break unless item
335
+
336
+ items << item
337
+ end
338
+ items
339
+ end
340
+
341
+ # Framework failures are reported and made terminal. No automatic restart,
342
+ # replay, or pending-queue recovery is attempted.
343
+ def notify_unexpected_dispatcher_failure(error)
344
+ @lifecycle_mutex.synchronize do
345
+ @state = :failed
346
+ @idle_cond.broadcast
347
+ end
348
+ @waiting.values.each { |waiter| complete_waiter(waiter, error) }
349
+ @runtime.__event_loop_failed(error)
350
+ end
351
+
352
+ def shutdown_cancel_requested?
353
+ @lifecycle_mutex.synchronize do
354
+ @cancel_requested && @state == :stopping
355
+ end
356
+ end
357
+
358
+ def accepting_events?
359
+ %i[running draining].include?(@state)
360
+ end
361
+
362
+ def ensure_accepting_registrations!
363
+ return if accepting_events?
364
+
365
+ raise Phronomy::RuntimeShutdownError,
366
+ "EventLoop is #{@state}; new sessions are not accepted"
367
+ end
368
+
369
+ def decrement_outstanding
370
+ @lifecycle_mutex.synchronize do
371
+ @outstanding_sessions -= 1 if @outstanding_sessions.positive?
372
+ @idle_cond.broadcast if @outstanding_sessions.zero?
350
373
  end
351
- rescue => e
352
- # Unblock all waiting callers if the loop dies unexpectedly.
353
- @waiting.values.each { |cq| complete_waiter(cq, e) }
354
- raise
374
+ end
375
+
376
+ def join_until(deadline)
377
+ remaining = deadline - monotonic_now
378
+ return if remaining <= 0
379
+
380
+ begin
381
+ @task&.join(remaining)
382
+ rescue
383
+ nil
384
+ end
385
+ end
386
+
387
+ def finalize_terminated(status)
388
+ @lifecycle_mutex.synchronize do
389
+ @state = :terminated
390
+ @task = nil unless @task&.alive?
391
+ @idle_cond.broadcast
392
+ end
393
+ status
355
394
  end
356
395
 
357
396
  def complete_waiter(waiter, payload)
@@ -385,9 +424,9 @@ module Phronomy
385
424
 
386
425
  Phronomy.configuration.logger&.warn do
387
426
  "[Phronomy::EventLoop] Starvation detected: event #{event.type.inspect} " \
388
- "for target #{event.target_id.inspect} waited " \
389
- "#{format("%.3f", lag_ns / 1_000_000_000.0)}s in queue " \
390
- "(threshold: #{threshold}s)"
427
+ "for target #{event.target_id.inspect} waited " \
428
+ "#{format("%.3f", lag_ns / 1_000_000_000.0)}s in queue " \
429
+ "(threshold: #{threshold}s)"
391
430
  end
392
431
  end
393
432
 
@@ -395,15 +434,23 @@ module Phronomy
395
434
  threshold = Phronomy.configuration.event_loop_dispatch_threshold_seconds
396
435
  return unless threshold
397
436
 
398
- elapsed_ns = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond) - dispatch_start_ns
437
+ elapsed_ns = monotonic_nanoseconds - dispatch_start_ns
399
438
  return unless elapsed_ns > (threshold * 1_000_000_000)
400
439
 
401
440
  Phronomy.configuration.logger&.warn do
402
441
  "[Phronomy::EventLoop] Long dispatch: event #{event.type.inspect} " \
403
- "for target #{event.target_id.inspect} took " \
404
- "#{format("%.3f", elapsed_ns / 1_000_000_000.0)}s on the EventLoop thread " \
405
- "(threshold: #{threshold}s). Consider moving blocking work to BlockingAdapterPool."
442
+ "for target #{event.target_id.inspect} took " \
443
+ "#{format("%.3f", elapsed_ns / 1_000_000_000.0)}s on the EventLoop thread " \
444
+ "(threshold: #{threshold}s). Consider moving blocking work to BlockingAdapterPool."
406
445
  end
407
446
  end
447
+
448
+ def monotonic_now
449
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
450
+ end
451
+
452
+ def monotonic_nanoseconds
453
+ Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
454
+ end
408
455
  end
409
456
  end