rooibos 0.5.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 +7 -0
- data/.builds/ruby-3.2.yml +51 -0
- data/.builds/ruby-3.3.yml +51 -0
- data/.builds/ruby-3.4.yml +51 -0
- data/.builds/ruby-4.0.0.yml +51 -0
- data/.pre-commit-config.yaml +16 -0
- data/.rubocop.yml +8 -0
- data/AGENTS.md +108 -0
- data/CHANGELOG.md +214 -0
- data/LICENSE +304 -0
- data/LICENSES/AGPL-3.0-or-later.txt +235 -0
- data/LICENSES/CC-BY-SA-4.0.txt +170 -0
- data/LICENSES/CC0-1.0.txt +121 -0
- data/LICENSES/LGPL-3.0-or-later.txt +304 -0
- data/LICENSES/MIT-0.txt +16 -0
- data/LICENSES/MIT.txt +18 -0
- data/README.md +183 -0
- data/REUSE.toml +24 -0
- data/Rakefile +16 -0
- data/Steepfile +13 -0
- data/doc/concepts/application_architecture.md +197 -0
- data/doc/concepts/application_testing.md +49 -0
- data/doc/concepts/async_work.md +164 -0
- data/doc/concepts/commands.md +530 -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 +409 -0
- data/doc/contributors/WIP/init_callable_proposal.md +344 -0
- data/doc/contributors/WIP/mvu_tea_implementations_research.md +373 -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 +214 -0
- data/doc/contributors/kit-no-outlet.md +238 -0
- data/doc/contributors/priorities.md +38 -0
- data/doc/custom.css +22 -0
- data/doc/getting_started/quickstart.md +56 -0
- data/doc/images/.gitkeep +0 -0
- data/doc/images/verify_readme_usage.png +0 -0
- data/doc/images/widget_cmd_exec.png +0 -0
- data/doc/index.md +25 -0
- data/examples/app_fractal_dashboard/README.md +60 -0
- data/examples/app_fractal_dashboard/app.rb +63 -0
- data/examples/app_fractal_dashboard/dashboard/base.rb +73 -0
- data/examples/app_fractal_dashboard/dashboard/update_helpers.rb +86 -0
- data/examples/app_fractal_dashboard/dashboard/update_manual.rb +87 -0
- data/examples/app_fractal_dashboard/dashboard/update_router.rb +43 -0
- data/examples/app_fractal_dashboard/fragments/custom_shell_input.rb +81 -0
- data/examples/app_fractal_dashboard/fragments/custom_shell_modal.rb +82 -0
- data/examples/app_fractal_dashboard/fragments/custom_shell_output.rb +90 -0
- data/examples/app_fractal_dashboard/fragments/disk_usage.rb +47 -0
- data/examples/app_fractal_dashboard/fragments/network_panel.rb +45 -0
- data/examples/app_fractal_dashboard/fragments/ping.rb +47 -0
- data/examples/app_fractal_dashboard/fragments/stats_panel.rb +45 -0
- data/examples/app_fractal_dashboard/fragments/system_info.rb +47 -0
- data/examples/app_fractal_dashboard/fragments/uptime.rb +47 -0
- data/examples/verify_readme_usage/README.md +54 -0
- data/examples/verify_readme_usage/app.rb +47 -0
- data/examples/widget_command_system/README.md +70 -0
- data/examples/widget_command_system/app.rb +132 -0
- data/exe/.gitkeep +0 -0
- data/lib/rooibos/command/all.rb +69 -0
- data/lib/rooibos/command/batch.rb +77 -0
- data/lib/rooibos/command/custom.rb +104 -0
- data/lib/rooibos/command/http.rb +192 -0
- data/lib/rooibos/command/lifecycle.rb +134 -0
- data/lib/rooibos/command/outlet.rb +157 -0
- data/lib/rooibos/command/wait.rb +80 -0
- data/lib/rooibos/command.rb +546 -0
- data/lib/rooibos/error.rb +55 -0
- data/lib/rooibos/message/all.rb +45 -0
- data/lib/rooibos/message/http_response.rb +61 -0
- data/lib/rooibos/message/system/batch.rb +61 -0
- data/lib/rooibos/message/system/stream.rb +67 -0
- data/lib/rooibos/message/timer.rb +46 -0
- data/lib/rooibos/message.rb +38 -0
- data/lib/rooibos/router.rb +403 -0
- data/lib/rooibos/runtime.rb +396 -0
- data/lib/rooibos/shortcuts.rb +49 -0
- data/lib/rooibos/test_helper.rb +56 -0
- data/lib/rooibos/version.rb +12 -0
- data/lib/rooibos.rb +121 -0
- data/mise.toml +8 -0
- data/rbs_collection.lock.yaml +108 -0
- data/rbs_collection.yaml +15 -0
- data/sig/concurrent.rbs +72 -0
- data/sig/examples/verify_readme_usage/app.rbs +19 -0
- data/sig/examples/widget_command_system/app.rbs +26 -0
- data/sig/open3.rbs +17 -0
- data/sig/rooibos/command.rbs +265 -0
- data/sig/rooibos/error.rbs +13 -0
- data/sig/rooibos/message.rbs +121 -0
- data/sig/rooibos/router.rbs +153 -0
- data/sig/rooibos/runtime.rbs +75 -0
- data/sig/rooibos/shortcuts.rbs +16 -0
- data/sig/rooibos/test_helper.rbs +10 -0
- data/sig/rooibos/version.rbs +8 -0
- data/sig/rooibos.rbs +46 -0
- data/tasks/example_viewer.html.erb +172 -0
- data/tasks/resources/build.yml.erb +53 -0
- data/tasks/resources/index.html.erb +44 -0
- data/tasks/resources/rubies.yml +7 -0
- data/tasks/steep.rake +11 -0
- data/vendor/goodcop/base.yml +1047 -0
- metadata +241 -0
|
@@ -0,0 +1,104 @@
|
|
|
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 Rooibos
|
|
9
|
+
module Command
|
|
10
|
+
# Mixin for user-defined custom commands.
|
|
11
|
+
#
|
|
12
|
+
# Custom commands extend Rooibos with side effects: WebSockets, gRPC, database polls,
|
|
13
|
+
# background tasks. The runtime dispatches them in threads and routes results
|
|
14
|
+
# back as messages.
|
|
15
|
+
#
|
|
16
|
+
# Include this module to identify your class as a command. The runtime uses
|
|
17
|
+
# +rooibos_command?+ to distinguish commands from plain models. Override
|
|
18
|
+
# +rooibos_cancellation_grace_period+ if your cleanup takes longer than 100 milliseconds.
|
|
19
|
+
#
|
|
20
|
+
# Use it to build real-time features, long-polling connections, or background workers.
|
|
21
|
+
#
|
|
22
|
+
# === Example
|
|
23
|
+
#
|
|
24
|
+
#--
|
|
25
|
+
# SPDX-SnippetBegin
|
|
26
|
+
# SPDX-FileCopyrightText: 2026 Kerrick Long
|
|
27
|
+
# SPDX-License-Identifier: MIT-0
|
|
28
|
+
#++
|
|
29
|
+
# class WebSocketCommand
|
|
30
|
+
# include Rooibos::Command::Custom
|
|
31
|
+
#
|
|
32
|
+
# def initialize(url)
|
|
33
|
+
# @url = url
|
|
34
|
+
# end
|
|
35
|
+
#
|
|
36
|
+
# def call(out, token)
|
|
37
|
+
# ws = WebSocket::Client.new(@url)
|
|
38
|
+
# ws.on_message { |msg| out.put(:ws_message, msg) }
|
|
39
|
+
# ws.connect
|
|
40
|
+
#
|
|
41
|
+
# until token.canceled?
|
|
42
|
+
# ws.ping
|
|
43
|
+
# sleep 1
|
|
44
|
+
# end
|
|
45
|
+
#
|
|
46
|
+
# ws.close
|
|
47
|
+
# end
|
|
48
|
+
#
|
|
49
|
+
# # WebSocket close handshake needs extra time
|
|
50
|
+
# def rooibos_cancellation_grace_period
|
|
51
|
+
# 5.0
|
|
52
|
+
# end
|
|
53
|
+
# end
|
|
54
|
+
#--
|
|
55
|
+
# SPDX-SnippetEnd
|
|
56
|
+
#++
|
|
57
|
+
module Custom
|
|
58
|
+
# Brand predicate for command identification.
|
|
59
|
+
#
|
|
60
|
+
# The runtime calls this to distinguish commands from plain models.
|
|
61
|
+
# Returns <tt>true</tt> unconditionally.
|
|
62
|
+
#
|
|
63
|
+
# You do not need to override this method.
|
|
64
|
+
def rooibos_command?
|
|
65
|
+
true
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Cleanup time after cancellation is requested. In seconds.
|
|
69
|
+
#
|
|
70
|
+
# When the runtime cancels your command (app exit, navigation, explicit cancel),
|
|
71
|
+
# it calls <tt>token.cancel!</tt> and waits this long for your command to stop.
|
|
72
|
+
# If your command does not exit within this window, it is force-killed.
|
|
73
|
+
#
|
|
74
|
+
# *This is NOT a lifetime limit.* Your command runs indefinitely until cancelled.
|
|
75
|
+
# A WebSocket open for 15 minutes is fine. This timeout only applies to the
|
|
76
|
+
# cleanup phase after cancellation is requested.
|
|
77
|
+
#
|
|
78
|
+
# Override this method to specify how long your cleanup takes:
|
|
79
|
+
#
|
|
80
|
+
# - <tt>0.5</tt> — Quick HTTP abort, no cleanup needed
|
|
81
|
+
# - <tt>2.0</tt> — Default, suitable for most commands
|
|
82
|
+
# - <tt>5.0</tt> — WebSocket close handshake with remote server
|
|
83
|
+
# - <tt>Float::INFINITY</tt> — Never force-kill (database transactions)
|
|
84
|
+
#
|
|
85
|
+
# === Example
|
|
86
|
+
#
|
|
87
|
+
#--
|
|
88
|
+
# SPDX-SnippetBegin
|
|
89
|
+
# SPDX-FileCopyrightText: 2026 Kerrick Long
|
|
90
|
+
# SPDX-License-Identifier: MIT-0
|
|
91
|
+
#++
|
|
92
|
+
# # Database transactions should never be interrupted mid-write
|
|
93
|
+
# def rooibos_cancellation_grace_period
|
|
94
|
+
# Float::INFINITY
|
|
95
|
+
# end
|
|
96
|
+
#--
|
|
97
|
+
# SPDX-SnippetEnd
|
|
98
|
+
#++
|
|
99
|
+
def rooibos_cancellation_grace_period
|
|
100
|
+
0.1
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,192 @@
|
|
|
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 Rooibos
|
|
12
|
+
module Command
|
|
13
|
+
# Alias to Message::HttpResponse for backwards compatibility.
|
|
14
|
+
# New code should use Rooibos::Message::HttpResponse.
|
|
15
|
+
HttpResponse = Message::HttpResponse
|
|
16
|
+
|
|
17
|
+
# An HTTP request command.
|
|
18
|
+
Http = Data.define(:method, :url, :envelope, :headers, :body, :timeout, :parser) do
|
|
19
|
+
include Custom
|
|
20
|
+
|
|
21
|
+
def self.new(*args, method: nil, url: nil, envelope: nil, headers: nil, body: nil, timeout: nil, parser: nil,
|
|
22
|
+
get: nil, post: nil, put: nil, patch: nil, delete: nil
|
|
23
|
+
)
|
|
24
|
+
# Auto-splat single hash argument
|
|
25
|
+
return new(**args.first) if args.size == 1 && args.first.is_a?(Hash)
|
|
26
|
+
|
|
27
|
+
# Auto-spread single array argument
|
|
28
|
+
return new(*args.first) if args.size == 1 && args.first.is_a?(Array)
|
|
29
|
+
|
|
30
|
+
# DWIM: parse positional args and keyword method shortcuts
|
|
31
|
+
method_keywords = { get:, post:, put:, patch:, delete: }.compact
|
|
32
|
+
method, url, envelope, body = parse_dwim_args(args, method, url, envelope, body, method_keywords)
|
|
33
|
+
|
|
34
|
+
# Ractor validation
|
|
35
|
+
if RatatuiRuby::Debug.enabled? && !Ractor.shareable?(url)
|
|
36
|
+
raise Rooibos::Error::Invariant,
|
|
37
|
+
"URL is not Ractor-shareable: #{url.inspect}\n" \
|
|
38
|
+
"Use a frozen string or Ractor.make_shareable."
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
if RatatuiRuby::Debug.enabled? && headers && !Ractor.shareable?(headers)
|
|
42
|
+
raise Rooibos::Error::Invariant,
|
|
43
|
+
"Headers are not Ractor-shareable: #{headers.inspect}\n" \
|
|
44
|
+
"Use Ractor.make_shareable or freeze the hash and its contents."
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
if RatatuiRuby::Debug.enabled? && body && !Ractor.shareable?(body)
|
|
48
|
+
raise Rooibos::Error::Invariant,
|
|
49
|
+
"Body is not Ractor-shareable: #{body.inspect}\n" \
|
|
50
|
+
"Use a frozen string or Ractor.make_shareable."
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
if RatatuiRuby::Debug.enabled? && envelope && !Ractor.shareable?(envelope)
|
|
54
|
+
raise Rooibos::Error::Invariant,
|
|
55
|
+
"Envelope is not Ractor-shareable: #{envelope.inspect}\n" \
|
|
56
|
+
"Use a frozen string, symbol, or Ractor.make_shareable."
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
if RatatuiRuby::Debug.enabled? && timeout && !Ractor.shareable?(timeout)
|
|
60
|
+
raise Rooibos::Error::Invariant,
|
|
61
|
+
"Timeout is not Ractor-shareable: #{timeout.inspect}\n" \
|
|
62
|
+
"Use a number or Ractor.make_shareable."
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Parser validation
|
|
66
|
+
if parser && !parser.respond_to?(:call)
|
|
67
|
+
raise ArgumentError, "parser: must respond to :call"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
if RatatuiRuby::Debug.enabled? && parser && !Ractor.shareable?(parser)
|
|
71
|
+
raise Rooibos::Error::Invariant,
|
|
72
|
+
"Parser is not Ractor-shareable: #{parser.inspect}\n" \
|
|
73
|
+
"Use a frozen Method object or Ractor.make_shareable."
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Method validation
|
|
77
|
+
unless %i[get post put patch delete].include?(method)
|
|
78
|
+
raise ArgumentError, "Unsupported HTTP method: #{method.inspect}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
instance = allocate
|
|
82
|
+
instance.__send__(:initialize, method:, url:, envelope:, headers:, body:, timeout: timeout || 10, parser:)
|
|
83
|
+
instance
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Net::HTTP is blocking; no cooperative cancellation possible.
|
|
87
|
+
# Grace period = 0 means runtime can force-kill immediately.
|
|
88
|
+
def rooibos_cancellation_grace_period = 0
|
|
89
|
+
|
|
90
|
+
def self.parse_dwim_args(args, method_kw, url_kw, envelope_kw, body_kw, method_keywords)
|
|
91
|
+
# Handle keyword method shortcuts: get: 'url'
|
|
92
|
+
if method_keywords.any?
|
|
93
|
+
method_key, url = method_keywords.first
|
|
94
|
+
# Check for conflicts with method: keyword
|
|
95
|
+
if method_kw && method_kw != method_key
|
|
96
|
+
raise ArgumentError, "Conflicting method specified: #{method_key}: and method: #{method_kw}"
|
|
97
|
+
end
|
|
98
|
+
# Check for conflicts with url: keyword
|
|
99
|
+
if url_kw && url_kw != url
|
|
100
|
+
raise ArgumentError, "Conflicting url specified: #{method_key}: provides url but url: also given"
|
|
101
|
+
end
|
|
102
|
+
return [method_key, url, url, body_kw]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# If keywords provided, use them directly
|
|
106
|
+
return [method_kw, url_kw, envelope_kw, body_kw] if args.empty?
|
|
107
|
+
|
|
108
|
+
case args
|
|
109
|
+
in [String => url]
|
|
110
|
+
# Http.new('url') → GET, URL as envelope
|
|
111
|
+
[:get, url, url, body_kw]
|
|
112
|
+
in [String => url, Symbol => envelope]
|
|
113
|
+
# Http.new('url', :tag) → GET, custom envelope
|
|
114
|
+
[:get, url, envelope, body_kw]
|
|
115
|
+
in [Symbol => method, String => url]
|
|
116
|
+
# Http.new(:delete, 'url') → method, URL as envelope
|
|
117
|
+
[method, url, url, body_kw]
|
|
118
|
+
in [Symbol => method, String => url, Symbol => envelope]
|
|
119
|
+
# Http.new(:delete, 'url', :tag) → method, custom envelope
|
|
120
|
+
[method, url, envelope, body_kw]
|
|
121
|
+
in [Symbol => method, String => url, String => body]
|
|
122
|
+
# Http.new(:post, 'url', 'body') → method, URL as envelope, body
|
|
123
|
+
[method, url, url, body]
|
|
124
|
+
in [Symbol => method, String => url, String => body, Symbol => envelope]
|
|
125
|
+
# Http.new(:post, 'url', 'body', :tag) → method, custom envelope, body
|
|
126
|
+
[method, url, envelope, body]
|
|
127
|
+
else
|
|
128
|
+
raise ArgumentError, "Invalid arguments for Http.new: #{args.inspect}"
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
private_class_method :parse_dwim_args
|
|
132
|
+
|
|
133
|
+
def call(out, token)
|
|
134
|
+
return if token.canceled?
|
|
135
|
+
|
|
136
|
+
uri = URI(url)
|
|
137
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
138
|
+
http.use_ssl = uri.scheme == "https"
|
|
139
|
+
if timeout
|
|
140
|
+
http.open_timeout = timeout
|
|
141
|
+
http.read_timeout = timeout
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
klass = case method
|
|
145
|
+
when :get then Net::HTTP::Get
|
|
146
|
+
when :post then Net::HTTP::Post
|
|
147
|
+
when :put then Net::HTTP::Put
|
|
148
|
+
when :patch then Net::HTTP::Patch
|
|
149
|
+
when :delete then Net::HTTP::Delete
|
|
150
|
+
end
|
|
151
|
+
request = klass.new(uri)
|
|
152
|
+
request.body = body if body
|
|
153
|
+
headers&.each { |key, value| request[key] = value }
|
|
154
|
+
response = http.request(request)
|
|
155
|
+
|
|
156
|
+
response_body = response.body.freeze
|
|
157
|
+
response_headers = response.each_header.to_h.freeze
|
|
158
|
+
response_status = response.code.to_i
|
|
159
|
+
|
|
160
|
+
# Invoke parser with positional params if provided
|
|
161
|
+
parsed_body = if parser
|
|
162
|
+
parser.call(response_body, response_headers, response_status)
|
|
163
|
+
else
|
|
164
|
+
response_body
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Validate parsed body is Ractor-shareable in debug mode
|
|
168
|
+
if RatatuiRuby::Debug.enabled? && parser && !Ractor.shareable?(parsed_body)
|
|
169
|
+
raise Rooibos::Error::Invariant,
|
|
170
|
+
"Parsed body is not Ractor-shareable: #{parsed_body.class}\n" \
|
|
171
|
+
"Parser must return frozen/shareable data. Use .freeze or Ractor.make_shareable."
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
out.put(Ractor.make_shareable(HttpResponse.new(
|
|
175
|
+
envelope:,
|
|
176
|
+
status: response_status,
|
|
177
|
+
body: parsed_body,
|
|
178
|
+
headers: response_headers,
|
|
179
|
+
error: nil
|
|
180
|
+
)))
|
|
181
|
+
rescue => e
|
|
182
|
+
out.put(Ractor.make_shareable(HttpResponse.new(
|
|
183
|
+
envelope:,
|
|
184
|
+
status: nil,
|
|
185
|
+
body: nil,
|
|
186
|
+
headers: nil,
|
|
187
|
+
error: e.message.freeze
|
|
188
|
+
)))
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
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 Rooibos
|
|
9
|
+
module Command
|
|
10
|
+
# Coordinates command execution across the runtime.
|
|
11
|
+
#
|
|
12
|
+
# Commands run off the main thread. Both the runtime and nested commands
|
|
13
|
+
# via <tt>Outlet#source</tt> share cancellation tokens. Racing results
|
|
14
|
+
# against cancellation is repetitive. Tracking active commands is tedious.
|
|
15
|
+
#
|
|
16
|
+
# This class centralizes that logic. It races results against cancellation
|
|
17
|
+
# and timeout. Commands that ignore cancellation are orphaned until
|
|
18
|
+
# process exit. Cooperative cancellation is the only way to exit cleanly.
|
|
19
|
+
#
|
|
20
|
+
# The framework creates one instance at startup. All outlets share it.
|
|
21
|
+
class Lifecycle
|
|
22
|
+
# :nodoc: Internal representation of a tracked async command.
|
|
23
|
+
Entry = Data.define(:future, :origin)
|
|
24
|
+
|
|
25
|
+
# Creates a lifecycle manager.
|
|
26
|
+
#
|
|
27
|
+
# The runtime creates one at startup. All outlets share it. Child commands
|
|
28
|
+
# from <tt>Outlet#source</tt> inherit the same lifecycle for consistent
|
|
29
|
+
# thread management.
|
|
30
|
+
def initialize
|
|
31
|
+
@active = Concurrent::Map.new
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Runs a command synchronously, returning its result.
|
|
35
|
+
#
|
|
36
|
+
# Spawns a thread, races the result against cancellation and timeout.
|
|
37
|
+
# On cancellation, waits the grace period then kills the thread if needed.
|
|
38
|
+
#
|
|
39
|
+
# [command] Callable with <tt>call(out, token)</tt>.
|
|
40
|
+
# [token] Parent's cancellation token.
|
|
41
|
+
# [timeout] Max wait seconds for the result.
|
|
42
|
+
#
|
|
43
|
+
# Returns the child's message, or <tt>nil</tt> if cancelled or timed out.
|
|
44
|
+
# Raises if the child raised.
|
|
45
|
+
def run_sync(command, token, timeout:)
|
|
46
|
+
return nil if token.canceled?
|
|
47
|
+
|
|
48
|
+
child_channel = Concurrent::Promises::Channel.new
|
|
49
|
+
child_outlet = Outlet.new(child_channel, lifecycle: self)
|
|
50
|
+
|
|
51
|
+
exception = nil
|
|
52
|
+
Concurrent::Promises.future do
|
|
53
|
+
command.call(child_outlet, token)
|
|
54
|
+
rescue => e
|
|
55
|
+
exception = e
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Race: pop result vs cancellation vs timeout
|
|
59
|
+
pop_future = Concurrent::Promises.future { child_channel.pop(timeout, :timeout) }
|
|
60
|
+
Concurrent::Promises.any_event(pop_future, token.origin).wait
|
|
61
|
+
|
|
62
|
+
# Cooperative cancellation only — misbehaving commands are orphaned
|
|
63
|
+
return nil if token.canceled?
|
|
64
|
+
|
|
65
|
+
if exception
|
|
66
|
+
raise exception.is_a?(Exception) ? exception : RuntimeError.new(exception.to_s)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
result = pop_future.value
|
|
70
|
+
return nil if result == :timeout
|
|
71
|
+
|
|
72
|
+
result
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Runs a command asynchronously, tracking it for later cancellation.
|
|
76
|
+
#
|
|
77
|
+
# Spawns a future that executes the command. Tracks the command in the
|
|
78
|
+
# active map for cancellation support. Errors are pushed to the channel
|
|
79
|
+
# as <tt>Command::Error</tt> messages.
|
|
80
|
+
#
|
|
81
|
+
# [command] Callable with <tt>call(out, token)</tt>.
|
|
82
|
+
# [channel] Channel to push results and errors to.
|
|
83
|
+
#
|
|
84
|
+
# Returns a hash with <tt>:future</tt> and <tt>:origin</tt> for tracking.
|
|
85
|
+
def run_async(command, channel)
|
|
86
|
+
cancellation, origin = Concurrent::Cancellation.new
|
|
87
|
+
outlet = Outlet.new(channel, lifecycle: self)
|
|
88
|
+
|
|
89
|
+
future = Concurrent::Promises.future do
|
|
90
|
+
command.call(outlet, cancellation)
|
|
91
|
+
rescue => e
|
|
92
|
+
channel.push Command::Error.new(command:, exception: e)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
entry = Entry.new(future:, origin:)
|
|
96
|
+
@active[command] = entry
|
|
97
|
+
entry
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Cancels a running command, waiting for its grace period.
|
|
101
|
+
#
|
|
102
|
+
# Signals cancellation, waits for the command's grace period, then
|
|
103
|
+
# removes it from tracking. Does nothing if the command isn't tracked.
|
|
104
|
+
#
|
|
105
|
+
# [command] The command to cancel (must be the same object passed to run_async).
|
|
106
|
+
def cancel(command)
|
|
107
|
+
entry = @active[command]
|
|
108
|
+
return unless entry&.future&.pending?
|
|
109
|
+
|
|
110
|
+
entry.origin.resolve # Signal cancellation
|
|
111
|
+
|
|
112
|
+
grace = command.respond_to?(:rooibos_cancellation_grace_period) ?
|
|
113
|
+
command.rooibos_cancellation_grace_period : 0.1
|
|
114
|
+
entry.future.wait(grace.finite? ? grace : nil)
|
|
115
|
+
|
|
116
|
+
@active.delete(command)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Cancels all active commands and waits for them to complete.
|
|
120
|
+
#
|
|
121
|
+
# Iterates through all tracked commands, signals cancellation, and waits
|
|
122
|
+
# for each command's grace period. Called at runtime shutdown.
|
|
123
|
+
def shutdown
|
|
124
|
+
@active.each do |command, entry|
|
|
125
|
+
entry.origin.resolve # Signal cancellation
|
|
126
|
+
|
|
127
|
+
grace = command.respond_to?(:rooibos_cancellation_grace_period) ?
|
|
128
|
+
command.rooibos_cancellation_grace_period : 0.1
|
|
129
|
+
entry.future.wait(grace.finite? ? grace : nil)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,157 @@
|
|
|
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 "ratatui_ruby"
|
|
9
|
+
|
|
10
|
+
module Rooibos
|
|
11
|
+
module Command
|
|
12
|
+
# Messaging gateway for custom commands.
|
|
13
|
+
#
|
|
14
|
+
# Custom commands run in background threads. They produce results that the
|
|
15
|
+
# main loop consumes.
|
|
16
|
+
#
|
|
17
|
+
# Managing queues and message formats manually is tedious. It scatters queue
|
|
18
|
+
# logic across your codebase and makes mistakes easy.
|
|
19
|
+
#
|
|
20
|
+
# This class wraps the queue with a clean API. Call +put+ to send tagged
|
|
21
|
+
# messages. Debug mode validates Ractor-shareability.
|
|
22
|
+
#
|
|
23
|
+
# Use it to send results from HTTP requests, WebSocket streams, or database polls.
|
|
24
|
+
#
|
|
25
|
+
# === Example (One-Shot)
|
|
26
|
+
#
|
|
27
|
+
# Commands run in their own thread. Blocking calls work fine:
|
|
28
|
+
#
|
|
29
|
+
#--
|
|
30
|
+
# SPDX-SnippetBegin
|
|
31
|
+
# SPDX-FileCopyrightText: 2026 Kerrick Long
|
|
32
|
+
# SPDX-License-Identifier: MIT-0
|
|
33
|
+
#++
|
|
34
|
+
# class FetchUserCommand
|
|
35
|
+
# include Rooibos::Command::Custom
|
|
36
|
+
#
|
|
37
|
+
# def initialize(user_id)
|
|
38
|
+
# @user_id = user_id
|
|
39
|
+
# end
|
|
40
|
+
#
|
|
41
|
+
# def call(out, _token)
|
|
42
|
+
# response = Net::HTTP.get(URI("https://api.example.com/users/#{@user_id}"))
|
|
43
|
+
# user = JSON.parse(response)
|
|
44
|
+
# out.put(:user_fetched, Ractor.make_shareable(user: user))
|
|
45
|
+
# rescue => e
|
|
46
|
+
# out.put(:user_fetch_failed, error: e.message.freeze)
|
|
47
|
+
# end
|
|
48
|
+
# end
|
|
49
|
+
#--
|
|
50
|
+
# SPDX-SnippetEnd
|
|
51
|
+
#++
|
|
52
|
+
#
|
|
53
|
+
# === Example (Long-Running)
|
|
54
|
+
#
|
|
55
|
+
# Commands that loop check the cancellation token:
|
|
56
|
+
#
|
|
57
|
+
#--
|
|
58
|
+
# SPDX-SnippetBegin
|
|
59
|
+
# SPDX-FileCopyrightText: 2026 Kerrick Long
|
|
60
|
+
# SPDX-License-Identifier: MIT-0
|
|
61
|
+
#++
|
|
62
|
+
# class PollerCommand
|
|
63
|
+
# include Rooibos::Command::Custom
|
|
64
|
+
#
|
|
65
|
+
# def call(out, token)
|
|
66
|
+
# until token.canceled?
|
|
67
|
+
# data = fetch_batch
|
|
68
|
+
# out.put(:batch, Ractor.make_shareable(data))
|
|
69
|
+
# sleep 5
|
|
70
|
+
# end
|
|
71
|
+
# out.put(:poller_stopped)
|
|
72
|
+
# end
|
|
73
|
+
# end
|
|
74
|
+
#--
|
|
75
|
+
# SPDX-SnippetEnd
|
|
76
|
+
#++
|
|
77
|
+
class Outlet
|
|
78
|
+
# Creates an outlet for the given message queue.
|
|
79
|
+
#
|
|
80
|
+
# The runtime provides the message queue and lifecycle. Custom commands receive
|
|
81
|
+
# the outlet as their first argument.
|
|
82
|
+
#
|
|
83
|
+
# [message_queue] A <tt>Concurrent::Promises::Channel</tt> or compatible object.
|
|
84
|
+
# [lifecycle] A <tt>Lifecycle</tt> for managing nested command execution.
|
|
85
|
+
def initialize(message_queue, lifecycle:)
|
|
86
|
+
@message_queue = message_queue
|
|
87
|
+
@live = lifecycle
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# :nodoc: Internal infrastructure for nested command lifecycle sharing.
|
|
91
|
+
attr_reader :live
|
|
92
|
+
|
|
93
|
+
# Sends a message to the runtime.
|
|
94
|
+
#
|
|
95
|
+
# Custom commands produce results. Those results feed back into your
|
|
96
|
+
# update function. This method handles the wiring.
|
|
97
|
+
#
|
|
98
|
+
# Call with one argument to send it directly. Call with multiple
|
|
99
|
+
# arguments and they arrive as an array.
|
|
100
|
+
#
|
|
101
|
+
# Use it for complex data flows or transports Rooibos doesn't ship with.
|
|
102
|
+
#
|
|
103
|
+
# === Example
|
|
104
|
+
#
|
|
105
|
+
# out.put(:done) # Update receives :done
|
|
106
|
+
# out.put(current_user) # Update receives current_user
|
|
107
|
+
# out.put(:user, alice) # Update receives [:user, alice]
|
|
108
|
+
#
|
|
109
|
+
# Debug mode validates Ractor-shareability.
|
|
110
|
+
def put(*args)
|
|
111
|
+
message = (args.size == 1) ? args.first : args.freeze
|
|
112
|
+
|
|
113
|
+
if RatatuiRuby::Debug.enabled? && !Ractor.shareable?(message)
|
|
114
|
+
raise Rooibos::Error::Invariant,
|
|
115
|
+
"Message is not Ractor-shareable: #{message.inspect}\n" \
|
|
116
|
+
"Use Ractor.make_shareable or Object#freeze."
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
@message_queue.push(message)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Runs a child command synchronously within a custom command.
|
|
123
|
+
#
|
|
124
|
+
# Use this to orchestrate multi-step workflows: fetch one result, then
|
|
125
|
+
# use it to compose the next command.
|
|
126
|
+
#
|
|
127
|
+
# The child runs asynchronously in a future. This method blocks until
|
|
128
|
+
# the child calls +put+, cancellation occurs, or the timeout expires.
|
|
129
|
+
#
|
|
130
|
+
# [command] A callable (lambda or Custom command) with +call(out, token)+.
|
|
131
|
+
# [token] The parent's cancellation token, passed through to the child.
|
|
132
|
+
# [timeout] Max seconds to wait for the child's result (default: 30.0).
|
|
133
|
+
#
|
|
134
|
+
# Returns the message from the child, or +nil+ if cancelled/timed out.
|
|
135
|
+
# Raises if the child command raised an exception.
|
|
136
|
+
#
|
|
137
|
+
# === Example
|
|
138
|
+
#
|
|
139
|
+
#--
|
|
140
|
+
# SPDX-SnippetBegin
|
|
141
|
+
# SPDX-FileCopyrightText: 2026 Kerrick Long
|
|
142
|
+
# SPDX-License-Identifier: MIT-0
|
|
143
|
+
#++
|
|
144
|
+
# def call(out, token)
|
|
145
|
+
# user_result = out.source(fetch_user_cmd, token)
|
|
146
|
+
# return if user_result.nil?
|
|
147
|
+
# out.put(:user_loaded, user: user_result)
|
|
148
|
+
# end
|
|
149
|
+
#--
|
|
150
|
+
# SPDX-SnippetEnd
|
|
151
|
+
#++
|
|
152
|
+
def source(command, token, timeout: 30.0)
|
|
153
|
+
@live.run_sync(command, token, timeout:)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
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 Rooibos
|
|
9
|
+
module Command
|
|
10
|
+
# A one-shot timer command.
|
|
11
|
+
#
|
|
12
|
+
# Applications need timed events. Notification auto-dismissal,
|
|
13
|
+
# debounced search, and animation frames all depend on delays.
|
|
14
|
+
# Building timers from scratch with threads is error-prone.
|
|
15
|
+
# Cancellation is tricky.
|
|
16
|
+
#
|
|
17
|
+
# This command waits, then sends a message. It responds to
|
|
18
|
+
# cancellation cooperatively. When cancelled, it sends
|
|
19
|
+
# <tt>Command.cancel(self)</tt> so you know the timer stopped.
|
|
20
|
+
#
|
|
21
|
+
# Use it for delayed actions, debounced inputs, or animation loops.
|
|
22
|
+
#
|
|
23
|
+
# === Example: Notification dismissal
|
|
24
|
+
#
|
|
25
|
+
# def update(msg, model)
|
|
26
|
+
# case msg
|
|
27
|
+
# in :save_clicked
|
|
28
|
+
# [model.with(notification: "Saved!"), Command.wait(3.0, :dismiss)]
|
|
29
|
+
# in :dismiss
|
|
30
|
+
# [model.with(notification: nil), nil]
|
|
31
|
+
# in Command::Cancel
|
|
32
|
+
# [model.with(notification: nil), nil] # User navigated away
|
|
33
|
+
# end
|
|
34
|
+
# end
|
|
35
|
+
#
|
|
36
|
+
# === Example: Animation loop
|
|
37
|
+
#
|
|
38
|
+
# def update(msg, model)
|
|
39
|
+
# case msg
|
|
40
|
+
# in :start_animation
|
|
41
|
+
# [model.with(frame: 0), Command.tick(0.1, :animate)]
|
|
42
|
+
# in :animate
|
|
43
|
+
# frame = (model.frame + 1) % 10
|
|
44
|
+
# [model.with(frame:), Command.tick(0.1, :animate)]
|
|
45
|
+
# end
|
|
46
|
+
# end
|
|
47
|
+
Wait = Data.define(:seconds, :envelope) do
|
|
48
|
+
include Custom
|
|
49
|
+
|
|
50
|
+
# Cooperative cancellation needs no grace period.
|
|
51
|
+
# The command responds instantly to cancellation via
|
|
52
|
+
# <tt>Concurrent::Cancellation.timeout</tt>.
|
|
53
|
+
def rooibos_cancellation_grace_period
|
|
54
|
+
0
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Executes the timer.
|
|
58
|
+
#
|
|
59
|
+
# Waits for <tt>seconds</tt>, then sends <tt>TimerResponse</tt>.
|
|
60
|
+
# If cancelled, sends <tt>Command.cancel(self)</tt> instead.
|
|
61
|
+
#
|
|
62
|
+
# [out] Outlet for sending messages.
|
|
63
|
+
# [token] Cancellation token from the runtime.
|
|
64
|
+
def call(out, token)
|
|
65
|
+
start_time = Time.now
|
|
66
|
+
timer_cancellation, _origin = Concurrent::Cancellation.timeout(seconds)
|
|
67
|
+
combined = token.join(timer_cancellation)
|
|
68
|
+
combined.origin.wait
|
|
69
|
+
|
|
70
|
+
if token.canceled?
|
|
71
|
+
out.put(Command.cancel(self))
|
|
72
|
+
else
|
|
73
|
+
elapsed = Time.now - start_time
|
|
74
|
+
response = Message::Timer.new(envelope:, elapsed:)
|
|
75
|
+
out.put(Ractor.make_shareable(response))
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|