ratatui_ruby-tea 0.3.1 → 0.4.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.
- checksums.yaml +4 -4
- data/AGENTS.md +42 -2
- data/CHANGELOG.md +76 -0
- data/README.md +8 -5
- data/doc/concepts/async_work.md +164 -0
- data/doc/concepts/commands.md +528 -0
- data/doc/concepts/message_processing.md +51 -0
- data/doc/contributors/WIP/decomposition_strategies_analysis.md +258 -0
- data/doc/contributors/WIP/implementation_plan.md +405 -0
- data/doc/contributors/WIP/init_callable_proposal.md +341 -0
- data/doc/contributors/WIP/mvu_tea_implementations_research.md +372 -0
- data/doc/contributors/WIP/runtime_refactoring_status.md +47 -0
- data/doc/contributors/WIP/task.md +36 -0
- data/doc/contributors/WIP/v0.4.0_todo.md +468 -0
- data/doc/contributors/design/commands_and_outlets.md +11 -1
- data/doc/contributors/priorities.md +22 -24
- data/examples/app_fractal_dashboard/app.rb +3 -7
- data/examples/app_fractal_dashboard/dashboard/base.rb +15 -16
- data/examples/app_fractal_dashboard/dashboard/update_helpers.rb +8 -8
- data/examples/app_fractal_dashboard/dashboard/update_manual.rb +11 -11
- data/examples/app_fractal_dashboard/dashboard/update_router.rb +4 -4
- data/examples/app_fractal_dashboard/{bags → fragments}/custom_shell_input.rb +8 -4
- data/examples/app_fractal_dashboard/fragments/custom_shell_modal.rb +82 -0
- data/examples/app_fractal_dashboard/{bags → fragments}/custom_shell_output.rb +8 -4
- data/examples/app_fractal_dashboard/{bags → fragments}/disk_usage.rb +13 -10
- data/examples/app_fractal_dashboard/{bags → fragments}/network_panel.rb +12 -12
- data/examples/app_fractal_dashboard/{bags → fragments}/ping.rb +12 -8
- data/examples/app_fractal_dashboard/{bags → fragments}/stats_panel.rb +12 -12
- data/examples/app_fractal_dashboard/{bags → fragments}/system_info.rb +11 -7
- data/examples/app_fractal_dashboard/{bags → fragments}/uptime.rb +11 -7
- data/examples/verify_readme_usage/README.md +7 -4
- data/examples/verify_readme_usage/app.rb +7 -4
- data/lib/ratatui_ruby/tea/command/all.rb +71 -0
- data/lib/ratatui_ruby/tea/command/batch.rb +79 -0
- data/lib/ratatui_ruby/tea/command/custom.rb +1 -1
- data/lib/ratatui_ruby/tea/command/http.rb +194 -0
- data/lib/ratatui_ruby/tea/command/lifecycle.rb +136 -0
- data/lib/ratatui_ruby/tea/command/outlet.rb +59 -27
- data/lib/ratatui_ruby/tea/command/wait.rb +82 -0
- data/lib/ratatui_ruby/tea/command.rb +245 -64
- data/lib/ratatui_ruby/tea/message/all.rb +47 -0
- data/lib/ratatui_ruby/tea/message/http_response.rb +63 -0
- data/lib/ratatui_ruby/tea/message/system/batch.rb +63 -0
- data/lib/ratatui_ruby/tea/message/system/stream.rb +69 -0
- data/lib/ratatui_ruby/tea/message/timer.rb +48 -0
- data/lib/ratatui_ruby/tea/message.rb +40 -0
- data/lib/ratatui_ruby/tea/router.rb +11 -11
- data/lib/ratatui_ruby/tea/runtime.rb +320 -185
- data/lib/ratatui_ruby/tea/shortcuts.rb +2 -2
- data/lib/ratatui_ruby/tea/test_helper.rb +58 -0
- data/lib/ratatui_ruby/tea/version.rb +1 -1
- data/lib/ratatui_ruby/tea.rb +44 -10
- data/rbs_collection.lock.yaml +1 -17
- data/sig/concurrent.rbs +72 -0
- data/sig/ratatui_ruby/tea/command.rbs +141 -37
- data/sig/ratatui_ruby/tea/message.rbs +123 -0
- data/sig/ratatui_ruby/tea/router.rbs +1 -1
- data/sig/ratatui_ruby/tea/runtime.rbs +39 -6
- data/sig/ratatui_ruby/tea/test_helper.rbs +12 -0
- data/sig/ratatui_ruby/tea.rbs +24 -4
- metadata +63 -11
- data/examples/app_fractal_dashboard/bags/custom_shell_modal.rb +0 -73
- data/lib/ratatui_ruby/tea/command/cancellation_token.rb +0 -135
|
@@ -13,9 +13,12 @@ require "ratatui_ruby/tea"
|
|
|
13
13
|
class VerifyReadmeUsage
|
|
14
14
|
# [SYNC:START:mvu]
|
|
15
15
|
Model = Data.define(:text)
|
|
16
|
-
MODEL = Model.new(text: "Hello, Ratatui! Press 'q' to quit.")
|
|
17
16
|
|
|
18
|
-
|
|
17
|
+
Init = -> do
|
|
18
|
+
Model.new(text: "Hello, Ratatui! Press 'q' to quit.")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
View = -> (model, tui) do
|
|
19
22
|
tui.paragraph(
|
|
20
23
|
text: model.text,
|
|
21
24
|
alignment: :center,
|
|
@@ -27,7 +30,7 @@ class VerifyReadmeUsage
|
|
|
27
30
|
)
|
|
28
31
|
end
|
|
29
32
|
|
|
30
|
-
|
|
33
|
+
Update = -> (msg, model) do
|
|
31
34
|
if msg.q? || msg.ctrl_c?
|
|
32
35
|
RatatuiRuby::Tea::Command.exit
|
|
33
36
|
else
|
|
@@ -36,7 +39,7 @@ class VerifyReadmeUsage
|
|
|
36
39
|
end
|
|
37
40
|
|
|
38
41
|
def run
|
|
39
|
-
RatatuiRuby::Tea.run(
|
|
42
|
+
RatatuiRuby::Tea.run(VerifyReadmeUsage)
|
|
40
43
|
end
|
|
41
44
|
# [SYNC:END:mvu]
|
|
42
45
|
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
#--
|
|
4
|
+
# SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
|
|
5
|
+
# SPDX-License-Identifier: LGPL-3.0-or-later
|
|
6
|
+
#++
|
|
7
|
+
|
|
8
|
+
module RatatuiRuby
|
|
9
|
+
module Tea
|
|
10
|
+
module Command
|
|
11
|
+
# An aggregating parallel command.
|
|
12
|
+
All = Data.define(:envelope, :commands, :nested) do
|
|
13
|
+
include Custom
|
|
14
|
+
|
|
15
|
+
def self.new(tag, *args)
|
|
16
|
+
# DWIM: detect nested vs splatted based on call-site arity
|
|
17
|
+
if args.size == 1 && args.first.is_a?(Array)
|
|
18
|
+
commands = args.first
|
|
19
|
+
nested = true
|
|
20
|
+
else
|
|
21
|
+
commands = args
|
|
22
|
+
nested = false
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
if RatatuiRuby::Debug.enabled?
|
|
26
|
+
commands.each do |cmd|
|
|
27
|
+
unless Ractor.shareable?(cmd)
|
|
28
|
+
raise RatatuiRuby::Error::Invariant,
|
|
29
|
+
"Command is not Ractor-shareable: #{cmd.inspect}\n" \
|
|
30
|
+
"Use Ractor.make_shareable or a Data.define command."
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
instance = allocate
|
|
36
|
+
instance.__send__(:initialize, envelope: tag, commands: commands.freeze, nested:)
|
|
37
|
+
instance
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def call(out, token)
|
|
41
|
+
# Early return for empty commands - prevents hang from zip_futures([])
|
|
42
|
+
if commands.empty?
|
|
43
|
+
response = Message::All.new(envelope:, results: [].freeze, nested:)
|
|
44
|
+
out.put(Ractor.make_shareable(response))
|
|
45
|
+
return
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
child_lifecycle = Lifecycle.new
|
|
49
|
+
|
|
50
|
+
futures = commands.map do |command|
|
|
51
|
+
Concurrent::Promises.future do
|
|
52
|
+
child_channel = Concurrent::Promises::Channel.new
|
|
53
|
+
child_outlet = Outlet.new(child_channel, lifecycle: child_lifecycle)
|
|
54
|
+
command.call(child_outlet, token)
|
|
55
|
+
child_channel.pop
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
all_done = Concurrent::Promises.zip_futures(*futures)
|
|
60
|
+
Concurrent::Promises.any_event(all_done, token.origin).wait
|
|
61
|
+
|
|
62
|
+
return out.put(Command.cancel(self)) if token.canceled?
|
|
63
|
+
|
|
64
|
+
shareable_results = Ractor.make_shareable(all_done.value!)
|
|
65
|
+
response = Message::All.new(envelope:, results: shareable_results, nested:)
|
|
66
|
+
out.put(Ractor.make_shareable(response))
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
#--
|
|
4
|
+
# SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
|
|
5
|
+
# SPDX-License-Identifier: LGPL-3.0-or-later
|
|
6
|
+
#++
|
|
7
|
+
|
|
8
|
+
module RatatuiRuby
|
|
9
|
+
module Tea
|
|
10
|
+
module Command
|
|
11
|
+
# A fire-and-forget parallel command.
|
|
12
|
+
#
|
|
13
|
+
# Applications fetch data from multiple sources. Dashboard panels load
|
|
14
|
+
# users, stats, and notifications. Waiting sequentially is slow.
|
|
15
|
+
# Managing threads and error handling manually is error-prone.
|
|
16
|
+
#
|
|
17
|
+
# This command runs children in parallel. Each child sends its own messages
|
|
18
|
+
# independently. The batch completes when all children finish or when
|
|
19
|
+
# cancellation fires. On cancellation, emits <tt>Command.cancel(self)</tt>.
|
|
20
|
+
#
|
|
21
|
+
# Use it for parallel fetches, concurrent refreshes, or any work that
|
|
22
|
+
# does not need coordinated results.
|
|
23
|
+
#
|
|
24
|
+
# === Example
|
|
25
|
+
#
|
|
26
|
+
# def update(msg, model)
|
|
27
|
+
# case msg
|
|
28
|
+
# in :refresh_all
|
|
29
|
+
# batch = Command.batch(
|
|
30
|
+
# Command.http(:get, "/users", :users),
|
|
31
|
+
# Command.http(:get, "/stats", :stats),
|
|
32
|
+
# )
|
|
33
|
+
# [model.with(loading: true), batch]
|
|
34
|
+
# in :users | :stats
|
|
35
|
+
# [model.with(msg => data), nil]
|
|
36
|
+
# end
|
|
37
|
+
# end
|
|
38
|
+
class Batch < Data.define(:commands) do
|
|
39
|
+
include Custom
|
|
40
|
+
|
|
41
|
+
# Initialize
|
|
42
|
+
def self.new(*args)
|
|
43
|
+
# DWIM: accept (cmd1, cmd2) or ([cmd1, cmd2])
|
|
44
|
+
commands = (args.size == 1 && args.first.is_a?(Array)) ? args.first : args
|
|
45
|
+
|
|
46
|
+
if RatatuiRuby::Debug.enabled?
|
|
47
|
+
commands.each do |cmd|
|
|
48
|
+
unless Ractor.shareable?(cmd)
|
|
49
|
+
raise RatatuiRuby::Error::Invariant,
|
|
50
|
+
"Command is not Ractor-shareable: #{cmd.inspect}\n" \
|
|
51
|
+
"Use Ractor.make_shareable or a Data.define command."
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
instance = allocate
|
|
57
|
+
instance.__send__(:initialize, commands: commands.freeze)
|
|
58
|
+
instance
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Call it
|
|
62
|
+
def call(out, token)
|
|
63
|
+
futures = commands.map do |command|
|
|
64
|
+
Concurrent::Promises.future { command.call(out, token) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
all_done = Concurrent::Promises.zip_futures(*futures)
|
|
68
|
+
Concurrent::Promises.any_event(all_done, token.origin).wait
|
|
69
|
+
|
|
70
|
+
# Re-raise any child exception for runtime to wrap in Command::Error
|
|
71
|
+
futures.each { |f| raise f.reason if f.rejected? }
|
|
72
|
+
|
|
73
|
+
out.put(Command.cancel(self)) if token.canceled?
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
#--
|
|
4
|
+
# SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
|
|
5
|
+
# SPDX-License-Identifier: LGPL-3.0-or-later
|
|
6
|
+
#++
|
|
7
|
+
|
|
8
|
+
require "net/http"
|
|
9
|
+
require "uri"
|
|
10
|
+
|
|
11
|
+
module RatatuiRuby
|
|
12
|
+
module Tea
|
|
13
|
+
module Command
|
|
14
|
+
# Alias to Message::HttpResponse for backwards compatibility.
|
|
15
|
+
# New code should use RatatuiRuby::Tea::Message::HttpResponse.
|
|
16
|
+
HttpResponse = Message::HttpResponse
|
|
17
|
+
|
|
18
|
+
# An HTTP request command.
|
|
19
|
+
Http = Data.define(:method, :url, :envelope, :headers, :body, :timeout, :parser) do
|
|
20
|
+
include Custom
|
|
21
|
+
|
|
22
|
+
def self.new(*args, method: nil, url: nil, envelope: nil, headers: nil, body: nil, timeout: nil, parser: nil,
|
|
23
|
+
get: nil, post: nil, put: nil, patch: nil, delete: nil
|
|
24
|
+
)
|
|
25
|
+
# Auto-splat single hash argument
|
|
26
|
+
return new(**args.first) if args.size == 1 && args.first.is_a?(Hash)
|
|
27
|
+
|
|
28
|
+
# Auto-spread single array argument
|
|
29
|
+
return new(*args.first) if args.size == 1 && args.first.is_a?(Array)
|
|
30
|
+
|
|
31
|
+
# DWIM: parse positional args and keyword method shortcuts
|
|
32
|
+
method_keywords = { get:, post:, put:, patch:, delete: }.compact
|
|
33
|
+
method, url, envelope, body = parse_dwim_args(args, method, url, envelope, body, method_keywords)
|
|
34
|
+
|
|
35
|
+
# Ractor validation
|
|
36
|
+
if RatatuiRuby::Debug.enabled? && !Ractor.shareable?(url)
|
|
37
|
+
raise RatatuiRuby::Error::Invariant,
|
|
38
|
+
"URL is not Ractor-shareable: #{url.inspect}\n" \
|
|
39
|
+
"Use a frozen string or Ractor.make_shareable."
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
if RatatuiRuby::Debug.enabled? && headers && !Ractor.shareable?(headers)
|
|
43
|
+
raise RatatuiRuby::Error::Invariant,
|
|
44
|
+
"Headers are not Ractor-shareable: #{headers.inspect}\n" \
|
|
45
|
+
"Use Ractor.make_shareable or freeze the hash and its contents."
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
if RatatuiRuby::Debug.enabled? && body && !Ractor.shareable?(body)
|
|
49
|
+
raise RatatuiRuby::Error::Invariant,
|
|
50
|
+
"Body is not Ractor-shareable: #{body.inspect}\n" \
|
|
51
|
+
"Use a frozen string or Ractor.make_shareable."
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
if RatatuiRuby::Debug.enabled? && envelope && !Ractor.shareable?(envelope)
|
|
55
|
+
raise RatatuiRuby::Error::Invariant,
|
|
56
|
+
"Envelope is not Ractor-shareable: #{envelope.inspect}\n" \
|
|
57
|
+
"Use a frozen string, symbol, or Ractor.make_shareable."
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
if RatatuiRuby::Debug.enabled? && timeout && !Ractor.shareable?(timeout)
|
|
61
|
+
raise RatatuiRuby::Error::Invariant,
|
|
62
|
+
"Timeout is not Ractor-shareable: #{timeout.inspect}\n" \
|
|
63
|
+
"Use a number or Ractor.make_shareable."
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Parser validation
|
|
67
|
+
if parser && !parser.respond_to?(:call)
|
|
68
|
+
raise ArgumentError, "parser: must respond to :call"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
if RatatuiRuby::Debug.enabled? && parser && !Ractor.shareable?(parser)
|
|
72
|
+
raise RatatuiRuby::Error::Invariant,
|
|
73
|
+
"Parser is not Ractor-shareable: #{parser.inspect}\n" \
|
|
74
|
+
"Use a frozen Method object or Ractor.make_shareable."
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Method validation
|
|
78
|
+
unless %i[get post put patch delete].include?(method)
|
|
79
|
+
raise ArgumentError, "Unsupported HTTP method: #{method.inspect}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
instance = allocate
|
|
83
|
+
instance.__send__(:initialize, method:, url:, envelope:, headers:, body:, timeout: timeout || 10, parser:)
|
|
84
|
+
instance
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Net::HTTP is blocking; no cooperative cancellation possible.
|
|
88
|
+
# Grace period = 0 means runtime can force-kill immediately.
|
|
89
|
+
def tea_cancellation_grace_period = 0
|
|
90
|
+
|
|
91
|
+
def self.parse_dwim_args(args, method_kw, url_kw, envelope_kw, body_kw, method_keywords)
|
|
92
|
+
# Handle keyword method shortcuts: get: 'url'
|
|
93
|
+
if method_keywords.any?
|
|
94
|
+
method_key, url = method_keywords.first
|
|
95
|
+
# Check for conflicts with method: keyword
|
|
96
|
+
if method_kw && method_kw != method_key
|
|
97
|
+
raise ArgumentError, "Conflicting method specified: #{method_key}: and method: #{method_kw}"
|
|
98
|
+
end
|
|
99
|
+
# Check for conflicts with url: keyword
|
|
100
|
+
if url_kw && url_kw != url
|
|
101
|
+
raise ArgumentError, "Conflicting url specified: #{method_key}: provides url but url: also given"
|
|
102
|
+
end
|
|
103
|
+
return [method_key, url, url, body_kw]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# If keywords provided, use them directly
|
|
107
|
+
return [method_kw, url_kw, envelope_kw, body_kw] if args.empty?
|
|
108
|
+
|
|
109
|
+
case args
|
|
110
|
+
in [String => url]
|
|
111
|
+
# Http.new('url') → GET, URL as envelope
|
|
112
|
+
[:get, url, url, body_kw]
|
|
113
|
+
in [String => url, Symbol => envelope]
|
|
114
|
+
# Http.new('url', :tag) → GET, custom envelope
|
|
115
|
+
[:get, url, envelope, body_kw]
|
|
116
|
+
in [Symbol => method, String => url]
|
|
117
|
+
# Http.new(:delete, 'url') → method, URL as envelope
|
|
118
|
+
[method, url, url, body_kw]
|
|
119
|
+
in [Symbol => method, String => url, Symbol => envelope]
|
|
120
|
+
# Http.new(:delete, 'url', :tag) → method, custom envelope
|
|
121
|
+
[method, url, envelope, body_kw]
|
|
122
|
+
in [Symbol => method, String => url, String => body]
|
|
123
|
+
# Http.new(:post, 'url', 'body') → method, URL as envelope, body
|
|
124
|
+
[method, url, url, body]
|
|
125
|
+
in [Symbol => method, String => url, String => body, Symbol => envelope]
|
|
126
|
+
# Http.new(:post, 'url', 'body', :tag) → method, custom envelope, body
|
|
127
|
+
[method, url, envelope, body]
|
|
128
|
+
else
|
|
129
|
+
raise ArgumentError, "Invalid arguments for Http.new: #{args.inspect}"
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
private_class_method :parse_dwim_args
|
|
133
|
+
|
|
134
|
+
def call(out, token)
|
|
135
|
+
return if token.canceled?
|
|
136
|
+
|
|
137
|
+
uri = URI(url)
|
|
138
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
139
|
+
http.use_ssl = uri.scheme == "https"
|
|
140
|
+
if timeout
|
|
141
|
+
http.open_timeout = timeout
|
|
142
|
+
http.read_timeout = timeout
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
klass = case method
|
|
146
|
+
when :get then Net::HTTP::Get
|
|
147
|
+
when :post then Net::HTTP::Post
|
|
148
|
+
when :put then Net::HTTP::Put
|
|
149
|
+
when :patch then Net::HTTP::Patch
|
|
150
|
+
when :delete then Net::HTTP::Delete
|
|
151
|
+
end
|
|
152
|
+
request = klass.new(uri)
|
|
153
|
+
request.body = body if body
|
|
154
|
+
headers&.each { |key, value| request[key] = value }
|
|
155
|
+
response = http.request(request)
|
|
156
|
+
|
|
157
|
+
response_body = response.body.freeze
|
|
158
|
+
response_headers = response.each_header.to_h.freeze
|
|
159
|
+
response_status = response.code.to_i
|
|
160
|
+
|
|
161
|
+
# Invoke parser with positional params if provided
|
|
162
|
+
parsed_body = if parser
|
|
163
|
+
parser.call(response_body, response_headers, response_status)
|
|
164
|
+
else
|
|
165
|
+
response_body
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Validate parsed body is Ractor-shareable in debug mode
|
|
169
|
+
if RatatuiRuby::Debug.enabled? && parser && !Ractor.shareable?(parsed_body)
|
|
170
|
+
raise RatatuiRuby::Error::Invariant,
|
|
171
|
+
"Parsed body is not Ractor-shareable: #{parsed_body.class}\n" \
|
|
172
|
+
"Parser must return frozen/shareable data. Use .freeze or Ractor.make_shareable."
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
out.put(Ractor.make_shareable(HttpResponse.new(
|
|
176
|
+
envelope:,
|
|
177
|
+
status: response_status,
|
|
178
|
+
body: parsed_body,
|
|
179
|
+
headers: response_headers,
|
|
180
|
+
error: nil
|
|
181
|
+
)))
|
|
182
|
+
rescue => e
|
|
183
|
+
out.put(Ractor.make_shareable(HttpResponse.new(
|
|
184
|
+
envelope:,
|
|
185
|
+
status: nil,
|
|
186
|
+
body: nil,
|
|
187
|
+
headers: nil,
|
|
188
|
+
error: e.message.freeze
|
|
189
|
+
)))
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
#--
|
|
4
|
+
# SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
|
|
5
|
+
# SPDX-License-Identifier: LGPL-3.0-or-later
|
|
6
|
+
#++
|
|
7
|
+
|
|
8
|
+
module RatatuiRuby
|
|
9
|
+
module Tea
|
|
10
|
+
module Command
|
|
11
|
+
# Coordinates command execution across the runtime.
|
|
12
|
+
#
|
|
13
|
+
# Commands run off the main thread. Both the runtime and nested commands
|
|
14
|
+
# via <tt>Outlet#source</tt> share cancellation tokens. Racing results
|
|
15
|
+
# against cancellation is repetitive. Tracking active commands is tedious.
|
|
16
|
+
#
|
|
17
|
+
# This class centralizes that logic. It races results against cancellation
|
|
18
|
+
# and timeout. Commands that ignore cancellation are orphaned until
|
|
19
|
+
# process exit. Cooperative cancellation is the only way to exit cleanly.
|
|
20
|
+
#
|
|
21
|
+
# The framework creates one instance at startup. All outlets share it.
|
|
22
|
+
class Lifecycle
|
|
23
|
+
# :nodoc: Internal representation of a tracked async command.
|
|
24
|
+
Entry = Data.define(:future, :origin)
|
|
25
|
+
|
|
26
|
+
# Creates a lifecycle manager.
|
|
27
|
+
#
|
|
28
|
+
# The runtime creates one at startup. All outlets share it. Child commands
|
|
29
|
+
# from <tt>Outlet#source</tt> inherit the same lifecycle for consistent
|
|
30
|
+
# thread management.
|
|
31
|
+
def initialize
|
|
32
|
+
@active = Concurrent::Map.new
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Runs a command synchronously, returning its result.
|
|
36
|
+
#
|
|
37
|
+
# Spawns a thread, races the result against cancellation and timeout.
|
|
38
|
+
# On cancellation, waits the grace period then kills the thread if needed.
|
|
39
|
+
#
|
|
40
|
+
# [command] Callable with <tt>call(out, token)</tt>.
|
|
41
|
+
# [token] Parent's cancellation token.
|
|
42
|
+
# [timeout] Max wait seconds for the result.
|
|
43
|
+
#
|
|
44
|
+
# Returns the child's message, or <tt>nil</tt> if cancelled or timed out.
|
|
45
|
+
# Raises if the child raised.
|
|
46
|
+
def run_sync(command, token, timeout:)
|
|
47
|
+
return nil if token.canceled?
|
|
48
|
+
|
|
49
|
+
child_channel = Concurrent::Promises::Channel.new
|
|
50
|
+
child_outlet = Outlet.new(child_channel, lifecycle: self)
|
|
51
|
+
|
|
52
|
+
exception = nil
|
|
53
|
+
Concurrent::Promises.future do
|
|
54
|
+
command.call(child_outlet, token)
|
|
55
|
+
rescue => e
|
|
56
|
+
exception = e
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Race: pop result vs cancellation vs timeout
|
|
60
|
+
pop_future = Concurrent::Promises.future { child_channel.pop(timeout, :timeout) }
|
|
61
|
+
Concurrent::Promises.any_event(pop_future, token.origin).wait
|
|
62
|
+
|
|
63
|
+
# Cooperative cancellation only — misbehaving commands are orphaned
|
|
64
|
+
return nil if token.canceled?
|
|
65
|
+
|
|
66
|
+
if exception
|
|
67
|
+
raise exception.is_a?(Exception) ? exception : RuntimeError.new(exception.to_s)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
result = pop_future.value
|
|
71
|
+
return nil if result == :timeout
|
|
72
|
+
|
|
73
|
+
result
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Runs a command asynchronously, tracking it for later cancellation.
|
|
77
|
+
#
|
|
78
|
+
# Spawns a future that executes the command. Tracks the command in the
|
|
79
|
+
# active map for cancellation support. Errors are pushed to the channel
|
|
80
|
+
# as <tt>Command::Error</tt> messages.
|
|
81
|
+
#
|
|
82
|
+
# [command] Callable with <tt>call(out, token)</tt>.
|
|
83
|
+
# [channel] Channel to push results and errors to.
|
|
84
|
+
#
|
|
85
|
+
# Returns a hash with <tt>:future</tt> and <tt>:origin</tt> for tracking.
|
|
86
|
+
def run_async(command, channel)
|
|
87
|
+
cancellation, origin = Concurrent::Cancellation.new
|
|
88
|
+
outlet = Outlet.new(channel, lifecycle: self)
|
|
89
|
+
|
|
90
|
+
future = Concurrent::Promises.future do
|
|
91
|
+
command.call(outlet, cancellation)
|
|
92
|
+
rescue => e
|
|
93
|
+
channel.push Command::Error.new(command:, exception: e)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
entry = Entry.new(future:, origin:)
|
|
97
|
+
@active[command] = entry
|
|
98
|
+
entry
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Cancels a running command, waiting for its grace period.
|
|
102
|
+
#
|
|
103
|
+
# Signals cancellation, waits for the command's grace period, then
|
|
104
|
+
# removes it from tracking. Does nothing if the command isn't tracked.
|
|
105
|
+
#
|
|
106
|
+
# [command] The command to cancel (must be the same object passed to run_async).
|
|
107
|
+
def cancel(command)
|
|
108
|
+
entry = @active[command]
|
|
109
|
+
return unless entry&.future&.pending?
|
|
110
|
+
|
|
111
|
+
entry.origin.resolve # Signal cancellation
|
|
112
|
+
|
|
113
|
+
grace = command.respond_to?(:tea_cancellation_grace_period) ?
|
|
114
|
+
command.tea_cancellation_grace_period : 0.1
|
|
115
|
+
entry.future.wait(grace.finite? ? grace : nil)
|
|
116
|
+
|
|
117
|
+
@active.delete(command)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Cancels all active commands and waits for them to complete.
|
|
121
|
+
#
|
|
122
|
+
# Iterates through all tracked commands, signals cancellation, and waits
|
|
123
|
+
# for each command's grace period. Called at runtime shutdown.
|
|
124
|
+
def shutdown
|
|
125
|
+
@active.each do |command, entry|
|
|
126
|
+
entry.origin.resolve # Signal cancellation
|
|
127
|
+
|
|
128
|
+
grace = command.respond_to?(:tea_cancellation_grace_period) ?
|
|
129
|
+
command.tea_cancellation_grace_period : 0.1
|
|
130
|
+
entry.future.wait(grace.finite? ? grace : nil)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -64,7 +64,7 @@ module RatatuiRuby
|
|
|
64
64
|
# include Tea::Command::Custom
|
|
65
65
|
#
|
|
66
66
|
# def call(out, token)
|
|
67
|
-
# until token.
|
|
67
|
+
# until token.canceled?
|
|
68
68
|
# data = fetch_batch
|
|
69
69
|
# out.put(:batch, Ractor.make_shareable(data))
|
|
70
70
|
# sleep 5
|
|
@@ -76,26 +76,64 @@ module RatatuiRuby
|
|
|
76
76
|
# SPDX-SnippetEnd
|
|
77
77
|
#++
|
|
78
78
|
class Outlet
|
|
79
|
-
# Creates an outlet for the given queue.
|
|
79
|
+
# Creates an outlet for the given message queue.
|
|
80
80
|
#
|
|
81
|
-
# The runtime provides the queue. Custom commands receive
|
|
82
|
-
# their first argument.
|
|
81
|
+
# The runtime provides the message queue and lifecycle. Custom commands receive
|
|
82
|
+
# the outlet as their first argument.
|
|
83
83
|
#
|
|
84
|
-
# [
|
|
85
|
-
|
|
86
|
-
|
|
84
|
+
# [message_queue] A <tt>Concurrent::Promises::Channel</tt> or compatible object.
|
|
85
|
+
# [lifecycle] A <tt>Lifecycle</tt> for managing nested command execution.
|
|
86
|
+
def initialize(message_queue, lifecycle:)
|
|
87
|
+
@message_queue = message_queue
|
|
88
|
+
@live = lifecycle
|
|
87
89
|
end
|
|
88
90
|
|
|
89
|
-
#
|
|
91
|
+
# :nodoc: Internal infrastructure for nested command lifecycle sharing.
|
|
92
|
+
attr_reader :live
|
|
93
|
+
|
|
94
|
+
# Sends a message to the runtime.
|
|
90
95
|
#
|
|
91
|
-
#
|
|
92
|
-
#
|
|
96
|
+
# Custom commands produce results. Those results feed back into your
|
|
97
|
+
# update function. This method handles the wiring.
|
|
93
98
|
#
|
|
94
|
-
#
|
|
95
|
-
#
|
|
99
|
+
# Call with one argument to send it directly. Call with multiple
|
|
100
|
+
# arguments and they arrive as an array.
|
|
96
101
|
#
|
|
97
|
-
#
|
|
98
|
-
#
|
|
102
|
+
# Use it for complex data flows or transports Tea doesn't ship with.
|
|
103
|
+
#
|
|
104
|
+
# === Example
|
|
105
|
+
#
|
|
106
|
+
# out.put(:done) # Update receives :done
|
|
107
|
+
# out.put(current_user) # Update receives current_user
|
|
108
|
+
# out.put(:user, alice) # Update receives [:user, alice]
|
|
109
|
+
#
|
|
110
|
+
# Debug mode validates Ractor-shareability.
|
|
111
|
+
def put(*args)
|
|
112
|
+
message = (args.size == 1) ? args.first : args.freeze
|
|
113
|
+
|
|
114
|
+
if RatatuiRuby::Debug.enabled? && !Ractor.shareable?(message)
|
|
115
|
+
raise RatatuiRuby::Error::Invariant,
|
|
116
|
+
"Message is not Ractor-shareable: #{message.inspect}\n" \
|
|
117
|
+
"Use Ractor.make_shareable or Object#freeze."
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
@message_queue.push(message)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Runs a child command synchronously within a custom command.
|
|
124
|
+
#
|
|
125
|
+
# Use this to orchestrate multi-step workflows: fetch one result, then
|
|
126
|
+
# use it to compose the next command.
|
|
127
|
+
#
|
|
128
|
+
# The child runs asynchronously in a future. This method blocks until
|
|
129
|
+
# the child calls +put+, cancellation occurs, or the timeout expires.
|
|
130
|
+
#
|
|
131
|
+
# [command] A callable (lambda or Custom command) with +call(out, token)+.
|
|
132
|
+
# [token] The parent's cancellation token, passed through to the child.
|
|
133
|
+
# [timeout] Max seconds to wait for the child's result (default: 30.0).
|
|
134
|
+
#
|
|
135
|
+
# Returns the message from the child, or +nil+ if cancelled/timed out.
|
|
136
|
+
# Raises if the child command raised an exception.
|
|
99
137
|
#
|
|
100
138
|
# === Example
|
|
101
139
|
#
|
|
@@ -104,22 +142,16 @@ module RatatuiRuby
|
|
|
104
142
|
# SPDX-FileCopyrightText: 2026 Kerrick Long
|
|
105
143
|
# SPDX-License-Identifier: MIT-0
|
|
106
144
|
#++
|
|
107
|
-
# out
|
|
108
|
-
#
|
|
109
|
-
#
|
|
145
|
+
# def call(out, token)
|
|
146
|
+
# user_result = out.source(fetch_user_cmd, token)
|
|
147
|
+
# return if user_result.nil?
|
|
148
|
+
# out.put(:user_loaded, user: user_result)
|
|
149
|
+
# end
|
|
110
150
|
#--
|
|
111
151
|
# SPDX-SnippetEnd
|
|
112
152
|
#++
|
|
113
|
-
def
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
if RatatuiRuby::Debug.enabled? && !Ractor.shareable?(message)
|
|
117
|
-
raise RatatuiRuby::Error::Invariant,
|
|
118
|
-
"Message is not Ractor-shareable: #{message.inspect}\n" \
|
|
119
|
-
"Use Ractor.make_shareable or Object#freeze."
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
@queue << message
|
|
153
|
+
def source(command, token, timeout: 30.0)
|
|
154
|
+
@live.run_sync(command, token, timeout:)
|
|
123
155
|
end
|
|
124
156
|
end
|
|
125
157
|
end
|