ratatui_ruby-tea 0.3.0 → 0.3.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/AGENTS.md +9 -5
- data/CHANGELOG.md +33 -0
- data/README.md +17 -0
- data/Rakefile +1 -1
- data/Steepfile +3 -3
- data/doc/contributors/design/commands_and_outlets.md +204 -0
- data/doc/contributors/kit-no-outlet.md +237 -0
- data/lib/ratatui_ruby/tea/command/cancellation_token.rb +135 -0
- data/lib/ratatui_ruby/tea/command/custom.rb +106 -0
- data/lib/ratatui_ruby/tea/command/outlet.rb +127 -0
- data/lib/ratatui_ruby/tea/command.rb +231 -9
- data/lib/ratatui_ruby/tea/router.rb +150 -82
- data/lib/ratatui_ruby/tea/runtime.rb +94 -50
- data/lib/ratatui_ruby/tea/version.rb +1 -1
- data/rbs_collection.lock.yaml +124 -0
- data/rbs_collection.yaml +15 -0
- data/sig/examples/verify_readme_usage/app.rbs +1 -1
- data/sig/examples/widget_command_system/app.rbs +1 -1
- data/sig/open3.rbs +17 -0
- data/sig/ratatui_ruby/tea/command.rbs +122 -6
- data/sig/ratatui_ruby/tea/router.rbs +110 -54
- data/sig/ratatui_ruby/tea/runtime.rbs +29 -11
- data/sig/ratatui_ruby/tea/shortcuts.rbs +18 -0
- data/sig/ratatui_ruby/tea/version.rbs +10 -0
- data/sig/ratatui_ruby/tea.rbs +19 -7
- data/tasks/steep.rake +11 -0
- metadata +14 -3
|
@@ -0,0 +1,135 @@
|
|
|
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
|
+
# Cooperative cancellation mechanism for long-running commands.
|
|
12
|
+
#
|
|
13
|
+
# Long-running commands block the event loop. Commands that poll, stream, or wait
|
|
14
|
+
# indefinitely prevent clean shutdown. Killing threads mid-operation corrupts state.
|
|
15
|
+
#
|
|
16
|
+
# This class signals cancellation requests. Commands check +cancelled?+ periodically
|
|
17
|
+
# and stop gracefully. The runtime calls +cancel!+ when shutdown begins.
|
|
18
|
+
#
|
|
19
|
+
# Use it to implement WebSocket handlers, database pollers, or any command that loops.
|
|
20
|
+
#
|
|
21
|
+
# === Example
|
|
22
|
+
#
|
|
23
|
+
#--
|
|
24
|
+
# SPDX-SnippetBegin
|
|
25
|
+
# SPDX-FileCopyrightText: 2026 Kerrick Long
|
|
26
|
+
# SPDX-License-Identifier: MIT-0
|
|
27
|
+
#++
|
|
28
|
+
# class PollerCommand
|
|
29
|
+
# include Tea::Command::Custom
|
|
30
|
+
#
|
|
31
|
+
# def call(out, token)
|
|
32
|
+
# until token.cancelled?
|
|
33
|
+
# data = fetch_batch
|
|
34
|
+
# out.put(:batch, data)
|
|
35
|
+
# sleep 5
|
|
36
|
+
# end
|
|
37
|
+
# out.put(:poller_stopped)
|
|
38
|
+
# end
|
|
39
|
+
# end
|
|
40
|
+
#--
|
|
41
|
+
# SPDX-SnippetEnd
|
|
42
|
+
#++
|
|
43
|
+
class CancellationToken
|
|
44
|
+
# Number of times +cancel!+ has been called. :nodoc:
|
|
45
|
+
#
|
|
46
|
+
# Exposed for testing thread-safety. Not part of the public API.
|
|
47
|
+
attr_reader :cancel_count
|
|
48
|
+
|
|
49
|
+
# Creates a new cancellation token in the non-cancelled state.
|
|
50
|
+
def initialize
|
|
51
|
+
@cancel_count = 0
|
|
52
|
+
@mutex = Mutex.new
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Signals cancellation. Thread-safe.
|
|
56
|
+
#
|
|
57
|
+
# Call this to request the command stop. The command checks +cancelled?+
|
|
58
|
+
# and stops at the next safe point.
|
|
59
|
+
#
|
|
60
|
+
# === Example
|
|
61
|
+
#
|
|
62
|
+
#--
|
|
63
|
+
# SPDX-SnippetBegin
|
|
64
|
+
# SPDX-FileCopyrightText: 2026 Kerrick Long
|
|
65
|
+
# SPDX-License-Identifier: MIT-0
|
|
66
|
+
#++
|
|
67
|
+
# token = CancellationToken.new
|
|
68
|
+
# token.cancel!
|
|
69
|
+
# token.cancelled? # => true
|
|
70
|
+
#--
|
|
71
|
+
# SPDX-SnippetEnd
|
|
72
|
+
#++
|
|
73
|
+
def cancel!
|
|
74
|
+
@mutex.synchronize do
|
|
75
|
+
current = @cancel_count
|
|
76
|
+
sleep 0 # Force context switch (enables thread-safety testing)
|
|
77
|
+
@cancel_count = current + 1
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Checks if cancellation was requested. Thread-safe.
|
|
82
|
+
#
|
|
83
|
+
# Commands call this periodically in their main loop. When it returns
|
|
84
|
+
# <tt>true</tt>, the command should clean up and exit.
|
|
85
|
+
#
|
|
86
|
+
# === Example
|
|
87
|
+
#
|
|
88
|
+
#--
|
|
89
|
+
# SPDX-SnippetBegin
|
|
90
|
+
# SPDX-FileCopyrightText: 2026 Kerrick Long
|
|
91
|
+
# SPDX-License-Identifier: MIT-0
|
|
92
|
+
#++
|
|
93
|
+
# until token.cancelled?
|
|
94
|
+
# do_work
|
|
95
|
+
# sleep 1
|
|
96
|
+
# end
|
|
97
|
+
#--
|
|
98
|
+
# SPDX-SnippetEnd
|
|
99
|
+
#++
|
|
100
|
+
def cancelled?
|
|
101
|
+
@cancel_count > 0
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Null object for commands that ignore cancellation.
|
|
105
|
+
#
|
|
106
|
+
# Some commands complete quickly and do not check for cancellation.
|
|
107
|
+
# Pass this when the command signature requires a token but the
|
|
108
|
+
# command does not use it.
|
|
109
|
+
#
|
|
110
|
+
# Ractor-shareable. Calling <tt>cancel!</tt> does nothing.
|
|
111
|
+
class NoneToken < Data.define(:cancelled?)
|
|
112
|
+
# Does nothing. Ignores cancellation requests.
|
|
113
|
+
#
|
|
114
|
+
# === Example
|
|
115
|
+
#
|
|
116
|
+
#--
|
|
117
|
+
# SPDX-SnippetBegin
|
|
118
|
+
# SPDX-FileCopyrightText: 2026 Kerrick Long
|
|
119
|
+
# SPDX-License-Identifier: MIT-0
|
|
120
|
+
#++
|
|
121
|
+
# CancellationToken::NONE.cancel! # => nil (no effect)
|
|
122
|
+
#--
|
|
123
|
+
# SPDX-SnippetEnd
|
|
124
|
+
#++
|
|
125
|
+
def cancel!
|
|
126
|
+
nil
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Singleton null token. Always returns <tt>cancelled? == false</tt>.
|
|
131
|
+
NONE = NoneToken.new(cancelled?: false)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
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
|
+
# Mixin for user-defined custom commands.
|
|
12
|
+
#
|
|
13
|
+
# Custom commands extend Tea with side effects: WebSockets, gRPC, database polls,
|
|
14
|
+
# background tasks. The runtime dispatches them in threads and routes results
|
|
15
|
+
# back as messages.
|
|
16
|
+
#
|
|
17
|
+
# Include this module to identify your class as a command. The runtime uses
|
|
18
|
+
# +tea_command?+ to distinguish commands from plain models. Override
|
|
19
|
+
# +tea_cancellation_grace_period+ if your cleanup takes longer than 100 milliseconds.
|
|
20
|
+
#
|
|
21
|
+
# Use it to build real-time features, long-polling connections, or background workers.
|
|
22
|
+
#
|
|
23
|
+
# === Example
|
|
24
|
+
#
|
|
25
|
+
#--
|
|
26
|
+
# SPDX-SnippetBegin
|
|
27
|
+
# SPDX-FileCopyrightText: 2026 Kerrick Long
|
|
28
|
+
# SPDX-License-Identifier: MIT-0
|
|
29
|
+
#++
|
|
30
|
+
# class WebSocketCommand
|
|
31
|
+
# include RatatuiRuby::Tea::Command::Custom
|
|
32
|
+
#
|
|
33
|
+
# def initialize(url)
|
|
34
|
+
# @url = url
|
|
35
|
+
# end
|
|
36
|
+
#
|
|
37
|
+
# def call(out, token)
|
|
38
|
+
# ws = WebSocket::Client.new(@url)
|
|
39
|
+
# ws.on_message { |msg| out.put(:ws_message, msg) }
|
|
40
|
+
# ws.connect
|
|
41
|
+
#
|
|
42
|
+
# until token.cancelled?
|
|
43
|
+
# ws.ping
|
|
44
|
+
# sleep 1
|
|
45
|
+
# end
|
|
46
|
+
#
|
|
47
|
+
# ws.close
|
|
48
|
+
# end
|
|
49
|
+
#
|
|
50
|
+
# # WebSocket close handshake needs extra time
|
|
51
|
+
# def tea_cancellation_grace_period
|
|
52
|
+
# 5.0
|
|
53
|
+
# end
|
|
54
|
+
# end
|
|
55
|
+
#--
|
|
56
|
+
# SPDX-SnippetEnd
|
|
57
|
+
#++
|
|
58
|
+
module Custom
|
|
59
|
+
# Brand predicate for command identification.
|
|
60
|
+
#
|
|
61
|
+
# The runtime calls this to distinguish commands from plain models.
|
|
62
|
+
# Returns <tt>true</tt> unconditionally.
|
|
63
|
+
#
|
|
64
|
+
# You do not need to override this method.
|
|
65
|
+
def tea_command?
|
|
66
|
+
true
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Cleanup time after cancellation is requested. In seconds.
|
|
70
|
+
#
|
|
71
|
+
# When the runtime cancels your command (app exit, navigation, explicit cancel),
|
|
72
|
+
# it calls <tt>token.cancel!</tt> and waits this long for your command to stop.
|
|
73
|
+
# If your command does not exit within this window, it is force-killed.
|
|
74
|
+
#
|
|
75
|
+
# *This is NOT a lifetime limit.* Your command runs indefinitely until cancelled.
|
|
76
|
+
# A WebSocket open for 15 minutes is fine. This timeout only applies to the
|
|
77
|
+
# cleanup phase after cancellation is requested.
|
|
78
|
+
#
|
|
79
|
+
# Override this method to specify how long your cleanup takes:
|
|
80
|
+
#
|
|
81
|
+
# - <tt>0.5</tt> — Quick HTTP abort, no cleanup needed
|
|
82
|
+
# - <tt>2.0</tt> — Default, suitable for most commands
|
|
83
|
+
# - <tt>5.0</tt> — WebSocket close handshake with remote server
|
|
84
|
+
# - <tt>Float::INFINITY</tt> — Never force-kill (database transactions)
|
|
85
|
+
#
|
|
86
|
+
# === Example
|
|
87
|
+
#
|
|
88
|
+
#--
|
|
89
|
+
# SPDX-SnippetBegin
|
|
90
|
+
# SPDX-FileCopyrightText: 2026 Kerrick Long
|
|
91
|
+
# SPDX-License-Identifier: MIT-0
|
|
92
|
+
#++
|
|
93
|
+
# # Database transactions should never be interrupted mid-write
|
|
94
|
+
# def tea_cancellation_grace_period
|
|
95
|
+
# Float::INFINITY
|
|
96
|
+
# end
|
|
97
|
+
#--
|
|
98
|
+
# SPDX-SnippetEnd
|
|
99
|
+
#++
|
|
100
|
+
def tea_cancellation_grace_period
|
|
101
|
+
0.1
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
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 RatatuiRuby
|
|
11
|
+
module Tea
|
|
12
|
+
module Command
|
|
13
|
+
# Messaging gateway for custom commands.
|
|
14
|
+
#
|
|
15
|
+
# Custom commands run in background threads. They produce results that the
|
|
16
|
+
# main loop consumes.
|
|
17
|
+
#
|
|
18
|
+
# Managing queues and message formats manually is tedious. It scatters queue
|
|
19
|
+
# logic across your codebase and makes mistakes easy.
|
|
20
|
+
#
|
|
21
|
+
# This class wraps the queue with a clean API. Call +put+ to send tagged
|
|
22
|
+
# messages. Debug mode validates Ractor-shareability.
|
|
23
|
+
#
|
|
24
|
+
# Use it to send results from HTTP requests, WebSocket streams, or database polls.
|
|
25
|
+
#
|
|
26
|
+
# === Example (One-Shot)
|
|
27
|
+
#
|
|
28
|
+
# Commands run in their own thread. Blocking calls work fine:
|
|
29
|
+
#
|
|
30
|
+
#--
|
|
31
|
+
# SPDX-SnippetBegin
|
|
32
|
+
# SPDX-FileCopyrightText: 2026 Kerrick Long
|
|
33
|
+
# SPDX-License-Identifier: MIT-0
|
|
34
|
+
#++
|
|
35
|
+
# class FetchUserCommand
|
|
36
|
+
# include Tea::Command::Custom
|
|
37
|
+
#
|
|
38
|
+
# def initialize(user_id)
|
|
39
|
+
# @user_id = user_id
|
|
40
|
+
# end
|
|
41
|
+
#
|
|
42
|
+
# def call(out, _token)
|
|
43
|
+
# response = Net::HTTP.get(URI("https://api.example.com/users/#{@user_id}"))
|
|
44
|
+
# user = JSON.parse(response)
|
|
45
|
+
# out.put(:user_fetched, Ractor.make_shareable(user: user))
|
|
46
|
+
# rescue => e
|
|
47
|
+
# out.put(:user_fetch_failed, error: e.message.freeze)
|
|
48
|
+
# end
|
|
49
|
+
# end
|
|
50
|
+
#--
|
|
51
|
+
# SPDX-SnippetEnd
|
|
52
|
+
#++
|
|
53
|
+
#
|
|
54
|
+
# === Example (Long-Running)
|
|
55
|
+
#
|
|
56
|
+
# Commands that loop check the cancellation token:
|
|
57
|
+
#
|
|
58
|
+
#--
|
|
59
|
+
# SPDX-SnippetBegin
|
|
60
|
+
# SPDX-FileCopyrightText: 2026 Kerrick Long
|
|
61
|
+
# SPDX-License-Identifier: MIT-0
|
|
62
|
+
#++
|
|
63
|
+
# class PollerCommand
|
|
64
|
+
# include Tea::Command::Custom
|
|
65
|
+
#
|
|
66
|
+
# def call(out, token)
|
|
67
|
+
# until token.cancelled?
|
|
68
|
+
# data = fetch_batch
|
|
69
|
+
# out.put(:batch, Ractor.make_shareable(data))
|
|
70
|
+
# sleep 5
|
|
71
|
+
# end
|
|
72
|
+
# out.put(:poller_stopped)
|
|
73
|
+
# end
|
|
74
|
+
# end
|
|
75
|
+
#--
|
|
76
|
+
# SPDX-SnippetEnd
|
|
77
|
+
#++
|
|
78
|
+
class Outlet
|
|
79
|
+
# Creates an outlet for the given queue.
|
|
80
|
+
#
|
|
81
|
+
# The runtime provides the queue. Custom commands receive the outlet as
|
|
82
|
+
# their first argument.
|
|
83
|
+
#
|
|
84
|
+
# [queue] A <tt>Thread::Queue</tt> or compatible object.
|
|
85
|
+
def initialize(queue)
|
|
86
|
+
@queue = queue
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Sends a tagged message to the runtime.
|
|
90
|
+
#
|
|
91
|
+
# Builds an array <tt>[tag, *payload]</tt> and pushes it to the queue.
|
|
92
|
+
# The update function pattern-matches on the tag.
|
|
93
|
+
#
|
|
94
|
+
# Debug mode validates Ractor-shareability. It raises <tt>Error::Invariant</tt>
|
|
95
|
+
# if the message is not shareable. Production skips this check.
|
|
96
|
+
#
|
|
97
|
+
# [tag] Symbol identifying the message type.
|
|
98
|
+
# [payload] Additional arguments. Freeze them or use <tt>Ractor.make_shareable</tt>.
|
|
99
|
+
#
|
|
100
|
+
# === Example
|
|
101
|
+
#
|
|
102
|
+
#--
|
|
103
|
+
# SPDX-SnippetBegin
|
|
104
|
+
# SPDX-FileCopyrightText: 2026 Kerrick Long
|
|
105
|
+
# SPDX-License-Identifier: MIT-0
|
|
106
|
+
#++
|
|
107
|
+
# out.put(:user_fetched, user: Ractor.make_shareable(user))
|
|
108
|
+
# out.put(:error, message: "Connection failed".freeze)
|
|
109
|
+
# out.put(:progress, percent: 42) # Integers are always shareable
|
|
110
|
+
#--
|
|
111
|
+
# SPDX-SnippetEnd
|
|
112
|
+
#++
|
|
113
|
+
def put(tag, *payload)
|
|
114
|
+
message = [tag, *payload].freeze
|
|
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
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -5,6 +5,10 @@
|
|
|
5
5
|
# SPDX-License-Identifier: LGPL-3.0-or-later
|
|
6
6
|
#++
|
|
7
7
|
|
|
8
|
+
require_relative "command/cancellation_token"
|
|
9
|
+
require_relative "command/custom"
|
|
10
|
+
require_relative "command/outlet"
|
|
11
|
+
|
|
8
12
|
module RatatuiRuby
|
|
9
13
|
module Tea
|
|
10
14
|
# Commands represent side effects.
|
|
@@ -50,12 +54,86 @@ module RatatuiRuby
|
|
|
50
54
|
Exit.new
|
|
51
55
|
end
|
|
52
56
|
|
|
53
|
-
#
|
|
57
|
+
# Sentinel value for command cancellation.
|
|
58
|
+
#
|
|
59
|
+
# Long-running commands (WebSocket listeners, database pollers) run until stopped.
|
|
60
|
+
# Stopping them requires signaling from outside the command. The runtime tracks
|
|
61
|
+
# active commands by their object identity and routes cancel requests.
|
|
62
|
+
#
|
|
63
|
+
# This type carries the handle (command object) to cancel. The runtime pattern-matches
|
|
64
|
+
# on <tt>Command::Cancel</tt> and signals the token.
|
|
65
|
+
Cancel = Data.define(:handle)
|
|
66
|
+
|
|
67
|
+
# Request cancellation of a running command.
|
|
68
|
+
#
|
|
69
|
+
# The model stores the command handle (the command object itself). Returning
|
|
70
|
+
# <tt>Command.cancel(handle)</tt> signals the runtime to stop it.
|
|
71
|
+
#
|
|
72
|
+
# [handle] The command object to cancel.
|
|
73
|
+
#
|
|
74
|
+
# === Example
|
|
75
|
+
#
|
|
76
|
+
# # Dispatch and store handle
|
|
77
|
+
# cmd = FetchData.new(url)
|
|
78
|
+
# [model.with(active_fetch: cmd), cmd]
|
|
79
|
+
#
|
|
80
|
+
# # User clicks cancel
|
|
81
|
+
# when :cancel_clicked
|
|
82
|
+
# [model.with(active_fetch: nil), Command.cancel(model.active_fetch)]
|
|
83
|
+
def self.cancel(handle)
|
|
84
|
+
Cancel.new(handle:)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Sentinel value for command errors.
|
|
88
|
+
#
|
|
89
|
+
# Commands run in threads. Exceptions bubble up silently. The update function
|
|
90
|
+
# never sees them, and backtraces in STDERR corrupt the TUI display.
|
|
91
|
+
#
|
|
92
|
+
# The runtime catches exceptions and pushes <tt>Error</tt> to the queue.
|
|
93
|
+
# Pattern match on it in your update function.
|
|
94
|
+
#
|
|
95
|
+
# Analogous to <tt>Exit</tt> and <tt>Cancel</tt>.
|
|
96
|
+
Error = Data.define(:command, :exception)
|
|
97
|
+
|
|
98
|
+
# Creates an error sentinel.
|
|
99
|
+
#
|
|
100
|
+
# The runtime produces this automatically when a command raises.
|
|
101
|
+
# Use this factory for testing or for commands that want to signal
|
|
102
|
+
# error completion without raising.
|
|
103
|
+
#
|
|
104
|
+
# [command] The command that failed.
|
|
105
|
+
# [exception] The exception that was raised.
|
|
106
|
+
#
|
|
107
|
+
# === Example
|
|
108
|
+
#
|
|
109
|
+
# def update(message, model)
|
|
110
|
+
# case message
|
|
111
|
+
# in Command::Error(command:, exception:)
|
|
112
|
+
# model.with(error: "#{command.class} failed: #{exception.message}")
|
|
113
|
+
# end
|
|
114
|
+
# end
|
|
115
|
+
def self.error(command, exception)
|
|
116
|
+
Error.new(command:, exception:)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Runs a shell command and routes its output back as messages.
|
|
120
|
+
#
|
|
121
|
+
# Apps run external tools: linters, compilers, scripts, system utilities.
|
|
122
|
+
# The runtime dispatches the command in a thread, so the UI stays responsive.
|
|
123
|
+
# Batch mode (default) waits for completion; streaming mode shows output live.
|
|
124
|
+
# Orphaned child processes linger and waste resources, so cancellation sends
|
|
125
|
+
# <tt>SIGTERM</tt> for graceful shutdown, then <tt>SIGKILL</tt> to prevent orphans.
|
|
126
|
+
#
|
|
127
|
+
# Use it to run builds, lint files, execute scripts, or invoke any CLI tool.
|
|
128
|
+
#
|
|
129
|
+
# === Batch Mode (default)
|
|
54
130
|
#
|
|
55
|
-
#
|
|
56
|
-
#
|
|
131
|
+
# A single message arrives when the command finishes:
|
|
132
|
+
# <tt>[tag, {stdout:, stderr:, status:}]</tt>
|
|
57
133
|
#
|
|
58
|
-
#
|
|
134
|
+
# === Streaming Mode
|
|
135
|
+
#
|
|
136
|
+
# Messages arrive incrementally:
|
|
59
137
|
# - <tt>[tag, :stdout, line]</tt> for each stdout line
|
|
60
138
|
# - <tt>[tag, :stderr, line]</tt> for each stderr line
|
|
61
139
|
# - <tt>[tag, :complete, {status:}]</tt> when the command finishes
|
|
@@ -63,10 +141,87 @@ module RatatuiRuby
|
|
|
63
141
|
#
|
|
64
142
|
# The <tt>status</tt> is the integer exit code (0 = success).
|
|
65
143
|
System = Data.define(:command, :tag, :stream) do
|
|
144
|
+
# Command identification — runtime uses this to dispatch as a command.
|
|
145
|
+
def tea_command?
|
|
146
|
+
true
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Grace period for cleanup after cancellation.
|
|
150
|
+
def tea_cancellation_grace_period
|
|
151
|
+
0.1
|
|
152
|
+
end
|
|
153
|
+
|
|
66
154
|
# Returns true if streaming mode is enabled.
|
|
67
155
|
def stream?
|
|
68
156
|
stream
|
|
69
157
|
end
|
|
158
|
+
|
|
159
|
+
# Executes the shell command and sends results via outlet.
|
|
160
|
+
#
|
|
161
|
+
# In batch mode, sends a single message with all output.
|
|
162
|
+
# In streaming mode, sends incremental messages as output arrives.
|
|
163
|
+
# Respects cancellation token by sending SIGTERM (then SIGKILL) to child.
|
|
164
|
+
def call(out, token)
|
|
165
|
+
require "open3"
|
|
166
|
+
|
|
167
|
+
if stream?
|
|
168
|
+
stream_execution(out, token)
|
|
169
|
+
else
|
|
170
|
+
batch_execution(out)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
private def batch_execution(out)
|
|
175
|
+
stdout, stderr, status = Open3.capture3(command)
|
|
176
|
+
out.put(tag, Ractor.make_shareable({ stdout:, stderr:, status: status.exitstatus }))
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
private def stream_execution(out, token)
|
|
180
|
+
Open3.popen3(command) do |stdin, stdout, stderr, wait_thr|
|
|
181
|
+
stdin.close
|
|
182
|
+
pid = wait_thr.pid
|
|
183
|
+
|
|
184
|
+
stdout_thread = Thread.new do
|
|
185
|
+
stdout.each_line { |line| out.put(tag, :stdout, line.freeze) }
|
|
186
|
+
rescue IOError
|
|
187
|
+
# Stream closed - SIGKILL the child if still alive (forcible cleanup)
|
|
188
|
+
begin
|
|
189
|
+
Process.kill("KILL", pid) if wait_thr.alive?
|
|
190
|
+
rescue Errno::ESRCH
|
|
191
|
+
# Already dead
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
stderr_thread = Thread.new do
|
|
195
|
+
stderr.each_line { |line| out.put(tag, :stderr, line.freeze) }
|
|
196
|
+
rescue IOError
|
|
197
|
+
# Stream closed
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Cooperative cancellation: SIGTERM when token is cancelled
|
|
201
|
+
cancellation_watcher = Thread.new do
|
|
202
|
+
sleep 0.01 until token.cancelled? || !wait_thr.alive?
|
|
203
|
+
if token.cancelled? && wait_thr.alive?
|
|
204
|
+
begin
|
|
205
|
+
Process.kill("TERM", pid)
|
|
206
|
+
rescue Errno::ESRCH
|
|
207
|
+
# Already dead
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
wait_thr.join
|
|
213
|
+
|
|
214
|
+
# Child exited; clean up threads
|
|
215
|
+
stdout_thread.kill
|
|
216
|
+
stderr_thread.kill
|
|
217
|
+
cancellation_watcher.kill
|
|
218
|
+
|
|
219
|
+
status = wait_thr.value.exitstatus
|
|
220
|
+
out.put(tag, :complete, Ractor.make_shareable({ status: }))
|
|
221
|
+
end
|
|
222
|
+
rescue Errno::ENOENT, Errno::EACCES => e
|
|
223
|
+
out.put(tag, :error, Ractor.make_shareable({ message: e.message }))
|
|
224
|
+
end
|
|
70
225
|
end
|
|
71
226
|
|
|
72
227
|
# Creates a shell execution command.
|
|
@@ -114,12 +269,46 @@ module RatatuiRuby
|
|
|
114
269
|
System.new(command:, tag:, stream:)
|
|
115
270
|
end
|
|
116
271
|
|
|
117
|
-
#
|
|
272
|
+
# Wraps another command's result with a transformation.
|
|
118
273
|
#
|
|
119
|
-
# Fractal Architecture requires composition. Child bags produce commands
|
|
120
|
-
# Parent bags
|
|
121
|
-
#
|
|
122
|
-
|
|
274
|
+
# Fractal Architecture requires composition. Child bags produce commands
|
|
275
|
+
# with their own tags. Parent bags need those results routed back with
|
|
276
|
+
# a parent prefix. Without transformation, update functions become
|
|
277
|
+
# monolithic "God Reducers" that know about every child's internals.
|
|
278
|
+
#
|
|
279
|
+
# This command wraps an inner command and transforms its result message.
|
|
280
|
+
# The parent bag delegates to the child, then intercepts the result and
|
|
281
|
+
# adds its routing prefix. Clean separation. No coupling.
|
|
282
|
+
#
|
|
283
|
+
# Use it to compose child bags that return their own commands.
|
|
284
|
+
Mapped = Data.define(:inner_command, :mapper) do
|
|
285
|
+
# Command identification for runtime dispatch.
|
|
286
|
+
def tea_command? = true
|
|
287
|
+
|
|
288
|
+
# Grace period delegates to inner command.
|
|
289
|
+
def tea_cancellation_grace_period
|
|
290
|
+
inner_command.respond_to?(:tea_cancellation_grace_period) ?
|
|
291
|
+
inner_command.tea_cancellation_grace_period : 0.1
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Executes the inner command, waits for result, and transforms it.
|
|
295
|
+
def call(out, token)
|
|
296
|
+
inner_queue = Queue.new
|
|
297
|
+
inner_outlet = Outlet.new(inner_queue)
|
|
298
|
+
|
|
299
|
+
# Dispatch inner command
|
|
300
|
+
if inner_command.respond_to?(:call)
|
|
301
|
+
inner_command.call(inner_outlet, token)
|
|
302
|
+
else
|
|
303
|
+
raise ArgumentError, "Inner command must respond to #call"
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# Transform result and send
|
|
307
|
+
inner_message = inner_queue.pop
|
|
308
|
+
transformed = mapper.call(inner_message)
|
|
309
|
+
out.put(*transformed)
|
|
310
|
+
end
|
|
311
|
+
end
|
|
123
312
|
|
|
124
313
|
# Creates a mapped command for Fractal Architecture composition.
|
|
125
314
|
#
|
|
@@ -140,6 +329,39 @@ module RatatuiRuby
|
|
|
140
329
|
def self.map(inner_command, &mapper)
|
|
141
330
|
Mapped.new(inner_command:, mapper:)
|
|
142
331
|
end
|
|
332
|
+
|
|
333
|
+
# Gives a callable unique identity for cancellation.
|
|
334
|
+
#
|
|
335
|
+
# Reusable procs and lambdas share identity. Dispatch them twice, and
|
|
336
|
+
# +Command.cancel+ would cancel both. Wrap them to get distinct handles.
|
|
337
|
+
#
|
|
338
|
+
# [callable] Proc, lambda, or any object responding to +call(out, token)+.
|
|
339
|
+
# If omitted, the block is used.
|
|
340
|
+
# [grace_period] Cleanup time override. Default: 2.0 seconds.
|
|
341
|
+
#
|
|
342
|
+
# === Example
|
|
343
|
+
#
|
|
344
|
+
# # With callable
|
|
345
|
+
# cmd = Command.custom(->(out, token) { out.put(:fetched, data) })
|
|
346
|
+
#
|
|
347
|
+
# # With block
|
|
348
|
+
# cmd = Command.custom(grace_period: 5.0) do |out, token|
|
|
349
|
+
# until token.cancelled?
|
|
350
|
+
# out.put(:tick, Time.now)
|
|
351
|
+
# sleep 1
|
|
352
|
+
# end
|
|
353
|
+
# end
|
|
354
|
+
def self.custom(callable = nil, grace_period: nil, &block)
|
|
355
|
+
Wrapped.new(callable: callable || block, grace_period:)
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# :nodoc:
|
|
359
|
+
Wrapped = Data.define(:callable, :grace_period) do
|
|
360
|
+
include Custom
|
|
361
|
+
def tea_cancellation_grace_period = grace_period || super
|
|
362
|
+
def call(out, token) = callable.call(out, token)
|
|
363
|
+
end
|
|
364
|
+
private_constant :Wrapped
|
|
143
365
|
end
|
|
144
366
|
end
|
|
145
367
|
end
|