phronomy 0.7.0 → 0.7.1

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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +8 -7
  3. data/CHANGELOG.md +151 -1
  4. data/README.md +155 -32
  5. data/Rakefile +33 -0
  6. data/benchmark/baseline.json +1 -1
  7. data/benchmark/bench_regression.rb +1 -0
  8. data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +24 -0
  9. data/docs/decisions/006-no-built-in-guardrails.md +20 -2
  10. data/docs/decisions/010-cooperative-first-concurrency.md +248 -0
  11. data/lib/phronomy/agent/base.rb +250 -65
  12. data/lib/phronomy/agent/concerns/suspendable.rb +15 -0
  13. data/lib/phronomy/agent/fsm.rb +41 -64
  14. data/lib/phronomy/agent/orchestrator.rb +146 -121
  15. data/lib/phronomy/agent/parallel_tool_chat.rb +79 -22
  16. data/lib/phronomy/agent/react_agent.rb +8 -0
  17. data/lib/phronomy/async_queue.rb +155 -0
  18. data/lib/phronomy/blocking_adapter_pool.rb +435 -0
  19. data/lib/phronomy/cancellation_scope.rb +123 -0
  20. data/lib/phronomy/cancellation_token.rb +43 -2
  21. data/lib/phronomy/concurrency_gate.rb +155 -0
  22. data/lib/phronomy/configuration.rb +142 -0
  23. data/lib/phronomy/deadline.rb +63 -0
  24. data/lib/phronomy/diagnostics.rb +62 -0
  25. data/lib/phronomy/embeddings/base.rb +17 -0
  26. data/lib/phronomy/eval/runner.rb +9 -9
  27. data/lib/phronomy/event_loop.rb +181 -43
  28. data/lib/phronomy/fsm_session.rb +50 -4
  29. data/lib/phronomy/guardrail/prompt_injection_guardrail.rb +58 -0
  30. data/lib/phronomy/invocation_context.rb +152 -0
  31. data/lib/phronomy/knowledge_source/base.rb +18 -0
  32. data/lib/phronomy/llm_adapter/base.rb +104 -0
  33. data/lib/phronomy/llm_adapter/ruby_llm.rb +41 -0
  34. data/lib/phronomy/llm_adapter.rb +20 -0
  35. data/lib/phronomy/metrics.rb +38 -0
  36. data/lib/phronomy/runtime/deterministic_scheduler.rb +412 -0
  37. data/lib/phronomy/runtime/fake_scheduler.rb +165 -0
  38. data/lib/phronomy/runtime/gate_registry.rb +52 -0
  39. data/lib/phronomy/runtime/pool_registry.rb +57 -0
  40. data/lib/phronomy/runtime/runtime_metrics.rb +117 -0
  41. data/lib/phronomy/runtime/scheduler.rb +98 -0
  42. data/lib/phronomy/runtime/scheduler_timer_adapter.rb +79 -0
  43. data/lib/phronomy/runtime/task_registry.rb +48 -0
  44. data/lib/phronomy/runtime/thread_scheduler.rb +30 -0
  45. data/lib/phronomy/runtime/timer_queue.rb +106 -0
  46. data/lib/phronomy/runtime/timer_service.rb +42 -0
  47. data/lib/phronomy/runtime.rb +374 -0
  48. data/lib/phronomy/task/backend.rb +80 -0
  49. data/lib/phronomy/task/fiber_backend.rb +157 -0
  50. data/lib/phronomy/task/immediate_backend.rb +89 -0
  51. data/lib/phronomy/task/thread_backend.rb +84 -0
  52. data/lib/phronomy/task.rb +275 -0
  53. data/lib/phronomy/task_group.rb +265 -0
  54. data/lib/phronomy/testing/fake_clock.rb +109 -0
  55. data/lib/phronomy/testing/fake_scheduler.rb +104 -0
  56. data/lib/phronomy/testing/scheduler_helpers.rb +59 -0
  57. data/lib/phronomy/testing.rb +12 -0
  58. data/lib/phronomy/tool/base.rb +110 -2
  59. data/lib/phronomy/tool/mcp_tool.rb +47 -16
  60. data/lib/phronomy/tool/scope_policy.rb +50 -0
  61. data/lib/phronomy/tool_executor.rb +106 -0
  62. data/lib/phronomy/tracing/open_telemetry_tracer.rb +34 -0
  63. data/lib/phronomy/vector_store/async_backend.rb +110 -0
  64. data/lib/phronomy/vector_store/base.rb +7 -0
  65. data/lib/phronomy/version.rb +1 -1
  66. data/lib/phronomy/workflow.rb +52 -5
  67. data/lib/phronomy/workflow_context.rb +29 -2
  68. data/lib/phronomy/workflow_runner.rb +74 -3
  69. data/lib/phronomy.rb +42 -0
  70. metadata +40 -2
@@ -0,0 +1,435 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ # A bounded, observable thread pool for blocking I/O operations.
5
+ #
6
+ # ## Architectural boundary
7
+ #
8
+ # `BlockingAdapterPool` is the *only* place in Phronomy that uses raw OS threads
9
+ # for I/O. All third-party gem calls whose internal I/O Phronomy cannot control
10
+ # — including RubyLLM, ActiveRecord, Redis, Faraday, and MCP stdio transport —
11
+ # **must** route through this pool (or a named pool obtained via
12
+ # {Runtime#pool}). Custom non-blocking HTTP/selector runtimes are intentionally
13
+ # out of scope; the pool + cooperative scheduler combination satisfies all
14
+ # current concurrency requirements without that complexity. (See ADR-010.)
15
+ #
16
+ # All blocking calls (LLM HTTP, MCP stdio, ActiveRecord, Redis, etc.) must be
17
+ # submitted through this pool so that:
18
+ #
19
+ # 1. The total number of OS threads is capped.
20
+ # 2. Queue depth is bounded (backpressure when the pool is saturated).
21
+ # 3. Per-operation timeouts are enforced consistently.
22
+ # 4. Abandoned (timed-out) operations are tracked and logged.
23
+ # 5. Metrics (active count, queue depth, abandoned count, avg wait time) are
24
+ # observable at runtime.
25
+ #
26
+ # @example Submitting a blocking LLM call
27
+ # op = runtime.blocking_io.submit(timeout: 30) { chat.ask(message) }
28
+ # result = op.await # blocks the calling thread until done
29
+ #
30
+ # @example With cancellation
31
+ # token = Phronomy::CancellationToken.timeout_after(60)
32
+ # op = pool.submit(timeout: 30, cancellation_token: token) { expensive_call }
33
+ # result = op.await
34
+ class BlockingAdapterPool
35
+ # Represents the pending result of a submitted blocking operation.
36
+ # Returned immediately by {BlockingAdapterPool#submit}; call {#await} to
37
+ # wait for the result.
38
+ class PendingOperation
39
+ # @return [Boolean] true when the operation has finished (success or error)
40
+ # @api private
41
+ def done?
42
+ @mutex.synchronize { @done }
43
+ end
44
+
45
+ # @return [Boolean] true when the operation was abandoned due to timeout
46
+ # @api private
47
+ def abandoned?
48
+ @abandoned
49
+ end
50
+
51
+ # @return [Float] seconds spent in the queue before execution started
52
+ # @api private
53
+ def wait_time
54
+ @wait_time || 0.0
55
+ end
56
+
57
+ # Blocks until the operation completes and returns its value.
58
+ #
59
+ # An optional +timeout+ (in seconds) may be passed here; it is measured
60
+ # from the moment +await+ is called. If both a submit-time timeout and an
61
+ # await-time timeout are present, the earlier deadline wins. The worker
62
+ # thread is NOT interrupted — it runs to completion on its own.
63
+ #
64
+ # An optional +cancellation_token+ may be passed here (or at submit time).
65
+ # If the token is cancelled while waiting, {Phronomy::CancellationError} is
66
+ # raised immediately without interrupting the worker.
67
+ #
68
+ # **Cooperative path (`:fiber` / `DeterministicScheduler`):**
69
+ # When called from a Fiber managed by {DeterministicScheduler} (i.e. under
70
+ # the +:fiber+ runtime backend), the calling Fiber suspends cooperatively
71
+ # via +Fiber.yield+ rather than blocking the OS thread. The Fiber is
72
+ # resumed on the scheduler's ready queue once the worker thread completes
73
+ # the operation.
74
+ #
75
+ # @note **Cooperative cancellation semantics** (ADR-010):
76
+ # Phronomy uses a non-preemptive, cooperative-first concurrency model.
77
+ # Cancellation is *cooperative*, not preemptive:
78
+ # - When a +cancellation_token+ is cancelled, +CancellationError+ is
79
+ # raised to the +await+ caller immediately; when the timeout fires,
80
+ # +TimeoutError+ is raised instead. In both cases, the underlying
81
+ # worker thread is **not** forcibly stopped.
82
+ # - The worker thread will complete its submitted block naturally.
83
+ # Code inside the block must call +token.check!+ at suitable
84
+ # checkpoints to observe the cancelled state and exit early.
85
+ # - There is no +Thread#kill+ or +Thread#raise+ involved. The framework
86
+ # never forcibly terminates worker threads.
87
+ #
88
+ # @note **Cooperative timeout limitation**: the +timeout:+ parameter passed
89
+ # to +await+ is *not* enforced on the cooperative path. The calling Fiber
90
+ # remains suspended until the worker thread finishes regardless of how many
91
+ # seconds elapse. This is because the cooperative scheduler cannot
92
+ # preempt a running OS thread. If a time bound is required, set
93
+ # +timeout:+ at {BlockingAdapterPool#submit submit} time instead; the pool
94
+ # will then abandon the operation on the worker side and mark it as
95
+ # {#abandoned?}.
96
+ #
97
+ # @param timeout [Numeric, nil] seconds from now before raising TimeoutError
98
+ # (thread path only; ignored on the cooperative/fiber path)
99
+ # @param cancellation_token [CancellationToken, nil]
100
+ # @return [Object]
101
+ # @raise [Phronomy::TimeoutError]
102
+ # @raise [Phronomy::CancellationError]
103
+ # @raise [Exception] error raised inside the submitted block
104
+ # @api private
105
+ def await(timeout: nil, cancellation_token: nil)
106
+ effective_timeout = [timeout, @timeout].compact.min
107
+ effective_token = cancellation_token || @cancellation_token
108
+
109
+ raise CancellationError, "blocking operation cancelled" if effective_token&.cancelled?
110
+
111
+ # Cooperative context: suspend the calling Fiber rather than blocking
112
+ # the OS thread so that DeterministicScheduler can continue dispatching
113
+ # other tasks while waiting for the blocking worker to finish.
114
+ # (Issue #338, ADR-010 Rule 3)
115
+ # Uses the same thread-local key as Task::FiberBackend::SCHEDULER_KEY
116
+ # (:phronomy_deterministic_scheduler) to avoid a cross-file constant
117
+ # dependency at load time.
118
+ scheduler = Thread.current.thread_variable_get(:phronomy_deterministic_scheduler)
119
+ in_managed_fiber = !Fiber.respond_to?(:main) || Fiber.current != Fiber.main
120
+ if scheduler && in_managed_fiber
121
+ unless @done
122
+ # Register this await with the scheduler so run_until_idle knows
123
+ # not to exit until the worker thread completes (Issue #338).
124
+ scheduler.track_blocking_await
125
+ waiting_fiber = Fiber.current
126
+ on_complete do |_result, _error|
127
+ # Decrement the counter and wake run_until_idle, then re-enqueue
128
+ # the suspended Fiber for cooperative resumption.
129
+ scheduler.complete_blocking_await
130
+ scheduler.enqueue_fiber(-> { waiting_fiber.resume })
131
+ end
132
+ Fiber.yield(:cooperative_suspend)
133
+ end
134
+ raise CancellationError, "blocking operation cancelled" if effective_token&.cancelled?
135
+ raise @error if @error
136
+
137
+ return @value
138
+ end
139
+
140
+ # Wake up the waiting thread whenever the token is cancelled so we can
141
+ # propagate cancellation without sleeping until the timeout expires.
142
+ effective_token&.on_cancel { @mutex.synchronize { @cond.broadcast } }
143
+
144
+ if effective_timeout
145
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + effective_timeout
146
+ @mutex.synchronize do
147
+ until @done
148
+ raise CancellationError, "blocking operation cancelled" if effective_token&.cancelled?
149
+
150
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
151
+ if remaining <= 0
152
+ # Guard against double-counting when await is called multiple times.
153
+ unless @abandoned
154
+ @abandoned = true
155
+ @on_abandoned&.call
156
+ end
157
+ raise Phronomy::TimeoutError, "blocking operation timed out after #{effective_timeout}s"
158
+ end
159
+ @cond.wait(@mutex, remaining)
160
+ end
161
+ end
162
+ else
163
+ @mutex.synchronize do
164
+ until @done
165
+ raise CancellationError, "blocking operation cancelled" if effective_token&.cancelled?
166
+
167
+ @cond.wait(@mutex)
168
+ end
169
+ end
170
+ end
171
+ raise @error if @error
172
+
173
+ @value
174
+ end
175
+
176
+ # Registers a callback to be called when the operation finishes.
177
+ # If the operation has already finished the callback is invoked immediately
178
+ # on the calling thread. Otherwise it is invoked on the worker thread that
179
+ # completes the operation.
180
+ #
181
+ # The callback receives +result+ and +error+ (one of them will be +nil+).
182
+ #
183
+ # @yield [result, error]
184
+ # @return [self]
185
+ # @api private
186
+ def on_complete(&callback)
187
+ fire_args = nil
188
+ @mutex.synchronize do
189
+ if @done
190
+ fire_args = [@value, @error]
191
+ else
192
+ @callbacks ||= []
193
+ @callbacks << callback
194
+ end
195
+ end
196
+ callback.call(*fire_args) if fire_args
197
+ self
198
+ end
199
+
200
+ # @api private
201
+ def initialize(block, timeout: nil, cancellation_token: nil, on_abandoned: nil)
202
+ @block = block
203
+ @timeout = timeout
204
+ @cancellation_token = cancellation_token
205
+ @on_abandoned = on_abandoned
206
+ @value = nil
207
+ @error = nil
208
+ @done = false
209
+ @abandoned = false
210
+ @wait_time = nil
211
+ @submitted_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
212
+ @mutex = Mutex.new
213
+ @cond = ConditionVariable.new
214
+ end
215
+
216
+ # @api private
217
+ def execute!
218
+ @wait_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @submitted_at
219
+
220
+ if @cancellation_token&.cancelled?
221
+ complete_with_error!(CancellationError.new("operation cancelled before execution"))
222
+ return
223
+ end
224
+
225
+ # Do NOT use Timeout.timeout here — it delivers an async Thread#raise
226
+ # that can corrupt external library state (mutexes, C extensions, etc.).
227
+ # Timeout enforcement is handled cooperatively in #await instead.
228
+ # Each blocking library (Net::HTTP, pg, redis, etc.) should set its
229
+ # own native connection/read timeouts.
230
+ begin
231
+ complete_with_value!(@block.call)
232
+ rescue => e
233
+ complete_with_error!(e)
234
+ end
235
+ end
236
+
237
+ private
238
+
239
+ def complete_with_value!(value)
240
+ cbs = nil
241
+ @mutex.synchronize do
242
+ @value = value
243
+ @done = true
244
+ @cond.broadcast
245
+ cbs = @callbacks
246
+ @callbacks = nil
247
+ end
248
+ cbs&.each { |cb| cb.call(value, nil) }
249
+ end
250
+
251
+ def complete_with_error!(error)
252
+ cbs = nil
253
+ @mutex.synchronize do
254
+ @error = error
255
+ @done = true
256
+ @cond.broadcast
257
+ cbs = @callbacks
258
+ @callbacks = nil
259
+ end
260
+ cbs&.each { |cb| cb.call(nil, error) }
261
+ end
262
+ end
263
+
264
+ # @param pool_size [Integer] maximum number of worker threads
265
+ # @param queue_size [Integer] maximum pending operations waiting for a worker
266
+ # @param name [String, Symbol, nil] optional pool name used in thread labels
267
+ # @param logger [Logger, nil] optional logger for warnings
268
+ # @api private
269
+ def initialize(pool_size: 10, queue_size: 100, name: nil, logger: nil)
270
+ @pool_size = pool_size
271
+ @queue_size = queue_size
272
+ @name = name
273
+ @logger = logger
274
+ @queue = SizedQueue.new(queue_size)
275
+ @active_count = 0
276
+ @abandoned_count = 0
277
+ @total_wait_ns = 0
278
+ @completed_count = 0
279
+ @mutex = Mutex.new
280
+ @shutdown = false
281
+ @workers = Array.new(pool_size) { |i| spawn_worker(i) }
282
+ end
283
+
284
+ # Submits a blocking operation to the pool.
285
+ # Returns a {PendingOperation} immediately; the block runs on a worker thread.
286
+ #
287
+ # @note **Cooperative callers**: if you are running under the `:fiber` backend
288
+ # (i.e. inside a {DeterministicScheduler} Fiber), set +timeout:+ here
289
+ # rather than on {PendingOperation#await}. The await-time timeout is not
290
+ # enforced on the cooperative path (the Fiber cannot preempt a running
291
+ # worker thread). A submit-time timeout triggers on the worker side and
292
+ # marks the operation {PendingOperation#abandoned? abandoned}, which
293
+ # unblocks the waiting Fiber via the normal on-complete callback.
294
+ # @param timeout [Numeric, nil] seconds before the operation is abandoned
295
+ # @param cancellation_token [CancellationToken, nil]
296
+ # @yield block containing the blocking call
297
+ # @return [PendingOperation]
298
+ # @raise [Phronomy::PoolShutdownError] when the pool has been shut down
299
+ # @raise [Phronomy::BackpressureError] when +on_full: :raise+ and queue is full
300
+ # @raise [Phronomy::TimeoutError] when +on_full: :timeout+ and wait exceeds +full_timeout+
301
+ # @api private
302
+ def submit(timeout: nil, cancellation_token: nil, on_full: :wait, full_timeout: nil, &block)
303
+ raise Phronomy::PoolShutdownError, "pool has been shut down" if @shutdown
304
+
305
+ op = PendingOperation.new(block, timeout: timeout, cancellation_token: cancellation_token,
306
+ on_abandoned: timeout ? -> { @mutex.synchronize { @abandoned_count += 1 } } : nil)
307
+ begin
308
+ case on_full
309
+ when :raise
310
+ begin
311
+ @queue.push(op, true)
312
+ rescue ThreadError
313
+ raise Phronomy::BackpressureError, "BlockingAdapterPool queue is full (depth: #{@queue_size})"
314
+ end
315
+ when :timeout
316
+ deadline = full_timeout ? (Process.clock_gettime(Process::CLOCK_MONOTONIC) + full_timeout) : nil
317
+ loop do
318
+ @queue.push(op, true)
319
+ break
320
+ rescue ThreadError
321
+ if deadline && Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
322
+ raise Phronomy::TimeoutError, "timed out waiting for a free slot in BlockingAdapterPool"
323
+ end
324
+ sleep(0.005)
325
+ end
326
+ else # :wait (default)
327
+ @queue.push(op)
328
+ end
329
+ rescue ClosedQueueError
330
+ # Shutdown raced with this submit — treat as if @shutdown was already set.
331
+ raise Phronomy::PoolShutdownError, "pool has been shut down"
332
+ end
333
+ op
334
+ end
335
+
336
+ # Gracefully drains the pool and terminates all worker threads.
337
+ # Waits up to +drain_timeout+ seconds for in-flight operations to finish.
338
+ #
339
+ # Closing the underlying SizedQueue signals workers to exit after draining
340
+ # remaining items, without blocking on a full-queue push.
341
+ #
342
+ # @param drain_timeout [Numeric] seconds to wait for workers to finish
343
+ # @return [self]
344
+ # @api private
345
+ def shutdown(drain_timeout: 30)
346
+ @shutdown = true
347
+ @queue.close
348
+ @workers.each { |t| t.join(drain_timeout) }
349
+ self
350
+ end
351
+
352
+ # --- Metrics ----------------------------------------------------------
353
+
354
+ # @return [Integer] number of operations currently executing on workers
355
+ # @api private
356
+ def active_count
357
+ @mutex.synchronize { @active_count }
358
+ end
359
+
360
+ # @return [Integer] number of operations waiting in the queue
361
+ # @api private
362
+ def queue_depth
363
+ @queue.size
364
+ end
365
+
366
+ # @return [Integer] number of operations that were abandoned due to timeout
367
+ # @api private
368
+ def abandoned_count
369
+ @mutex.synchronize { @abandoned_count }
370
+ end
371
+
372
+ # Average time (in seconds) that completed operations spent in the queue
373
+ # waiting for a worker. Returns 0.0 when no operations have completed yet.
374
+ # @return [Float]
375
+ # @api private
376
+ def average_wait_seconds
377
+ @mutex.synchronize do
378
+ return 0.0 if @completed_count.zero?
379
+
380
+ @total_wait_ns / @completed_count.to_f / 1_000_000_000.0
381
+ end
382
+ end
383
+
384
+ # @return [Integer] configured maximum number of worker threads
385
+ attr_reader :pool_size
386
+
387
+ # @return [Integer] configured maximum queue depth
388
+ attr_reader :queue_size
389
+
390
+ # @return [String, Symbol, nil] pool name used in thread labels
391
+ attr_reader :name
392
+
393
+ private
394
+
395
+ SENTINEL = :shutdown
396
+ private_constant :SENTINEL
397
+
398
+ def spawn_worker(index = nil)
399
+ label = ["phronomy", "blocking-pool", @name, index].compact.join("-")
400
+ Thread.new do
401
+ Thread.current.name = label
402
+ loop do
403
+ op = begin
404
+ @queue.pop
405
+ rescue ClosedQueueError
406
+ break
407
+ end
408
+ # nil is returned by a closed, empty Queue on some Ruby versions
409
+ break if op.nil? || op == SENTINEL
410
+
411
+ run_operation(op)
412
+ end
413
+ end
414
+ end
415
+
416
+ def run_operation(op)
417
+ @mutex.synchronize { @active_count += 1 }
418
+
419
+ begin
420
+ op.execute!
421
+ ensure
422
+ @mutex.synchronize do
423
+ @active_count -= 1
424
+
425
+ if op.abandoned?
426
+ @logger&.warn { "BlockingAdapterPool: worker finished operation after caller timed out" }
427
+ end
428
+
429
+ @total_wait_ns += (op.wait_time * 1_000_000_000).to_i
430
+ @completed_count += 1
431
+ end
432
+ end
433
+ end
434
+ end
435
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ # Represents a bounded execution scope that owns a {CancellationToken} and
5
+ # optionally a {Deadline}.
6
+ #
7
+ # +CancellationScope+ replaces ad-hoc +Timeout.timeout+ calls in agent and
8
+ # tool code. All work performed within a scope should observe the scope's
9
+ # token; when the scope is cancelled (explicitly or by deadline expiry) the
10
+ # token is cancelled and all child tasks that check it will stop.
11
+ #
12
+ # @example Time-bounded invocation
13
+ # scope = Phronomy::CancellationScope.new.deadline_in(30)
14
+ # result = scope.pop_queue(completion_queue) do
15
+ # raise Phronomy::TimeoutError, "timed out"
16
+ # end
17
+ #
18
+ # @example Explicit cancellation
19
+ # scope = Phronomy::CancellationScope.new
20
+ # Phronomy::Runtime.instance.spawn(name: "worker") do
21
+ # scope.token.raise_if_cancelled!
22
+ # # ... do work ...
23
+ # end
24
+ # scope.cancel! if some_condition
25
+ class CancellationScope
26
+ # @return [CancellationToken] the token owned by this scope
27
+ attr_reader :token
28
+
29
+ # @return [Deadline, nil] the deadline attached to this scope, if any
30
+ attr_reader :deadline
31
+
32
+ # @param parent_token [CancellationToken, nil] when provided, cancellation of
33
+ # the parent token is propagated to this scope's token via a callback
34
+ # (for explicit cancel) and/or the Runtime timer queue (for monotonic
35
+ # deadline expiry). No polling thread is spawned.
36
+ # @api private
37
+ def initialize(parent_token: nil)
38
+ @token = Phronomy::CancellationToken.new
39
+ @deadline = nil
40
+
41
+ if parent_token
42
+ # Propagate explicit cancel() from parent to child via callback.
43
+ parent_token.on_cancel { @token.cancel! }
44
+
45
+ # Propagate monotonic-deadline expiry from parent to child via the
46
+ # timer queue (avoids a polling thread).
47
+ remaining = parent_token.remaining_monotonic_seconds
48
+ if !remaining.nil?
49
+ if remaining <= 0
50
+ @token.cancel!
51
+ else
52
+ Phronomy::Runtime.instance.timer_queue.schedule(seconds: remaining) do
53
+ @token.cancel!
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+
60
+ # Attaches a deadline that will cancel this scope after +seconds+.
61
+ #
62
+ # @param seconds [Numeric] timeout duration
63
+ # @return [self]
64
+ # @api private
65
+ def deadline_in(seconds)
66
+ @deadline = Phronomy::Deadline.in(seconds)
67
+ @deadline.attach_to(@token)
68
+ self
69
+ end
70
+
71
+ # Cancels this scope immediately.
72
+ # @return [void]
73
+ # @api private
74
+ def cancel!
75
+ @token.cancel!
76
+ end
77
+
78
+ # Returns +true+ if this scope has been cancelled.
79
+ # @return [Boolean]
80
+ # @api private
81
+ def cancelled?
82
+ @token.cancelled?
83
+ end
84
+
85
+ # Returns the remaining time in seconds before the deadline expires,
86
+ # or +nil+ when no deadline is set.
87
+ # @return [Float, nil]
88
+ # @api private
89
+ def remaining_seconds
90
+ @deadline&.remaining_seconds
91
+ end
92
+
93
+ # Pops from +queue+ with a timeout derived from the attached deadline (or
94
+ # +fallback_timeout+ seconds when no deadline is set). If the pop times out,
95
+ # the scope is cancelled and the block is called (or a {TimeoutError} raised).
96
+ #
97
+ # @param queue [Phronomy::AsyncQueue] the queue to pop from
98
+ # @param fallback_timeout [Numeric, nil] used when no deadline is attached
99
+ # @yield called when the operation times out
100
+ # @raise [Phronomy::TimeoutError] when no block is given and a timeout occurs
101
+ # @return [Object] the popped value
102
+ # @api private
103
+ def pop_queue(queue, fallback_timeout: nil)
104
+ timeout = @deadline&.remaining_seconds || fallback_timeout
105
+ result = if timeout
106
+ queue.pop(timeout: timeout)
107
+ else
108
+ queue.pop
109
+ end
110
+
111
+ if result.nil?
112
+ cancel!
113
+ if block_given?
114
+ yield
115
+ else
116
+ raise Phronomy::TimeoutError, "CancellationScope timed out"
117
+ end
118
+ end
119
+
120
+ result
121
+ end
122
+ end
123
+ end
@@ -52,16 +52,57 @@ module Phronomy
52
52
  @deadline = deadline
53
53
  @monotonic_deadline = monotonic_deadline
54
54
  @mutex = Mutex.new
55
+ @cancel_callbacks = []
55
56
  end
56
57
 
57
58
  # @return [Time, nil] the wall-clock deadline passed to {#initialize}, or +nil+.
58
59
  attr_reader :deadline
59
60
 
60
- # Mark the token as cancelled. Thread-safe; may be called from any thread.
61
+ # Returns the remaining seconds until the monotonic deadline fires, or +nil+
62
+ # when no monotonic deadline is set. Returns 0.0 if already past.
63
+ # @return [Float, nil]
64
+ # @api public
65
+ def remaining_monotonic_seconds
66
+ return nil if @monotonic_deadline.nil?
67
+ remaining = @monotonic_deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
68
+ [remaining, 0.0].max
69
+ end
70
+
71
+ # Registers a one-shot callback invoked when this token is explicitly
72
+ # cancelled via {#cancel!}. If the token is already cancelled, the block
73
+ # is called immediately (still within the caller's thread).
74
+ #
75
+ # Callbacks are NOT fired for deadline-based cancellation (i.e. when
76
+ # {#cancelled?} returns +true+ due to +@monotonic_deadline+ expiry). Use
77
+ # {Runtime#timer_queue} to schedule deadline callbacks.
78
+ #
79
+ # @yield called with no arguments when (or if) the token is cancelled
80
+ # @return [self]
81
+ # @api public
82
+ def on_cancel(&block)
83
+ already_cancelled = @mutex.synchronize do
84
+ if @cancelled
85
+ true
86
+ else
87
+ @cancel_callbacks << block
88
+ false
89
+ end
90
+ end
91
+ block.call if already_cancelled
92
+ self
93
+ end
94
+
95
+ # Mark the token as cancelled and fire any registered {#on_cancel} callbacks.
96
+ # Thread-safe; idempotent — calling multiple times has no additional effect.
61
97
  # @return [self]
62
98
  # @api public
63
99
  def cancel!
64
- @mutex.synchronize { @cancelled = true }
100
+ callbacks = @mutex.synchronize do
101
+ return self if @cancelled
102
+ @cancelled = true
103
+ @cancel_callbacks.dup
104
+ end
105
+ callbacks.each(&:call)
65
106
  self
66
107
  end
67
108