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.
- checksums.yaml +4 -4
- data/.mutant.yml +8 -7
- data/CHANGELOG.md +151 -1
- data/README.md +155 -32
- data/Rakefile +33 -0
- data/benchmark/baseline.json +1 -1
- data/benchmark/bench_regression.rb +1 -0
- data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +24 -0
- data/docs/decisions/006-no-built-in-guardrails.md +20 -2
- data/docs/decisions/010-cooperative-first-concurrency.md +248 -0
- data/lib/phronomy/agent/base.rb +250 -65
- data/lib/phronomy/agent/concerns/suspendable.rb +15 -0
- data/lib/phronomy/agent/fsm.rb +41 -64
- data/lib/phronomy/agent/orchestrator.rb +146 -121
- data/lib/phronomy/agent/parallel_tool_chat.rb +79 -22
- data/lib/phronomy/agent/react_agent.rb +8 -0
- data/lib/phronomy/async_queue.rb +155 -0
- data/lib/phronomy/blocking_adapter_pool.rb +435 -0
- data/lib/phronomy/cancellation_scope.rb +123 -0
- data/lib/phronomy/cancellation_token.rb +43 -2
- data/lib/phronomy/concurrency_gate.rb +155 -0
- data/lib/phronomy/configuration.rb +142 -0
- data/lib/phronomy/deadline.rb +63 -0
- data/lib/phronomy/diagnostics.rb +62 -0
- data/lib/phronomy/embeddings/base.rb +17 -0
- data/lib/phronomy/eval/runner.rb +9 -9
- data/lib/phronomy/event_loop.rb +181 -43
- data/lib/phronomy/fsm_session.rb +50 -4
- data/lib/phronomy/guardrail/prompt_injection_guardrail.rb +58 -0
- data/lib/phronomy/invocation_context.rb +152 -0
- data/lib/phronomy/knowledge_source/base.rb +18 -0
- data/lib/phronomy/llm_adapter/base.rb +104 -0
- data/lib/phronomy/llm_adapter/ruby_llm.rb +41 -0
- data/lib/phronomy/llm_adapter.rb +20 -0
- data/lib/phronomy/metrics.rb +38 -0
- data/lib/phronomy/runtime/deterministic_scheduler.rb +412 -0
- data/lib/phronomy/runtime/fake_scheduler.rb +165 -0
- data/lib/phronomy/runtime/gate_registry.rb +52 -0
- data/lib/phronomy/runtime/pool_registry.rb +57 -0
- data/lib/phronomy/runtime/runtime_metrics.rb +117 -0
- data/lib/phronomy/runtime/scheduler.rb +98 -0
- data/lib/phronomy/runtime/scheduler_timer_adapter.rb +79 -0
- data/lib/phronomy/runtime/task_registry.rb +48 -0
- data/lib/phronomy/runtime/thread_scheduler.rb +30 -0
- data/lib/phronomy/runtime/timer_queue.rb +106 -0
- data/lib/phronomy/runtime/timer_service.rb +42 -0
- data/lib/phronomy/runtime.rb +374 -0
- data/lib/phronomy/task/backend.rb +80 -0
- data/lib/phronomy/task/fiber_backend.rb +157 -0
- data/lib/phronomy/task/immediate_backend.rb +89 -0
- data/lib/phronomy/task/thread_backend.rb +84 -0
- data/lib/phronomy/task.rb +275 -0
- data/lib/phronomy/task_group.rb +265 -0
- data/lib/phronomy/testing/fake_clock.rb +109 -0
- data/lib/phronomy/testing/fake_scheduler.rb +104 -0
- data/lib/phronomy/testing/scheduler_helpers.rb +59 -0
- data/lib/phronomy/testing.rb +12 -0
- data/lib/phronomy/tool/base.rb +110 -2
- data/lib/phronomy/tool/mcp_tool.rb +47 -16
- data/lib/phronomy/tool/scope_policy.rb +50 -0
- data/lib/phronomy/tool_executor.rb +106 -0
- data/lib/phronomy/tracing/open_telemetry_tracer.rb +34 -0
- data/lib/phronomy/vector_store/async_backend.rb +110 -0
- data/lib/phronomy/vector_store/base.rb +7 -0
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +52 -5
- data/lib/phronomy/workflow_context.rb +29 -2
- data/lib/phronomy/workflow_runner.rb +74 -3
- data/lib/phronomy.rb +42 -0
- metadata +40 -2
|
@@ -20,6 +20,24 @@ module Phronomy
|
|
|
20
20
|
raise NotImplementedError, "#{self.class}#fetch is not implemented"
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
+
# Submits a {#fetch} call to {BlockingAdapterPool} and returns a
|
|
24
|
+
# {BlockingAdapterPool::PendingOperation}.
|
|
25
|
+
# Callers can fan out multiple fetches in parallel and await them all.
|
|
26
|
+
#
|
|
27
|
+
# @param query [String, nil]
|
|
28
|
+
# @param cancellation_token [Phronomy::CancellationToken, nil]
|
|
29
|
+
# @param timeout [Numeric, nil] seconds before the operation is abandoned
|
|
30
|
+
# @return [BlockingAdapterPool::PendingOperation]
|
|
31
|
+
# @api public
|
|
32
|
+
def fetch_async(query: nil, cancellation_token: nil, timeout: nil)
|
|
33
|
+
Phronomy::Runtime.instance.blocking_io.submit(
|
|
34
|
+
timeout: timeout,
|
|
35
|
+
cancellation_token: cancellation_token
|
|
36
|
+
) do
|
|
37
|
+
fetch(query: query, cancellation_token: cancellation_token)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
23
41
|
# Returns true when this source's content is considered static (i.e. does
|
|
24
42
|
# not change between agent invocations). Static sources are eligible for
|
|
25
43
|
# fingerprint-based caching in ContextVersionCache.
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module LLMAdapter
|
|
5
|
+
# Abstract base class for LLM adapters.
|
|
6
|
+
#
|
|
7
|
+
# Subclasses must implement {#complete} and {#stream}.
|
|
8
|
+
# The agent pipeline calls {#complete_async} / {#stream_async} which wrap
|
|
9
|
+
# those methods in a {BlockingAdapterPool} submission.
|
|
10
|
+
class Base
|
|
11
|
+
# Performs a blocking (non-streaming) LLM completion.
|
|
12
|
+
# Implementors must call +chat.ask(message)+ (or equivalent) and
|
|
13
|
+
# return the response object.
|
|
14
|
+
#
|
|
15
|
+
# @param chat [Object] the configured chat session object
|
|
16
|
+
# @param message [String] the user message
|
|
17
|
+
# @param config [Hash] the invocation config (e.g. +:cancellation_token+)
|
|
18
|
+
# @return [Object] LLM response object
|
|
19
|
+
# @raise [NotImplementedError]
|
|
20
|
+
# @api private
|
|
21
|
+
def complete(chat, message, config: {})
|
|
22
|
+
raise NotImplementedError, "#{self.class}#complete is not implemented"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Performs a blocking streaming LLM completion.
|
|
26
|
+
# Implementors must call +chat.ask(message) { |chunk| block.call(chunk) }+
|
|
27
|
+
# (or equivalent) and return the response object.
|
|
28
|
+
#
|
|
29
|
+
# @param chat [Object] the configured chat session object
|
|
30
|
+
# @param message [String] the user message
|
|
31
|
+
# @param config [Hash] the invocation config
|
|
32
|
+
# @yield [chunk] streaming chunk from the LLM
|
|
33
|
+
# @return [Object] LLM response object
|
|
34
|
+
# @raise [NotImplementedError]
|
|
35
|
+
# @api private
|
|
36
|
+
def stream(chat, message, config: {}, &block)
|
|
37
|
+
raise NotImplementedError, "#{self.class}#stream is not implemented"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Submits a non-streaming LLM call to {BlockingAdapterPool} and returns
|
|
41
|
+
# a {BlockingAdapterPool::PendingOperation}.
|
|
42
|
+
#
|
|
43
|
+
# @param chat [Object] configured chat session
|
|
44
|
+
# @param message [String] user message
|
|
45
|
+
# @param config [Hash] invocation config
|
|
46
|
+
# @param pool [BlockingAdapterPool] pool to submit to
|
|
47
|
+
# @return [BlockingAdapterPool::PendingOperation]
|
|
48
|
+
# @api private
|
|
49
|
+
def complete_async(chat, message, config: {}, pool: default_pool)
|
|
50
|
+
token = config[:cancellation_token]
|
|
51
|
+
timeout = config[:llm_timeout]
|
|
52
|
+
pool.submit(timeout: timeout, cancellation_token: token) do
|
|
53
|
+
complete(chat, message, config: config)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Submits a streaming LLM call to {BlockingAdapterPool} and returns
|
|
58
|
+
# a {BlockingAdapterPool::PendingOperation}.
|
|
59
|
+
#
|
|
60
|
+
# When +enqueue_to:+ is given, streaming chunks are pushed into that
|
|
61
|
+
# {AsyncQueue} from the worker thread instead of being passed directly
|
|
62
|
+
# to the caller's block. The queue is closed (via +ensure+) after the
|
|
63
|
+
# LLM call finishes so the consumer's drain loop terminates naturally.
|
|
64
|
+
# This keeps user-supplied blocks off the blocking-pool worker thread.
|
|
65
|
+
#
|
|
66
|
+
# When +enqueue_to:+ is nil and a block is given, the block is invoked
|
|
67
|
+
# directly from the worker thread (legacy behaviour, preserved for
|
|
68
|
+
# backward compatibility).
|
|
69
|
+
#
|
|
70
|
+
# @param chat [Object] configured chat session
|
|
71
|
+
# @param message [String] user message
|
|
72
|
+
# @param config [Hash] invocation config
|
|
73
|
+
# @param pool [BlockingAdapterPool] pool to submit to
|
|
74
|
+
# @param enqueue_to [AsyncQueue, nil] when set, push chunks here instead of
|
|
75
|
+
# calling the block on the worker thread
|
|
76
|
+
# @yield [chunk] streaming chunk — only used when +enqueue_to:+ is nil
|
|
77
|
+
# @return [BlockingAdapterPool::PendingOperation]
|
|
78
|
+
# @api private
|
|
79
|
+
def stream_async(chat, message, config: {}, pool: default_pool, enqueue_to: nil, &block)
|
|
80
|
+
token = config[:cancellation_token]
|
|
81
|
+
timeout = config[:llm_timeout]
|
|
82
|
+
if enqueue_to
|
|
83
|
+
pool.submit(timeout: timeout, cancellation_token: token) do
|
|
84
|
+
stream(chat, message, config: config) do |chunk|
|
|
85
|
+
enqueue_to.push(chunk)
|
|
86
|
+
end
|
|
87
|
+
ensure
|
|
88
|
+
enqueue_to.close
|
|
89
|
+
end
|
|
90
|
+
else
|
|
91
|
+
pool.submit(timeout: timeout, cancellation_token: token) do
|
|
92
|
+
stream(chat, message, config: config, &block)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def default_pool
|
|
100
|
+
Phronomy::Runtime.instance.blocking_io
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module LLMAdapter
|
|
5
|
+
# LLM adapter that delegates to the RubyLLM blocking client.
|
|
6
|
+
#
|
|
7
|
+
# This is the default adapter used by Phronomy agents. It wraps
|
|
8
|
+
# +chat.ask+ (and its streaming variant) so that the blocking HTTP
|
|
9
|
+
# call runs inside {BlockingAdapterPool} rather than on the EventLoop
|
|
10
|
+
# thread or the caller's thread directly.
|
|
11
|
+
#
|
|
12
|
+
# @example Explicitly configuring this adapter
|
|
13
|
+
# Phronomy.configure do |c|
|
|
14
|
+
# c.llm_adapter = Phronomy::LLMAdapter::RubyLLM.new
|
|
15
|
+
# end
|
|
16
|
+
class RubyLLM < Base
|
|
17
|
+
# Delegates to +chat.ask(message)+.
|
|
18
|
+
#
|
|
19
|
+
# @param chat [Object] RubyLLM chat session
|
|
20
|
+
# @param message [String] user message
|
|
21
|
+
# @param config [Hash] invocation config (not used directly by this impl)
|
|
22
|
+
# @return [Object] RubyLLM response
|
|
23
|
+
# @api private
|
|
24
|
+
def complete(chat, message, config: {})
|
|
25
|
+
chat.ask(message)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Delegates to +chat.ask(message) { |chunk| block.call(chunk) }+.
|
|
29
|
+
#
|
|
30
|
+
# @param chat [Object] RubyLLM chat session
|
|
31
|
+
# @param message [String] user message
|
|
32
|
+
# @param config [Hash] invocation config
|
|
33
|
+
# @yield [chunk] streaming chunk forwarded from +chat.ask+
|
|
34
|
+
# @return [Object] RubyLLM response
|
|
35
|
+
# @api private
|
|
36
|
+
def stream(chat, message, config: {}, &block)
|
|
37
|
+
chat.ask(message, &block)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
# Namespace for LLM adapter implementations.
|
|
5
|
+
#
|
|
6
|
+
# An LLMAdapter decouples Phronomy's agent pipeline from direct
|
|
7
|
+
# dependency on the RubyLLM blocking client. All LLM calls in
|
|
8
|
+
# {Agent::Base} are routed through the adapter so that:
|
|
9
|
+
#
|
|
10
|
+
# - Blocking HTTP can be submitted to {BlockingAdapterPool} for bounded
|
|
11
|
+
# concurrency and per-operation timeouts.
|
|
12
|
+
# - Alternative LLM clients can be swapped in without changing agent code.
|
|
13
|
+
#
|
|
14
|
+
# @example Configuring a custom adapter
|
|
15
|
+
# Phronomy.configure do |c|
|
|
16
|
+
# c.llm_adapter = MyCustomAdapter.new
|
|
17
|
+
# end
|
|
18
|
+
module LLMAdapter
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
# Task-centric observability snapshot (Issue #276, extended in #307).
|
|
5
|
+
#
|
|
6
|
+
# Collects live metrics from the shared Runtime components
|
|
7
|
+
# (BlockingAdapterPool, EventLoop, and Runtime task registry) and returns
|
|
8
|
+
# them as a plain Hash so they can be forwarded to any monitoring backend
|
|
9
|
+
# (Prometheus, OpenTelemetry, StatsD, etc.).
|
|
10
|
+
#
|
|
11
|
+
# All metrics are read at the moment {.snapshot} is called; no
|
|
12
|
+
# persistent state is held here.
|
|
13
|
+
#
|
|
14
|
+
# @example Exporting to a metrics endpoint
|
|
15
|
+
# data = Phronomy::Metrics.snapshot
|
|
16
|
+
# # => { blocking_pool_active: 2, active_agent_tasks: 1, ... }
|
|
17
|
+
module Metrics
|
|
18
|
+
# Returns a Hash of current observability metrics.
|
|
19
|
+
#
|
|
20
|
+
# @return [Hash{Symbol => Numeric}]
|
|
21
|
+
# @api public
|
|
22
|
+
def self.snapshot
|
|
23
|
+
pool = Runtime.instance.blocking_io
|
|
24
|
+
el = EventLoop.instance
|
|
25
|
+
task_snap = Runtime.instance.task_snapshot
|
|
26
|
+
|
|
27
|
+
{
|
|
28
|
+
blocking_pool_active: pool.active_count,
|
|
29
|
+
blocking_pool_queue_length: pool.queue_depth,
|
|
30
|
+
blocking_pool_abandoned_total: pool.abandoned_count,
|
|
31
|
+
blocking_pool_size: pool.pool_size,
|
|
32
|
+
event_loop_lag_last_ms: (el.last_lag_seconds * 1000).round(3),
|
|
33
|
+
event_loop_lag_max_ms: (el.max_lag_seconds * 1000).round(3),
|
|
34
|
+
event_loop_lag_average_ms: (el.average_lag_seconds * 1000).round(3)
|
|
35
|
+
}.merge(task_snap)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
class Runtime
|
|
5
|
+
# Tick-based deterministic cooperative scheduler for testing.
|
|
6
|
+
#
|
|
7
|
+
# Unlike {FakeScheduler} (which runs every task synchronously to completion
|
|
8
|
+
# before +spawn+ returns), +DeterministicScheduler+ pushes each task to a
|
|
9
|
+
# ready queue and only advances execution one step at a time via {#tick}.
|
|
10
|
+
# This makes it possible to test:
|
|
11
|
+
#
|
|
12
|
+
# - Task interleaving (two tasks yielding control back and forth)
|
|
13
|
+
# - Virtual-time timer firing order
|
|
14
|
+
# - +await+ suspension and resumption
|
|
15
|
+
# - Cancellation while a task is suspended
|
|
16
|
+
#
|
|
17
|
+
# @example Basic usage
|
|
18
|
+
# sched = Phronomy::Runtime::DeterministicScheduler.new
|
|
19
|
+
# rt = Phronomy::Runtime.new(scheduler: sched)
|
|
20
|
+
#
|
|
21
|
+
# rt.spawn { Fiber.yield; :done } # not started yet
|
|
22
|
+
# sched.tick # runs until first Fiber.yield
|
|
23
|
+
# sched.tick # runs to completion
|
|
24
|
+
# sched.run_until_idle # same as calling tick until empty
|
|
25
|
+
#
|
|
26
|
+
# @example Virtual clock
|
|
27
|
+
# sched.schedule_after(1.0) { puts "fired at T=1" }
|
|
28
|
+
# sched.advance(1.0) # moves virtual clock forward, fires the timer
|
|
29
|
+
# sched.run_until_idle # dispatches the timer callback
|
|
30
|
+
# EXPERIMENTAL Fiber-based cooperative scheduler.
|
|
31
|
+
#
|
|
32
|
+
# Uses {Task::FiberBackend} to run tasks cooperatively without OS threads.
|
|
33
|
+
# Intended for deterministic testing and, in future, as a production
|
|
34
|
+
# cooperative scheduler. Not recommended for production use.
|
|
35
|
+
#
|
|
36
|
+
# Activated via +runtime_backend: :fiber+ in {Phronomy.configure}.
|
|
37
|
+
# @api private
|
|
38
|
+
class DeterministicScheduler < Scheduler
|
|
39
|
+
# Scheduler-aware signal for cooperative suspension.
|
|
40
|
+
#
|
|
41
|
+
# Used by {ConcurrencyGate} and {TaskGroup} to suspend a Fiber until a
|
|
42
|
+
# slot or condition becomes available, without blocking the OS thread.
|
|
43
|
+
# All methods must be called from within a {DeterministicScheduler} tick.
|
|
44
|
+
# @api private
|
|
45
|
+
class CoopSignal
|
|
46
|
+
def initialize(scheduler)
|
|
47
|
+
@scheduler = scheduler
|
|
48
|
+
@waiters = [] # Array of Fiber
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Suspends the current Fiber until {#notify_one} or {#notify_all} fires.
|
|
52
|
+
# @api private
|
|
53
|
+
# @return [void]
|
|
54
|
+
def wait
|
|
55
|
+
@waiters << Fiber.current
|
|
56
|
+
# Yield with :cooperative_suspend so step_callable knows not to
|
|
57
|
+
# automatically re-enqueue this Fiber — only an explicit notify call
|
|
58
|
+
# should resume it.
|
|
59
|
+
Fiber.yield(:cooperative_suspend)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Wakes up one waiting Fiber.
|
|
63
|
+
# @api private
|
|
64
|
+
# @return [void]
|
|
65
|
+
def notify_one
|
|
66
|
+
waiter = @waiters.shift
|
|
67
|
+
@scheduler.enqueue_fiber(-> { waiter.resume }) if waiter
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Wakes up all waiting Fibers.
|
|
71
|
+
# @api private
|
|
72
|
+
# @return [void]
|
|
73
|
+
def notify_all
|
|
74
|
+
waiters, @waiters = @waiters, []
|
|
75
|
+
waiters.each { |w| @scheduler.enqueue_fiber(-> { w.resume }) }
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @return [Float] current virtual clock time (seconds since scheduler creation)
|
|
80
|
+
attr_reader :virtual_time
|
|
81
|
+
|
|
82
|
+
# @param autorun [Boolean] when +true+, each call to {#spawn} automatically
|
|
83
|
+
# drains the ready queue via {#run_until_idle} before returning the task.
|
|
84
|
+
# This makes +DeterministicScheduler+ behave like {FakeScheduler} (tasks
|
|
85
|
+
# complete synchronously) while still executing them on real Fibers.
|
|
86
|
+
# Used internally by the +:fiber+ runtime backend.
|
|
87
|
+
# @api private
|
|
88
|
+
def initialize(autorun: false)
|
|
89
|
+
@autorun = autorun
|
|
90
|
+
@ready = [] # Array of callables ({ fiber.resume } or timer callbacks)
|
|
91
|
+
@mutex = Mutex.new
|
|
92
|
+
@virtual_time = 0.0
|
|
93
|
+
@timer_heap = [] # Array of { fire_at:, callback: }
|
|
94
|
+
@real_timer_heap = [] # Array of [fire_at_monotonic, callback] for wall-clock timers
|
|
95
|
+
@clock = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
96
|
+
# Tracks Fibers suspended in BlockingAdapterPool#await so that
|
|
97
|
+
# run_until_idle knows to keep looping until worker threads complete.
|
|
98
|
+
# Protected by @await_mutex (separate from @mutex to avoid contention).
|
|
99
|
+
@pending_awaits = 0
|
|
100
|
+
@await_mutex = Mutex.new
|
|
101
|
+
@await_cond = ConditionVariable.new
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Returns +true+ when this scheduler is in autorun mode.
|
|
105
|
+
# @return [Boolean]
|
|
106
|
+
# @api private
|
|
107
|
+
def autorun?
|
|
108
|
+
@autorun
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Spawns a new {Task} backed by {Task::FiberBackend} and enqueues it.
|
|
112
|
+
# The task does NOT start executing until {#tick} is called.
|
|
113
|
+
#
|
|
114
|
+
# @param name [String, nil]
|
|
115
|
+
# @param parent [Task, nil]
|
|
116
|
+
# @return [Task]
|
|
117
|
+
# @api private
|
|
118
|
+
def spawn(name:, parent:, &block)
|
|
119
|
+
task = Task.spawn(name: name, parent: parent, backend_class: Task::FiberBackend, &block)
|
|
120
|
+
backend = task.backend
|
|
121
|
+
# Build a self-rescheduling step: after each step, re-enqueue if the
|
|
122
|
+
# Fiber yielded cooperatively and is still alive.
|
|
123
|
+
step_callable = nil
|
|
124
|
+
step_callable = lambda do
|
|
125
|
+
backend.step
|
|
126
|
+
enqueue_fiber(step_callable) if backend.alive? && !backend.cooperative_suspend?
|
|
127
|
+
end
|
|
128
|
+
enqueue_fiber(step_callable)
|
|
129
|
+
# Auto-run only when called from outside a running scheduler tick.
|
|
130
|
+
# When SCHEDULER_KEY is set, the calling code is already inside a managed
|
|
131
|
+
# Fiber; the outer run_until_idle loop will pick up the new task on the
|
|
132
|
+
# next iteration without a recursive re-entry.
|
|
133
|
+
run_until_idle if @autorun && Thread.current.thread_variable_get(SCHEDULER_KEY).nil?
|
|
134
|
+
task
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Creates a new cooperative signal backed by {CoopSignal}.
|
|
138
|
+
# @return [CoopSignal]
|
|
139
|
+
# @api private
|
|
140
|
+
def new_signal
|
|
141
|
+
CoopSignal.new(self)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Suspends the current Fiber until +signal+ is notified.
|
|
145
|
+
# @param signal [CoopSignal]
|
|
146
|
+
# @return [void]
|
|
147
|
+
# @api private
|
|
148
|
+
def wait_for_signal(signal)
|
|
149
|
+
signal.wait
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Wakes up one Fiber waiting on +signal+.
|
|
153
|
+
# @param signal [CoopSignal]
|
|
154
|
+
# @return [void]
|
|
155
|
+
# @api private
|
|
156
|
+
def raise_signal(signal)
|
|
157
|
+
signal.notify_one
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Wakes up all Fibers waiting on +signal+.
|
|
161
|
+
# @param signal [CoopSignal]
|
|
162
|
+
# @return [void]
|
|
163
|
+
# @api private
|
|
164
|
+
def raise_signal_all(signal)
|
|
165
|
+
signal.notify_all
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Executes one ready entry (a fiber step or a timer callback).
|
|
169
|
+
# Sets the thread-local scheduler reference so that +FiberBackend#await+
|
|
170
|
+
# can suspend cooperatively.
|
|
171
|
+
#
|
|
172
|
+
# @return [self]
|
|
173
|
+
# @api private
|
|
174
|
+
def tick
|
|
175
|
+
callable = @mutex.synchronize { @ready.shift }
|
|
176
|
+
return self unless callable
|
|
177
|
+
|
|
178
|
+
# Use thread_variable_set (not Thread#[]) so the value is accessible from
|
|
179
|
+
# any Fiber running on this OS thread, not just the current Fiber.
|
|
180
|
+
prev = Thread.current.thread_variable_get(SCHEDULER_KEY)
|
|
181
|
+
Thread.current.thread_variable_set(SCHEDULER_KEY, self)
|
|
182
|
+
callable.call
|
|
183
|
+
ensure
|
|
184
|
+
Thread.current.thread_variable_set(SCHEDULER_KEY, prev)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Drains the ready queue by calling {#tick} until it is empty.
|
|
188
|
+
#
|
|
189
|
+
# In autorun mode ({#autorun?} is +true+), also handles wall-clock timers
|
|
190
|
+
# and cooperative blocking-I/O awaits:
|
|
191
|
+
# - Fires any timers whose deadline has already passed on each iteration.
|
|
192
|
+
# - When all ready tasks are done but future timers remain pending, sleeps
|
|
193
|
+
# until the next deadline and fires them.
|
|
194
|
+
# - When Fibers are suspended in {BlockingAdapterPool::PendingOperation#await}
|
|
195
|
+
# (tracked via {#track_blocking_await}), waits on a condition variable
|
|
196
|
+
# that is broadcast by {#enqueue_fiber} when the worker thread completes
|
|
197
|
+
# (Issue #338). This ensures run_until_idle does not exit while blocking
|
|
198
|
+
# I/O operations are still in flight.
|
|
199
|
+
#
|
|
200
|
+
# Does not fire pending virtual timers — call {#advance} for those.
|
|
201
|
+
#
|
|
202
|
+
# @return [self]
|
|
203
|
+
# @api private
|
|
204
|
+
def run_until_idle
|
|
205
|
+
if @autorun
|
|
206
|
+
loop do
|
|
207
|
+
fire_real_timers
|
|
208
|
+
tick until idle?
|
|
209
|
+
|
|
210
|
+
# Atomically check all exit conditions.
|
|
211
|
+
should_break = @await_mutex.synchronize do
|
|
212
|
+
idle? && pending_real_timer_count.zero? && @pending_awaits.zero?
|
|
213
|
+
end
|
|
214
|
+
break if should_break
|
|
215
|
+
|
|
216
|
+
if idle?
|
|
217
|
+
if pending_real_timer_count > 0 &&
|
|
218
|
+
@await_mutex.synchronize { @pending_awaits.zero? }
|
|
219
|
+
# Only real timers pending — sleep until the next deadline.
|
|
220
|
+
sleep_until_next_real_timer
|
|
221
|
+
else
|
|
222
|
+
# Pending blocking awaits (pool workers still running).
|
|
223
|
+
# Wait for the completion signal broadcast by enqueue_fiber /
|
|
224
|
+
# complete_blocking_await (30-second safety cap).
|
|
225
|
+
@await_mutex.synchronize { @await_cond.wait(@await_mutex, 30) }
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
else
|
|
230
|
+
tick until idle?
|
|
231
|
+
end
|
|
232
|
+
self
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Advances the virtual clock by +seconds+ and enqueues any timer
|
|
236
|
+
# callbacks that are now due.
|
|
237
|
+
#
|
|
238
|
+
# @param seconds [Numeric]
|
|
239
|
+
# @return [self]
|
|
240
|
+
# @api private
|
|
241
|
+
def advance(seconds)
|
|
242
|
+
@virtual_time += seconds
|
|
243
|
+
fire_due_timers
|
|
244
|
+
self
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Schedules +callback+ to fire at the given absolute virtual time.
|
|
248
|
+
#
|
|
249
|
+
# @param absolute_time [Float]
|
|
250
|
+
# @yield callback to invoke when the virtual clock reaches +absolute_time+
|
|
251
|
+
# @return [self]
|
|
252
|
+
# @api private
|
|
253
|
+
def schedule_at(absolute_time, &callback)
|
|
254
|
+
@mutex.synchronize do
|
|
255
|
+
@timer_heap << {fire_at: absolute_time, callback: callback}
|
|
256
|
+
@timer_heap.sort_by! { |e| e[:fire_at] }
|
|
257
|
+
end
|
|
258
|
+
self
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Schedules +callback+ to fire +delay+ seconds from now (virtual time).
|
|
262
|
+
#
|
|
263
|
+
# @param delay [Numeric]
|
|
264
|
+
# @yield callback
|
|
265
|
+
# @return [self]
|
|
266
|
+
# @api private
|
|
267
|
+
def schedule_after(delay, &callback)
|
|
268
|
+
schedule_at(@virtual_time + delay, &callback)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Enqueues a callable (Fiber step or arbitrary block) onto the ready queue.
|
|
272
|
+
# Called by {Task::FiberBackend#await} to resume a waiting Fiber.
|
|
273
|
+
# Also wakes any thread blocked in {#run_until_idle} waiting for external
|
|
274
|
+
# completion signals (e.g. from {BlockingAdapterPool} worker threads).
|
|
275
|
+
#
|
|
276
|
+
# @param callable [#call]
|
|
277
|
+
# @return [self]
|
|
278
|
+
# @api private
|
|
279
|
+
def enqueue_fiber(callable)
|
|
280
|
+
@mutex.synchronize { @ready << callable }
|
|
281
|
+
# Broadcast to wake run_until_idle if it is sleeping on @await_cond.
|
|
282
|
+
# @await_mutex is always acquired AFTER releasing @mutex (never nested)
|
|
283
|
+
# to guarantee consistent lock ordering and avoid deadlocks.
|
|
284
|
+
@await_mutex.synchronize { @await_cond.broadcast }
|
|
285
|
+
self
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Returns +true+ when there are no ready entries to dispatch.
|
|
289
|
+
# @return [Boolean]
|
|
290
|
+
# @api private
|
|
291
|
+
def idle?
|
|
292
|
+
@mutex.synchronize { @ready.empty? }
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Returns the number of entries currently in the ready queue.
|
|
296
|
+
# @return [Integer]
|
|
297
|
+
# @api private
|
|
298
|
+
def ready_count
|
|
299
|
+
@mutex.synchronize { @ready.size }
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# Returns a list of pending timer entries (not yet fired).
|
|
303
|
+
# Each entry has +:fire_at+ and +:description+ (if set) keys.
|
|
304
|
+
# @return [Array<Hash>]
|
|
305
|
+
# @api private
|
|
306
|
+
def pending_timers
|
|
307
|
+
@mutex.synchronize { @timer_heap.dup }
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Schedules +callback+ to fire +seconds+ from now (wall-clock time).
|
|
311
|
+
#
|
|
312
|
+
# Unlike {#schedule_after} (which uses virtual time), this method uses
|
|
313
|
+
# the real monotonic clock. Callbacks are fired during {#run_until_idle}
|
|
314
|
+
# when {#autorun?} is +true+, or explicitly via {#fire_real_timers}.
|
|
315
|
+
#
|
|
316
|
+
# This is the integration point for {TimerQueue} replacement: when a
|
|
317
|
+
# {Runtime} is backed by a +DeterministicScheduler+, its {Runtime#timer_queue}
|
|
318
|
+
# returns a {SchedulerTimerAdapter} that delegates here instead of spawning
|
|
319
|
+
# a background OS thread.
|
|
320
|
+
#
|
|
321
|
+
# @param seconds [Numeric] delay before the callback fires
|
|
322
|
+
# @yield called when the deadline is reached
|
|
323
|
+
# @return [self]
|
|
324
|
+
# @api private
|
|
325
|
+
def schedule_real_after(seconds, &callback)
|
|
326
|
+
fire_at = @clock.call + seconds.to_f
|
|
327
|
+
@mutex.synchronize do
|
|
328
|
+
@real_timer_heap << [fire_at, callback]
|
|
329
|
+
@real_timer_heap.sort_by! { |(t, _)| t }
|
|
330
|
+
end
|
|
331
|
+
self
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# Fires all wall-clock timer callbacks whose deadline has passed.
|
|
335
|
+
# Enqueues each fired callback onto the ready queue for scheduler dispatch.
|
|
336
|
+
#
|
|
337
|
+
# @return [self]
|
|
338
|
+
# @api private
|
|
339
|
+
def fire_real_timers
|
|
340
|
+
now = @clock.call
|
|
341
|
+
due = @mutex.synchronize do
|
|
342
|
+
ready, pending = @real_timer_heap.partition { |(t, _)| t <= now }
|
|
343
|
+
@real_timer_heap.replace(pending)
|
|
344
|
+
ready
|
|
345
|
+
end
|
|
346
|
+
due.each { |(_, cb)| enqueue_fiber(cb) }
|
|
347
|
+
self
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# Returns the number of pending wall-clock timer entries (not yet fired).
|
|
351
|
+
# @return [Integer]
|
|
352
|
+
# @api private
|
|
353
|
+
def pending_real_timer_count
|
|
354
|
+
@mutex.synchronize { @real_timer_heap.size }
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# Registers one pending cooperative blocking-I/O await.
|
|
358
|
+
# Called by {BlockingAdapterPool::PendingOperation#await} before
|
|
359
|
+
# +Fiber.yield+ so that {#run_until_idle} knows not to exit yet.
|
|
360
|
+
# Each call must be balanced by a {#complete_blocking_await} call.
|
|
361
|
+
# @return [self]
|
|
362
|
+
# @api private
|
|
363
|
+
def track_blocking_await
|
|
364
|
+
@await_mutex.synchronize { @pending_awaits += 1 }
|
|
365
|
+
self
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# Marks one pending cooperative blocking-I/O await as complete.
|
|
369
|
+
# Called from the {BlockingAdapterPool::PendingOperation#on_complete}
|
|
370
|
+
# callback (on the pool worker thread) after the result is ready.
|
|
371
|
+
# Decrements the counter and broadcasts to wake {#run_until_idle}.
|
|
372
|
+
# @return [self]
|
|
373
|
+
# @api private
|
|
374
|
+
def complete_blocking_await
|
|
375
|
+
@await_mutex.synchronize do
|
|
376
|
+
@pending_awaits -= 1
|
|
377
|
+
@await_cond.broadcast
|
|
378
|
+
end
|
|
379
|
+
self
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
private
|
|
383
|
+
|
|
384
|
+
def real_timers_due?
|
|
385
|
+
now = @clock.call
|
|
386
|
+
@mutex.synchronize { @real_timer_heap.any? { |(t, _)| t <= now } }
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# Sleeps until the nearest pending real-timer deadline, then returns.
|
|
390
|
+
# Called only from run_until_idle when the ready queue is empty and at
|
|
391
|
+
# least one future real-timer is pending. Ensures we never overshoot:
|
|
392
|
+
# sleep is bounded to the exact remaining time to the next deadline.
|
|
393
|
+
# @api private
|
|
394
|
+
def sleep_until_next_real_timer
|
|
395
|
+
next_at = @mutex.synchronize { @real_timer_heap.first&.first }
|
|
396
|
+
return unless next_at
|
|
397
|
+
|
|
398
|
+
wait_duration = [next_at - @clock.call, 0.0].max
|
|
399
|
+
sleep(wait_duration) if wait_duration > 0
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def fire_due_timers
|
|
403
|
+
due = @mutex.synchronize do
|
|
404
|
+
ready, pending = @timer_heap.partition { |e| e[:fire_at] <= @virtual_time }
|
|
405
|
+
@timer_heap.replace(pending)
|
|
406
|
+
ready
|
|
407
|
+
end
|
|
408
|
+
due.each { |e| enqueue_fiber(e[:callback]) }
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
end
|