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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fa0b4786fb2cf3f47481a630d0629da5870f2bcb5b2791a568760b3a20393724
|
|
4
|
+
data.tar.gz: 2e3064cfc39f24ea43ae1dc374d64b501723ca0c70d734446ba153388ee369d7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e3e093cdb72ceeb471c70e4ca2bfc8910a73229e514608a79873be5c3ab8acd414023ab0343dfc79546a8104c782a17d2dd770a6b8a054dc9e61a3646a9be5cf
|
|
7
|
+
data.tar.gz: af6f46ba59e0539b0d09025c63e9ca36df0035d000bced8fb4952065e5e4d275775ebdbca71afb53862063536f43b67e2574f00dd5aa53ba917af18c5e9bb560
|
data/AGENTS.md
CHANGED
|
@@ -34,8 +34,8 @@ Description: Part of the RatatuiRuby ecosystem.
|
|
|
34
34
|
### Tea-Specific Vocabulary
|
|
35
35
|
|
|
36
36
|
- **BANNED WORD: "component"** — Reserved for Kit.
|
|
37
|
-
- **Avoid "widget" for Tea units** — "Widget" refers to Engine/Ratatui render primitives. In Tea, call them **
|
|
38
|
-
- **
|
|
37
|
+
- **Avoid "widget" for Tea units** — "Widget" refers to Engine/Ratatui render primitives. In Tea, call them **fragments**.
|
|
38
|
+
- **Fragment:** A module containing `Model`, `INITIAL`, `UPDATE`, and `VIEW` constants. Fragments compose: parent fragments delegate to child fragments.
|
|
39
39
|
- Use "model", "update", "view" for the MVU pattern. Use "message" (not "msg") and "command" (not "cmd").
|
|
40
40
|
|
|
41
41
|
### Ruby Standards
|
|
@@ -66,3 +66,43 @@ Before considering a task complete and returning control to the user, you **MUST
|
|
|
66
66
|
4. **Commit Message Suggested:** You **MUST** ensure the final message to the user includes a suggested commit message block. This is NOT optional.
|
|
67
67
|
- You MUST also check `git log -n1` to see the current standard AI footer ("Generated with" and "Co-Authored-By") and include it in your suggested message.
|
|
68
68
|
|
|
69
|
+
## 4. Committing
|
|
70
|
+
|
|
71
|
+
- Who commits: DON'T stage (DON'T `git add`) unless explicitly instructed. DON'T commit unless explicitly instructed. DO suggest a commit message when you finish, even if not instructed..
|
|
72
|
+
- When: Before reporting the task as complete to the user, suggest the commit message.
|
|
73
|
+
- What: Consider not what you remember, but EVERYTHING in the `git diff` and `git diff --cached`.
|
|
74
|
+
- **Format:**
|
|
75
|
+
- Format: Use [Conventional Commits](https://www.conventionalcommits.org/).
|
|
76
|
+
- Body: Explanation if necessary (wrap at 72 chars).
|
|
77
|
+
- Explain why this is the implementation, as opposed to other possible implementations.
|
|
78
|
+
- Skip the body entirely if it's rote, a duplication of the diff, or otherwise unhelpful.
|
|
79
|
+
- **DON'T list the files changed or the edits made in the body.** Don't provide a bulleted list of changes. Use prose to explain the problem and the solution.
|
|
80
|
+
- **DON'T use markdown syntax** (no backticks, no bolding, no lists, no links). The commit message must be plain text.
|
|
81
|
+
- **Type conventions by directory:**
|
|
82
|
+
- `lib/`, `ext/`, `sig/`: Use `feat`, `fix`, `refactor`, `perf` as appropriate.
|
|
83
|
+
- `bin/`, `tasks/`, `.builds/`, CI/CD: Use `chore` for tooling internal to developing this gem. Use `feat`/`fix` for user-facing executables or changes that affect downstream users.
|
|
84
|
+
- `examples/`: Always `docs` (documentation by example).
|
|
85
|
+
- `test/`: Use `test` for new/changed tests, or match the type of the code being tested.
|
|
86
|
+
- `doc/`: Always `docs`.
|
|
87
|
+
|
|
88
|
+
### 5. Changelog
|
|
89
|
+
|
|
90
|
+
- Follow [Semantic Versioning](https://semver.org/)
|
|
91
|
+
- Follow the [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) specification.
|
|
92
|
+
- **What belongs in CHANGELOG:** Only changes that affect **application developers** or **higher-level library developers** who use or depend on `ratatui_ruby`:
|
|
93
|
+
- New public APIs or widget parameters
|
|
94
|
+
- Backwards-incompatible type signature changes, or behavioral additions to type signature changes
|
|
95
|
+
- Observable behavior changes (rendering, styling, layout)
|
|
96
|
+
- Deprecations and removals
|
|
97
|
+
- Breaking changes
|
|
98
|
+
- **What does NOT belong in CHANGELOG:** Internal or non-behavioral changes that don't affect downstream users:
|
|
99
|
+
- Test additions or improvements
|
|
100
|
+
- Documentation updates, RDoc fixes, markdown clarifications
|
|
101
|
+
- Refactors of internal code
|
|
102
|
+
- New or modified example code
|
|
103
|
+
- Internal tooling, CI/CD, or build configuration changes
|
|
104
|
+
- Code style or linting changes
|
|
105
|
+
- Performance improvements that affect applications
|
|
106
|
+
- Changelogs should be useful to downstream developers (both app and library developers), not simple restatements of diffs or commit messages.
|
|
107
|
+
- The Unreleased section MUST be considered "since the last git tag". Therefore, if a change was done in one commit and undone in another (both since the last tag), the second commit should remove its changelog entry.
|
|
108
|
+
- **Location:** New entries ALWAYS go in `## [Unreleased]`. Never edit past version sections (e.g., `## [0.4.0]`)—those are frozen history.
|
data/CHANGELOG.md
CHANGED
|
@@ -21,6 +21,81 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
21
21
|
|
|
22
22
|
### Removed
|
|
23
23
|
|
|
24
|
+
## [0.4.0] - 2026-01-16
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- **Timer Commands**: `Command.wait(seconds, tag)` and `Command.tick(seconds, tag)` for timed events. After the duration, sends `tag` to the update function. Responds to cancellation cooperatively — sends `Command.cancel(self)` when cancelled so you can handle it. Uses `Concurrent::Cancellation.timeout` internally. `Command.tick` is an alias for `Command.wait`; the "recurring tick" pattern is achieved by re-dispatching in the update function.
|
|
29
|
+
|
|
30
|
+
- **Command.uncancellable Factory**: Creates a fresh `Concurrent::Cancellation` that never fires. Use for commands wrapping non-cancellable blocking I/O (e.g., `Net::HTTP` requests).
|
|
31
|
+
|
|
32
|
+
- **Command.batch**: Fire-and-forget parallel execution. `Command.batch(cmd1, cmd2)` or `Command.batch([cmds])` runs children in parallel; each child sends its own messages independently. On cancellation, emits `Command.cancel(self)` so you can detect the batch was stopped. Child errors surface as `Command::Error` so you can handle failures in your update function. One failing child does not stop the others. Requires all child commands to be Ractor-shareable.
|
|
33
|
+
|
|
34
|
+
- **Command.all**: Aggregating parallel execution. `Command.all(:tag, cmd1, cmd2)` or `Command.all(:tag, [cmds])` runs children in parallel and waits for all to complete, then sends a single aggregated message. Array syntax produces `[:tag, [results]]`; variadic syntax splats results as `[:tag, result1, result2]`. On cancellation, emits `Command.cancel(self)`. Child errors surface as `Command::Error`. Requires all child commands to be Ractor-shareable.
|
|
35
|
+
|
|
36
|
+
- **Outlet#source**: Command composition for multi-step workflows. `out.source(child_command, token, timeout: 30.0)` runs a child command synchronously within a custom command, returning its result (or `nil` if cancelled/timed out). Exceptions from failed children propagate to the caller. Use this to orchestrate sequential API calls, conditional fetches, or any workflow that needs one result before starting the next.
|
|
37
|
+
|
|
38
|
+
- **Command.http**: Native HTTP client for API calls. Supports GET, POST, PUT, PATCH, DELETE with DWIM syntax: `Command.http(get: 'url')`, `Command.http(:get, 'url', :tag)`, etc. Returns hash-based `HttpResponse` with `deconstruct_keys` for pattern matching: `{ type: :http, envelope:, status:, body:, headers: }` or `{ type: :http, envelope:, error: }`. Optional `parser:` keyword invokes a callable on the response body for JSON, YAML, CSV, or custom parsing. SSL, default 10s timeout, and cancellation-before-request are supported. Parsers and parsed results must be Ractor-shareable.
|
|
39
|
+
|
|
40
|
+
- **Streaming Command Data Loss Fix**: Fixed race condition in `Command.system(stream: true)` where fast commands could lose stdout/stderr data. Reader threads now `join` instead of `kill` to ensure all output is processed before completion.
|
|
41
|
+
|
|
42
|
+
- **Message::Predicates Mixin**: Include in custom message types for safe predicate calls. Returns `false` for any unknown predicate method (ending in `?`). Enables pattern matching workflows where messages can respond to predicates like `msg.http?` or `msg.timer?` without raising `NoMethodError`. Includes `respond_to_missing?` for introspection parity.
|
|
43
|
+
|
|
44
|
+
- **Message::Timer**: Predicate-rich response type for timer commands (`Command.wait`, `Command.tick`). Includes `timer?` predicate and `deconstruct_keys` for pattern matching with `type: :timer` discriminator. Contains `envelope` (routing symbol) and `elapsed` (actual wait time in seconds).
|
|
45
|
+
|
|
46
|
+
- **Message::HttpResponse**: Moved from `Command::HttpResponse` to `Message::HttpResponse` with added predicates. Includes `http?`, `success?`, and `error?` predicates. Implements `deconstruct_keys` for pattern matching with `type: :http` discriminator.
|
|
47
|
+
|
|
48
|
+
- **Message::System::Batch**: Response type for `Command.system` (batch mode). Includes `system?`, `success?` (exit 0), and `error?` (non-zero exit) predicates. Contains `envelope`, `stdout`, `stderr`, and `status`. Implements `deconstruct_keys` for pattern matching.
|
|
49
|
+
|
|
50
|
+
- **Message::System::Stream**: Response type for `Command.system(..., stream: true)`. Includes `system?`, `stdout?`, `stderr?`, and `complete?` predicates. Contains `envelope`, `stream` type (`:stdout`, `:stderr`, `:complete`), `content` (for output lines), and `status` (for completion). Implements conditional `deconstruct_keys` based on stream type.
|
|
51
|
+
|
|
52
|
+
- **Message::All**: Response type for `Command.all` aggregating parallel execution. Includes `all?` predicate. Contains `envelope`, `results` array, and `nested` boolean. Implements `deconstruct_keys` for pattern matching.
|
|
53
|
+
|
|
54
|
+
- **Command Parameter Rename**: Renamed `tag` parameter to `envelope` across all commands for consistency: `Command.wait`, `Command.tick`, `Command.system`, and `Command.all`. These now use `envelope:` in their data definitions. Messages emit with `envelope:` for pattern matching.
|
|
55
|
+
|
|
56
|
+
- **Dependencies**: Added `concurrent-ruby` (~> 1.3) and `concurrent-ruby-edge` (~> 0.7) for robust concurrency primitives.
|
|
57
|
+
|
|
58
|
+
- **Tea.normalize_init Helper**: New `RatatuiRuby::Tea.normalize_init(result)` normalizes Init callable returns. Accepts the output of a `Fragment.Init` callable and always returns `[model, command]`. Use when composing child fragment initialization in parent fragments.
|
|
59
|
+
|
|
60
|
+
### Changed
|
|
61
|
+
|
|
62
|
+
- **Terminology: "Bag" → "Fragment"**: Renamed Fractal Architecture units from "bags" to "fragments" throughout the codebase. A fragment is a module containing `Model`, `INITIAL`, `UPDATE`, and `VIEW` constants. Parent fragments compose child fragments via routing. The `examples/app_fractal_dashboard/bags/` directory is now `examples/app_fractal_dashboard/fragments/`. All API documentation, code comments, and examples updated to reflect this terminology change.
|
|
63
|
+
|
|
64
|
+
- **Runtime API Signature (Breaking)**: `Tea.run` signature changed:
|
|
65
|
+
- Fragment parameter is now **positional** instead of keyword: `Tea.run(MyApp)` instead of `Tea.run(fragment: MyApp)`
|
|
66
|
+
- Removed `argv:` and `env:` parameters - Runtime now automatically uses `ARGV` and `ENV` globals
|
|
67
|
+
- Added `fps:` parameter (default 60) for configurable frame rate
|
|
68
|
+
- Renamed `init:` parameter to `command:` in explicit parameters API for clarity
|
|
69
|
+
|
|
70
|
+
- **Fragment Convention Rename (Breaking)**: Fragment constants have new naming conventions:
|
|
71
|
+
- `INITIAL` constant → `Init` callable. The runtime calls `Init.()` to get the initial model.
|
|
72
|
+
- `UPDATE` constant → `Update` callable (capitalized).
|
|
73
|
+
- `VIEW` constant → `View` callable (capitalized).
|
|
74
|
+
- Init is now a lambda/callable instead of a frozen constant. This enables parameterized initialization and returning `[model, command]` tuples for initial commands.
|
|
75
|
+
|
|
76
|
+
- **Internal Method Rename (Breaking)**: `Runtime.normalize_update_result` renamed to `Runtime.normalize_update_return` for clarity. Only affects code calling private Runtime internals.
|
|
77
|
+
|
|
78
|
+
- **Command.custom Ractor Validation (Breaking)**: `Command.custom(callable)` now validates in debug mode that the callable is Ractor-shareable. Callables that capture mutable state will raise `Invariant`. Define callables at module level or use `Ractor.make_shareable`.
|
|
79
|
+
|
|
80
|
+
- **Model Validation Timing (Breaking)**: Runtime now validates model Ractor-shareability **immediately after Init returns**, not just during the Update cycle. This catches mutable models earlier, enforcing immutability at startup. Models must be frozen (`.freeze`) or use immutable data structures (`Data.define`). This is a good breaking change that prevents subtle concurrency bugs.
|
|
81
|
+
|
|
82
|
+
- **CancellationToken Replaced (Breaking)**: Custom commands now receive `Concurrent::Cancellation` instead of `CancellationToken`. The method to check cancellation changes from `token.cancelled?` (British) to `token.canceled?` (American).
|
|
83
|
+
|
|
84
|
+
- **Outlet Accepts Channel (Breaking)**: `Outlet.new` now accepts a `Concurrent::Promises::Channel` instead of `Thread::Queue`.
|
|
85
|
+
|
|
86
|
+
- **Outlet#put (Breaking)**: `out.put(msg)`, the common case now sends `msg` directly instead of `[msg]`; `out.put(a, b, c)` sends `[a, b, c]`. Previously all calls wrapped arguments in an array. Update functions that matched `when Array` may need adjustment.
|
|
87
|
+
|
|
88
|
+
- **Command.all Output (Breaking)**: `Command.all` now emits `Message::All` objects instead of raw arrays. Previously emitted `[:tag, [results]]` (nested) or `[:tag, result1, result2]` (variadic). Now emits `Message::All` with `envelope`, `results`, and `nested` fields. Use hash pattern matching: `in { type: :all, envelope:, results:, nested: }`.
|
|
89
|
+
|
|
90
|
+
### Fixed
|
|
91
|
+
|
|
92
|
+
- **Streaming Command Data Loss**: Fixed race condition where fast commands (e.g., `echo hello`) could lose stdout/stderr messages. The streaming reader threads were being killed immediately after the child process exited, before they could finish reading buffered output. Now the runtime joins the reader threads to ensure all data is processed before sending `:complete`.
|
|
93
|
+
|
|
94
|
+
### Removed
|
|
95
|
+
|
|
96
|
+
- **CancellationToken Class**: Removed in favor of `Concurrent::Cancellation` from concurrent-ruby-edge.
|
|
97
|
+
- **CancellationToken::NONE**: Removed. Use `Command.uncancellable` factory instead.
|
|
98
|
+
|
|
24
99
|
## [0.3.1] - 2026-01-11
|
|
25
100
|
|
|
26
101
|
### Added
|
|
@@ -115,6 +190,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
115
190
|
- **First Release**: Empty release of `ratatui_ruby-tea`, a Ruby implementation of The Elm Architecture (TEA) for `ratatui_ruby`. Scaffolding generated by `ratatui_ruby-devtools`.
|
|
116
191
|
|
|
117
192
|
[Unreleased]: https://git.sr.ht/~kerrick/ratatui_ruby-tea/refs/HEAD
|
|
193
|
+
[0.4.0]: https://git.sr.ht/~kerrick/ratatui_ruby-tea/refs/v0.4.0
|
|
118
194
|
[0.3.1]: https://git.sr.ht/~kerrick/ratatui_ruby-tea/refs/v0.3.1
|
|
119
195
|
[0.3.0]: https://git.sr.ht/~kerrick/ratatui_ruby-tea/refs/v0.3.0
|
|
120
196
|
[0.2.0]: https://git.sr.ht/~kerrick/ratatui_ruby-tea/refs/v0.2.0
|
data/README.md
CHANGED
|
@@ -20,7 +20,7 @@ Mailing List: Announcements](https://img.shields.io/badge/mailing_list-announcem
|
|
|
20
20
|
**ratatui_ruby** is a community wrapper that is not affiliated with [the Ratatui team](https://github.com/orgs/ratatui/people).
|
|
21
21
|
|
|
22
22
|
> [!WARNING]
|
|
23
|
-
> **ratatui_ruby-tea** is currently in **
|
|
23
|
+
> **ratatui_ruby-tea** is currently in **ALPHA**. The API may change with minor versions.
|
|
24
24
|
|
|
25
25
|
**[Why RatatuiRuby?](https://man.sr.ht/~kerrick/ratatui_ruby/why.md)** — Native Rust performance, zero runtime overhead, and Ruby's expressiveness. [See how we compare](https://man.sr.ht/~kerrick/ratatui_ruby/why.md) to CharmRuby, raw Rust, and Go.
|
|
26
26
|
|
|
@@ -107,9 +107,12 @@ gem install ratatui_ruby-tea
|
|
|
107
107
|
<!-- SYNC:START:examples/verify_readme_usage/app.rb:mvu -->
|
|
108
108
|
```ruby
|
|
109
109
|
Model = Data.define(:text)
|
|
110
|
-
MODEL = Model.new(text: "Hello, Ratatui! Press 'q' to quit.")
|
|
111
110
|
|
|
112
|
-
|
|
111
|
+
Init = -> do
|
|
112
|
+
Model.new(text: "Hello, Ratatui! Press 'q' to quit.")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
View = -> (model, tui) do
|
|
113
116
|
tui.paragraph(
|
|
114
117
|
text: model.text,
|
|
115
118
|
alignment: :center,
|
|
@@ -121,7 +124,7 @@ VIEW = -> (model, tui) do
|
|
|
121
124
|
)
|
|
122
125
|
end
|
|
123
126
|
|
|
124
|
-
|
|
127
|
+
Update = -> (msg, model) do
|
|
125
128
|
if msg.q? || msg.ctrl_c?
|
|
126
129
|
RatatuiRuby::Tea::Command.exit
|
|
127
130
|
else
|
|
@@ -130,7 +133,7 @@ UPDATE = -> (msg, model) do
|
|
|
130
133
|
end
|
|
131
134
|
|
|
132
135
|
def run
|
|
133
|
-
RatatuiRuby::Tea.run(
|
|
136
|
+
RatatuiRuby::Tea.run(VerifyReadmeUsage)
|
|
134
137
|
end
|
|
135
138
|
```
|
|
136
139
|
<!-- SYNC:END -->
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
|
|
3
|
+
SPDX-License-Identifier: CC-BY-SA-4.0
|
|
4
|
+
-->
|
|
5
|
+
# Async Work
|
|
6
|
+
|
|
7
|
+
## Context
|
|
8
|
+
|
|
9
|
+
Your application does concurrent work. It fetches from multiple APIs. It reads from sockets. It processes streams. These operations overlap in time.
|
|
10
|
+
|
|
11
|
+
## Problem
|
|
12
|
+
|
|
13
|
+
Threads are hard. Exceptions in spawned threads vanish silently. The main thread never learns what happened. Your application hangs, waiting for messages that will never arrive. Debugging this is miserable.
|
|
14
|
+
|
|
15
|
+
## Solution
|
|
16
|
+
|
|
17
|
+
Tea handles concurrency for you. Two patterns cover nearly every case. Use them instead of raw threads.
|
|
18
|
+
|
|
19
|
+
## Pattern 1: Command Orchestration
|
|
20
|
+
|
|
21
|
+
Compose child commands instead of spawning threads. Use <tt>out.source</tt> for sequential steps. Use <tt>Command.all</tt> for parallel steps.
|
|
22
|
+
|
|
23
|
+
<!-- SPDX-SnippetBegin -->
|
|
24
|
+
<!--
|
|
25
|
+
SPDX-FileCopyrightText: 2026 Kerrick Long
|
|
26
|
+
SPDX-License-Identifier: MIT-0
|
|
27
|
+
-->
|
|
28
|
+
```ruby
|
|
29
|
+
class LoadDashboard < Data.define(:user_id, :tag)
|
|
30
|
+
include RatatuiRuby::Tea::Command::Custom
|
|
31
|
+
|
|
32
|
+
def call(out, token)
|
|
33
|
+
# Step 1: Authenticate (sequential - we need the token first)
|
|
34
|
+
auth = out.source(Authenticate.new(user_id:, tag: :_), token)
|
|
35
|
+
return if auth.nil? || token.canceled?
|
|
36
|
+
|
|
37
|
+
# Step 2: Fetch dashboard data in parallel, waiting for all to complete
|
|
38
|
+
dashboard = out.source(
|
|
39
|
+
Command.all(:_, [
|
|
40
|
+
FetchProfile.new(token: auth[:token], tag: :profile),
|
|
41
|
+
FetchNotifications.new(token: auth[:token], tag: :notifications),
|
|
42
|
+
FetchWeather.new(tag: :weather)
|
|
43
|
+
]),
|
|
44
|
+
token
|
|
45
|
+
)
|
|
46
|
+
return if dashboard.nil? || token.canceled?
|
|
47
|
+
|
|
48
|
+
# Step 3: Send a message to the update with the dashboard data
|
|
49
|
+
out.put(tag, dashboard.results)
|
|
50
|
+
return if token.canceled?
|
|
51
|
+
|
|
52
|
+
# Step 4: Log the access (sequential - after we have data)
|
|
53
|
+
out.source(LogAccess.new(user_id:, tag: :_), token)
|
|
54
|
+
return if token.canceled?
|
|
55
|
+
|
|
56
|
+
# COMING SOON: out.source_nonblock
|
|
57
|
+
# Step 5: Watch for new data via HTTP Server-Sent Events, handling parallel streaming data
|
|
58
|
+
# 5a: Pass messages from the StreamNotifications custom command directly to LoadDashboard's out.put
|
|
59
|
+
notifications = out.source_nonblocki(
|
|
60
|
+
StreamNotifications.new(:user_id, auth[:token]),
|
|
61
|
+
token,
|
|
62
|
+
)
|
|
63
|
+
# 5b: Do work to the messages before sending them to the update function
|
|
64
|
+
deltas = out.source_nonblock(
|
|
65
|
+
StreamDashboardDeltas.new(dashboard.results[:profile][:id], auth[:token]),
|
|
66
|
+
token,
|
|
67
|
+
DeltaPostProcessor.new(:user_id)
|
|
68
|
+
)
|
|
69
|
+
# 5c: block this command on both async outsourced commands finishing
|
|
70
|
+
out.last(notifications, deltas)
|
|
71
|
+
return if token.canceled?
|
|
72
|
+
|
|
73
|
+
# Step 6: Log completion
|
|
74
|
+
out.source(LogAccess.new(user_id:, tag: :_, finished: true), token)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
```
|
|
78
|
+
<!-- SPDX-SnippetEnd -->
|
|
79
|
+
|
|
80
|
+
<tt>out.source</tt> blocks until the child command finishes. Pass <tt>Command.all</tt> to run children in parallel. Exceptions propagate correctly. Cancellation stops the workflow at any point.
|
|
81
|
+
|
|
82
|
+
Use this for any multi-step workflow with dependencies between stages.
|
|
83
|
+
|
|
84
|
+
## Pattern 2: Multiplexed I/O
|
|
85
|
+
|
|
86
|
+
Read from multiple sources without threads. Ruby's <tt>IO.select</tt> waits for any of several IOs to become ready.
|
|
87
|
+
|
|
88
|
+
<!-- SPDX-SnippetBegin -->
|
|
89
|
+
<!--
|
|
90
|
+
SPDX-FileCopyrightText: 2026 Kerrick Long
|
|
91
|
+
SPDX-License-Identifier: MIT-0
|
|
92
|
+
-->
|
|
93
|
+
```ruby
|
|
94
|
+
class MultiSocketReader < Data.define(:sockets, :tag)
|
|
95
|
+
include RatatuiRuby::Tea::Command::Custom
|
|
96
|
+
|
|
97
|
+
def call(out, token)
|
|
98
|
+
remaining = sockets.dup
|
|
99
|
+
|
|
100
|
+
until remaining.empty? || token.canceled?
|
|
101
|
+
# Wait up to 0.1s for any socket to have data
|
|
102
|
+
ready = IO.select(remaining, nil, nil, 0.1)
|
|
103
|
+
next unless ready
|
|
104
|
+
|
|
105
|
+
ready[0].each do |socket|
|
|
106
|
+
data = socket.read_nonblock(4096, exception: false)
|
|
107
|
+
case data
|
|
108
|
+
when :wait_readable
|
|
109
|
+
next
|
|
110
|
+
when nil
|
|
111
|
+
remaining.delete(socket)
|
|
112
|
+
else
|
|
113
|
+
out.put(:data, { socket: socket, chunk: data })
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
out.put(tag, :complete)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
```
|
|
122
|
+
<!-- SPDX-SnippetEnd -->
|
|
123
|
+
|
|
124
|
+
<tt>IO.select</tt> multiplexes reads across sockets, pipes, or files. One thread handles many connections. No spawned threads means no silent failures.
|
|
125
|
+
|
|
126
|
+
Use this for chat clients, log tailers, or any multi-stream scenario.
|
|
127
|
+
|
|
128
|
+
## Why Not Threads?
|
|
129
|
+
|
|
130
|
+
You might wonder: "Why can't I just spawn a thread?"
|
|
131
|
+
|
|
132
|
+
<!-- SPDX-SnippetBegin -->
|
|
133
|
+
<!--
|
|
134
|
+
SPDX-FileCopyrightText: 2026 Kerrick Long
|
|
135
|
+
SPDX-License-Identifier: MIT-0
|
|
136
|
+
-->
|
|
137
|
+
```ruby
|
|
138
|
+
# ❌ Don't do this
|
|
139
|
+
def call(out, token)
|
|
140
|
+
Thread.new do
|
|
141
|
+
data = fetch_something
|
|
142
|
+
out.put(:result, data)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
```
|
|
146
|
+
<!-- SPDX-SnippetEnd -->
|
|
147
|
+
|
|
148
|
+
This looks harmless. It hides a trap.
|
|
149
|
+
|
|
150
|
+
If <tt>fetch_something</tt> raises, the exception happens in the spawned thread. Ruby logs it. The main thread never sees it. Your <tt>call</tt> method returns. The runtime considers the command complete. But <tt>out.put</tt> never ran. Your update function waits for <tt>:result</tt> forever.
|
|
151
|
+
|
|
152
|
+
The runtime cannot protect you from this. Threads spawned inside your command escape its error handling. The framework wraps <tt>call</tt> in a rescue. It does not wrap threads you create.
|
|
153
|
+
|
|
154
|
+
Use the patterns above instead. They keep errors visible.
|
|
155
|
+
|
|
156
|
+
## Choosing the Right Pattern
|
|
157
|
+
|
|
158
|
+
| Situation | Pattern |
|
|
159
|
+
|-----------|---------|
|
|
160
|
+
| Multi-step workflows | <tt>out.source</tt> + <tt>Command.all</tt> |
|
|
161
|
+
| Read from multiple sockets/pipes | <tt>IO.select</tt> |
|
|
162
|
+
| One blocking operation | Just do it in <tt>call</tt> |
|
|
163
|
+
|
|
164
|
+
Commands already run off the main thread. You rarely need additional concurrency. When you do, these patterns handle the hard parts.
|