mixin_bot 2.5.0 → 3.0.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 +3 -3
- data/CHANGELOG.md +17 -0
- data/README.md +75 -5
- data/docs/agent/cookbook.md +26 -0
- data/examples/blaze_async.rb +91 -0
- data/lib/mixin_bot/api/blaze_async.rb +62 -0
- data/lib/mixin_bot/api.rb +2 -0
- data/lib/mixin_bot/blaze/reactor.rb +244 -0
- data/lib/mixin_bot/cli/base.rb +1 -0
- data/lib/mixin_bot/configuration.rb +52 -0
- data/lib/mixin_bot/version.rb +1 -1
- data/lib/mixin_bot.rb +1 -0
- data/lib/puma/plugin/mixin_blaze.rb +263 -0
- data/llms.txt +1 -1
- metadata +36 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6c6e5421b12eabf2c32c0500c1b828228fd24a10855047f2f7f1bf605dc62ada
|
|
4
|
+
data.tar.gz: a18da86395abe9053e52d70965538359cbbada5bc13439196c73536382d05c87
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4a20f4ef42062cff12ff5ecb280f8d447528ee2f7882e0425783af545b57a3fe7faecf36ca7c70387e5c3dbeb08cdcea02729b919cddadb386fe76b5b6d45fa6
|
|
7
|
+
data.tar.gz: f9f88a933a44502b2709fd282c8170adf73653d46721c83044358b59434444b10efac60ef82b84cb470cffaf7d4694d7bbeb270dc851c54ba5a66e40ff285713
|
data/AGENTS.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# AGENTS.md — MixinBot
|
|
2
2
|
|
|
3
|
-
Ruby gem (
|
|
3
|
+
Ruby gem (v3.0.0): Mixin Network REST SDK + `mixinbot` CLI. Parity targets: [bot-api-go-client](https://github.com/MixinNetwork/bot-api-go-client), [bot-api-nodejs-client](https://github.com/MixinNetwork/bot-api-nodejs-client).
|
|
4
4
|
|
|
5
5
|
## Commands
|
|
6
6
|
|
|
@@ -34,13 +34,13 @@ docs/agent/ # LLM-oriented CLI and cookbook docs
|
|
|
34
34
|
|
|
35
35
|
## CI and release
|
|
36
36
|
|
|
37
|
-
- **CI** (`.github/workflows/ci.yml`): `pull_request` and `push` to `main` — `rake test` on Ruby
|
|
37
|
+
- **CI** (`.github/workflows/ci.yml`): `pull_request` and `push` to `main` — `rake test` on Ruby 4.0, `rake rubocop` (4.0), `rake mixin_bot:api_coverage`.
|
|
38
38
|
- **Release** (`.github/workflows/release.yml`): push tag `v*` (must match `MixinBot::VERSION`, e.g. tag `v2.1.0` for `VERSION = '2.1.0'`) → `rake build` → RubyGems via [trusted publishing](https://guides.rubygems.org/trusted-publishing/) (OIDC; workflow `release.yml`, no repo secret) → GitHub Release (notes from `CHANGELOG.md`, `.gem` attached).
|
|
39
39
|
- **Dependabot** (`.github/dependabot.yml`): weekly Bundler and GitHub Actions updates; Dependabot PRs use the same CI workflow.
|
|
40
40
|
|
|
41
41
|
## Conventions
|
|
42
42
|
|
|
43
|
-
- **Ruby** >=
|
|
43
|
+
- **Ruby** >= 4.0 (CI: 4.0)
|
|
44
44
|
- **HTTP responses**: `MixinBot::Models::ApiEnvelope` — use `res['data']` or delegated keys
|
|
45
45
|
- **Safe transfers**: require `spend_key`; prefer `create_safe_transfer` / `create_transfer` (Safe pipeline)
|
|
46
46
|
- **Legacy APIs**: `create_legacy_transfer`, `POST /transfers` — deprecated, warns once
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [3.0.0] - 2026-09-15
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Puma plugin hosting the Blaze connection** (`plugin :mixin_blaze`) — Solid Queue-style in-process hosting, removing the standalone Blaze deployment artifact. Dual run modes via the `mixin_blaze_mode` Puma DSL option: `:fork` (default) forks a dedicated Blaze child on `on_booted` with a monitor thread that stops Puma if the child dies, terminated on `on_stopped`/`on_restart`; `:async` runs the reactor on a Puma background thread in-process. Handler resolution comes from `MixinBot` configuration (resolved lazily after app boot) with an explicit ack policy (on-receipt vs after-handler); Puma 6/7 lifecycle-event compatibility; cluster mode requires `preload_app!` and fails loudly without it. Puma remains a host-app concern — no new gem dependency.
|
|
15
|
+
- **`MixinBot::Blaze::Reactor`** — reusable Blaze loop (connect, keepalive ping, read → handler → ack, reconnect with backoff) wrapping `API#blaze_async`; extracted from `examples/blaze_async.rb` so the plugin shell stays thin and the loop is unit-testable offline.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- **Breaking**: Ruby >= 4.0 is now required — `required_ruby_version` bumped from `>= 3.2.0` to `>= 4.0.0`. The CI matrix, RuboCop target, and Release workflow all run Ruby 4.0; the dev-only `parallel` version pin (kept for the old Ruby 3.2 floor) was dropped.
|
|
20
|
+
|
|
21
|
+
## [2.6.0] - 2026-09-07
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- **`API#blaze_async`** — fiber-based sibling of `blaze`: returns a connected `async-websocket` client (same `wss://<blaze_host>/` URL, `Mixin-Blaze-1` subprotocol, Bearer JWT, User-Agent, and shared frame codec) for hosts driving Blaze outside EventMachine. `handler:` is yield-through for caller-supplied `Async::WebSocket::Connection` subclasses; `endpoint_options:` forwards to `Async::HTTP::Endpoint.parse`. Forces the HTTP/1 upgrade path via ALPN. Caller-side wire notes: send `write_ws_message` byte arrays as `BinaryMessage.new(ary.pack('C*'))`, feed `message.to_str` into `ws_message`, and own the keepalive ping (server pings are auto-replied).
|
|
26
|
+
|
|
10
27
|
## [2.5.0] - 2026-09-03
|
|
11
28
|
|
|
12
29
|
### Added
|
data/README.md
CHANGED
|
@@ -6,12 +6,12 @@ Ruby SDK and CLI for [Mixin Network](https://developers.mixin.one/docs): authent
|
|
|
6
6
|
|
|
7
7
|
The gem aims for **parity with the official [bot-api-go-client](https://github.com/MixinNetwork/bot-api-go-client)** Go SDK and **[bot-api-nodejs-client](https://github.com/MixinNetwork/bot-api-nodejs-client)** Node SDK. See [API_COVERAGE.md](API_COVERAGE.md) for the full mapping; run `rake mixin_bot:api_coverage` to confirm no gaps are marked missing.
|
|
8
8
|
|
|
9
|
-
Current gem version: **
|
|
9
|
+
Current gem version: **3.0.0** (see [CHANGELOG.md](CHANGELOG.md) for breaking changes and deprecations).
|
|
10
10
|
|
|
11
11
|
## Requirements
|
|
12
12
|
|
|
13
|
-
- **Ruby** ≥
|
|
14
|
-
- **Bundler** 2.
|
|
13
|
+
- **Ruby** ≥ 4.0 (CI runs 4.0).
|
|
14
|
+
- **Bundler** 2.6+ recommended.
|
|
15
15
|
- Optional: the **`mixin`** CLI in `PATH` if you use `MixinBot::API#encode_raw_transaction_native` / `#decode_raw_transaction_native` or the experimental `MixinBot::NodeCLI` helpers.
|
|
16
16
|
|
|
17
17
|
## Installation
|
|
@@ -257,6 +257,76 @@ end
|
|
|
257
257
|
|
|
258
258
|
For outbound messages over an open socket, use `blaze_send_plain_text`, `blaze_send_contact`, `blaze_send_app_card`, and related helpers (parity with Go `BlazeClient`).
|
|
259
259
|
|
|
260
|
+
### Without EventMachine: `blaze_async`
|
|
261
|
+
|
|
262
|
+
`blaze_async` returns the connected `async-websocket` connection instead of a `Faye::WebSocket::Client` — same URL, `Mixin-Blaze-1` subprotocol, and frame codec; the caller drives the loop in an `Async` reactor. Wire notes: wrap `write_ws_message` byte arrays in `Protocol::WebSocket::BinaryMessage`, feed `message.to_str` into `ws_message`, and own the keepalive ping (see `examples/blaze_async.rb`).
|
|
263
|
+
|
|
264
|
+
```ruby
|
|
265
|
+
require 'async'
|
|
266
|
+
require 'mixin_bot'
|
|
267
|
+
|
|
268
|
+
Async do |task|
|
|
269
|
+
connection = MixinBot.api.blaze_async
|
|
270
|
+
|
|
271
|
+
task.async do # keepalive is the caller's job
|
|
272
|
+
loop do
|
|
273
|
+
sleep 30
|
|
274
|
+
connection.send_ping
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
connection.write Protocol::WebSocket::BinaryMessage.new(MixinBot.api.list_pending_message.pack('C*'))
|
|
279
|
+
while (message = connection.read)
|
|
280
|
+
raw = JSON.parse MixinBot.api.ws_message(message.to_str)
|
|
281
|
+
# data is a Hash for message envelopes, an Array in LIST_PENDING_MESSAGES replies
|
|
282
|
+
data = raw['data'].is_a?(Hash) ? raw['data'] : {}
|
|
283
|
+
next unless (message_id = data['message_id'])
|
|
284
|
+
|
|
285
|
+
bytes = MixinBot.api.acknowledge_message_receipt(message_id)
|
|
286
|
+
connection.write Protocol::WebSocket::BinaryMessage.new(bytes.pack('C*'))
|
|
287
|
+
end
|
|
288
|
+
ensure
|
|
289
|
+
connection&.close
|
|
290
|
+
end
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
### Running Blaze inside Puma (`plugin :mixin_blaze`)
|
|
294
|
+
|
|
295
|
+
For Rails/Puma apps, the gem ships a Puma plugin (Solid Queue-style) that hosts the Blaze connection inside the web process tree — no standalone Blaze process to deploy or supervise:
|
|
296
|
+
|
|
297
|
+
```ruby
|
|
298
|
+
# config/puma.rb
|
|
299
|
+
plugin :mixin_blaze
|
|
300
|
+
mixin_blaze_mode :fork # default; see table below
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
```ruby
|
|
304
|
+
# config/initializers/mixin_bot.rb
|
|
305
|
+
MixinBot.configure do
|
|
306
|
+
# ... credentials ...
|
|
307
|
+
self.blaze_handler = ->(envelope) { MyBot.process! envelope }
|
|
308
|
+
self.blaze_ack_policy = :on_receipt # default; or :after_handler
|
|
309
|
+
end
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
The handler receives each decoded message envelope (a Hash with `action` and `data` keys) and runs serially on the hosting thread — safe for ActiveRecord. Handlers run one message at a time; the keepalive ping runs separately.
|
|
313
|
+
|
|
314
|
+
| Mode | Where the connection lives | Notes |
|
|
315
|
+
|---|---|---|
|
|
316
|
+
| `:fork` (default) | Dedicated child process forked from the Puma launcher | Crash-isolated; if the child dies, Puma is stopped so the process manager restarts the unit |
|
|
317
|
+
| `:async` | Background thread inside the Puma process itself | In cluster mode that process is the master; no child process |
|
|
318
|
+
|
|
319
|
+
Ack policy: `:on_receipt` acknowledges each message immediately (at-most-once dispatch — a handler crash loses that message); `:after_handler` acknowledges only after the handler completes without raising (at-least-once — handlers must be idempotent, since unacknowledged messages are redelivered on reconnect).
|
|
320
|
+
|
|
321
|
+
Receipt confirmations (`ACKNOWLEDGE_MESSAGE_RECEIPT` frames the server pushes for your own outgoing/acknowledged messages) are consumed internally: the handler never sees them and they are never re-acknowledged.
|
|
322
|
+
|
|
323
|
+
Operational notes:
|
|
324
|
+
|
|
325
|
+
- **Remove any standalone Blaze process when enabling the plugin** — two connections double your handler invocations (or split deliveries).
|
|
326
|
+
- **Cluster mode requires `preload_app!`** — plugins run in the launcher process, which holds no application code without preloading; the plugin reports an error and stays off otherwise.
|
|
327
|
+
- **Phased restarts keep the old handler code** in the hosting process until a full restart; the same applies to Solid Queue's plugin.
|
|
328
|
+
- Workers that need to *send* messages should use the REST API (`create_message` / `create_messages`), not the socket.
|
|
329
|
+
|
|
260
330
|
## Deep links and bot auth
|
|
261
331
|
|
|
262
332
|
```ruby
|
|
@@ -363,8 +433,8 @@ Examples under `examples/` expect `examples/config.yml` (copy from `examples/con
|
|
|
363
433
|
|
|
364
434
|
GitHub Actions runs on every pull request and on pushes to `main`:
|
|
365
435
|
|
|
366
|
-
- **Test** — Ruby
|
|
367
|
-
- **RuboCop** — Ruby
|
|
436
|
+
- **Test** — Ruby 4.0: `bundle exec rake test`
|
|
437
|
+
- **RuboCop** — Ruby 4.0: `bundle exec rake rubocop`
|
|
368
438
|
- **API coverage** — `bundle exec rake mixin_bot:api_coverage`
|
|
369
439
|
|
|
370
440
|
### Release
|
data/docs/agent/cookbook.md
CHANGED
|
@@ -130,6 +130,32 @@ end
|
|
|
130
130
|
|
|
131
131
|
See [examples/blaze.rb](../../examples/blaze.rb).
|
|
132
132
|
|
|
133
|
+
## Blaze inside Puma (`plugin :mixin_blaze`)
|
|
134
|
+
|
|
135
|
+
For Rails/Puma apps the gem ships a Puma plugin (Solid Queue-style) that hosts the Blaze connection inside the web process tree — no standalone Blaze process. Requires a configured `blaze_handler`; without one the plugin logs an error and stays off.
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
# config/puma.rb
|
|
139
|
+
plugin :mixin_blaze
|
|
140
|
+
mixin_blaze_mode :fork # default (:fork = supervised child process); :async = in-process thread
|
|
141
|
+
|
|
142
|
+
# config/initializers/mixin_bot.rb
|
|
143
|
+
MixinBot.configure do
|
|
144
|
+
# ... credentials ...
|
|
145
|
+
self.blaze_handler = ->(envelope) { MyBot.process! envelope } # required
|
|
146
|
+
self.blaze_ack_policy = :on_receipt # default; :after_handler = ack after handler success
|
|
147
|
+
end
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Behavior: reconnect with bounded backoff, keepalive pings, serial handler dispatch (ActiveRecord-safe), handler exceptions logged without ending delivery, graceful shutdown with Puma (child killed on stop; in `:fork` mode a dead child stops Puma so the unit restarts). `ACKNOWLEDGE_MESSAGE_RECEIPT` confirmations are consumed internally — the handler never sees them and they are never re-acknowledged.
|
|
151
|
+
|
|
152
|
+
Operational notes:
|
|
153
|
+
|
|
154
|
+
- **Remove any standalone Blaze process when enabling the plugin** — duplicate connections duplicate handler invocations.
|
|
155
|
+
- **Puma cluster mode requires `preload_app!`** — plugins run in the launcher process; without preloading there is no app code to resolve the handler and the plugin reports an error.
|
|
156
|
+
- **Phased restarts keep old handler code** in the hosting process until a full restart.
|
|
157
|
+
- Workers that need to *send* messages use the REST API (`create_message`), not the socket.
|
|
158
|
+
|
|
133
159
|
## Discover API methods
|
|
134
160
|
|
|
135
161
|
```bash
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Fiber-based Blaze client — the async-websocket sibling of examples/blaze.rb.
|
|
4
|
+
#
|
|
5
|
+
# Drives API#blaze_async without EventMachine: the gem returns the connected
|
|
6
|
+
# connection and the caller runs the loop inside an Async reactor. The frame
|
|
7
|
+
# codec (write_ws_message / ws_message / list_pending_message /
|
|
8
|
+
# acknowledge_message_receipt) is shared with blaze verbatim; only the wire
|
|
9
|
+
# differs:
|
|
10
|
+
# - *Send*: write_ws_message returns an Array of byte integers (Faye framed
|
|
11
|
+
# that itself) — wrap it in a Protocol::WebSocket::BinaryMessage.
|
|
12
|
+
# - *Read*: connection.read yields a Protocol::WebSocket::Message (or nil
|
|
13
|
+
# after a clean close) — feed message.to_str into ws_message.
|
|
14
|
+
# - *Keepalive*: Faye's ping: 60 becomes the caller's job; server pings are
|
|
15
|
+
# auto-replied by the protocol layer.
|
|
16
|
+
#
|
|
17
|
+
# Run: ruby examples/blaze_async.rb
|
|
18
|
+
|
|
19
|
+
require './lib/mixin_bot'
|
|
20
|
+
require 'async'
|
|
21
|
+
require 'base64'
|
|
22
|
+
require 'json'
|
|
23
|
+
require 'securerandom'
|
|
24
|
+
require 'yaml'
|
|
25
|
+
|
|
26
|
+
CONFIG = YAML.load_file("#{File.dirname __FILE__}/config.yml")
|
|
27
|
+
MixinBot.configure do
|
|
28
|
+
self.app_id = CONFIG['app_id']
|
|
29
|
+
self.client_secret = CONFIG['client_secret']
|
|
30
|
+
self.session_id = CONFIG['session_id']
|
|
31
|
+
self.server_public_key = CONFIG['server_public_key']
|
|
32
|
+
self.session_private_key = CONFIG['session_private_key']
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
API = MixinBot.api
|
|
36
|
+
|
|
37
|
+
# gzip+JSON bytes from write_ws_message -> one binary WebSocket frame
|
|
38
|
+
def send_frame(connection, bytes)
|
|
39
|
+
connection.write Protocol::WebSocket::BinaryMessage.new(bytes.pack('C*'))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
Async do |task|
|
|
43
|
+
# endpoint_options: is forwarded to Async::HTTP::Endpoint.parse. No
|
|
44
|
+
# timeout: here — it would become the socket's per-read timeout and kill
|
|
45
|
+
# quiet-but-healthy connections; liveness is the keepalive ping below.
|
|
46
|
+
# handler: stays default; pass an Async::WebSocket::Connection subclass to
|
|
47
|
+
# hook the raw frame events (e.g. PONG correlation).
|
|
48
|
+
connection = API.blaze_async
|
|
49
|
+
p [Time.now.to_s, :connected]
|
|
50
|
+
|
|
51
|
+
# liveness is the caller's policy: nothing on the wire goes stale quietly
|
|
52
|
+
keepalive = task.async do
|
|
53
|
+
loop do
|
|
54
|
+
sleep 30
|
|
55
|
+
connection.send_ping
|
|
56
|
+
end
|
|
57
|
+
rescue Protocol::WebSocket::ProtocolError, IOError # IOError covers EOFError
|
|
58
|
+
# connection is gone; the read loop below is already winding down
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# sends nothing on open — mirror blaze and request pending messages first
|
|
62
|
+
send_frame connection, API.list_pending_message
|
|
63
|
+
|
|
64
|
+
# read returns nil after a clean close; an abrupt one raises EOFError
|
|
65
|
+
while (message = connection.read)
|
|
66
|
+
raw = JSON.parse API.ws_message(message.to_str)
|
|
67
|
+
p [Time.now.to_s, :on_message, raw&.[]('action')]
|
|
68
|
+
|
|
69
|
+
# data is a Hash for message envelopes, but an Array (the pending list)
|
|
70
|
+
# in the LIST_PENDING_MESSAGES reply
|
|
71
|
+
data = raw['data'].is_a?(Hash) ? raw['data'] : {}
|
|
72
|
+
send_frame connection, API.acknowledge_message_receipt(data['message_id']) if data['message_id']
|
|
73
|
+
|
|
74
|
+
# echo PLAIN_TEXT back to its sender over the same connection
|
|
75
|
+
next unless data['category'] == 'PLAIN_TEXT'
|
|
76
|
+
|
|
77
|
+
send_frame connection, API.write_ws_message(
|
|
78
|
+
params: {
|
|
79
|
+
conversation_id: data['conversation_id'],
|
|
80
|
+
recipient_id: data['user_id'],
|
|
81
|
+
message_id: SecureRandom.uuid,
|
|
82
|
+
category: 'PLAIN_TEXT',
|
|
83
|
+
data_base64: Base64.urlsafe_encode64("echo: #{Base64.urlsafe_decode64(data['data'])}", padding: false)
|
|
84
|
+
}
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
p [Time.now.to_s, :closed]
|
|
88
|
+
ensure
|
|
89
|
+
keepalive&.stop
|
|
90
|
+
connection&.close
|
|
91
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'async/http/endpoint'
|
|
4
|
+
require 'async/http/protocol/http11'
|
|
5
|
+
require 'async/websocket/client'
|
|
6
|
+
|
|
7
|
+
module MixinBot
|
|
8
|
+
class API
|
|
9
|
+
# Fiber-based sibling of +Blaze#blaze+: returns a connected
|
|
10
|
+
# +Async::WebSocket+ client instead of a +Faye::WebSocket::Client+.
|
|
11
|
+
#
|
|
12
|
+
# Same endpoint, +Mixin-Blaze-1+ subprotocol, Bearer JWT, and User-Agent as
|
|
13
|
+
# +blaze+. The frame codec (+ws_message+, +write_ws_message+,
|
|
14
|
+
# +list_pending_message+, +acknowledge_message_receipt+) is
|
|
15
|
+
# transport-agnostic and shared verbatim.
|
|
16
|
+
#
|
|
17
|
+
# Wire differences the caller must handle:
|
|
18
|
+
# - *Send*: +write_ws_message+ returns an +Array+ of byte integers (the
|
|
19
|
+
# Faye backend framed that automatically); here the caller wraps it:
|
|
20
|
+
# +connection.write(Protocol::WebSocket::BinaryMessage.new(bytes.pack('C*')))+.
|
|
21
|
+
# - *Read*: +connection.read+ returns a +Protocol::WebSocket::Message+;
|
|
22
|
+
# pass +message.to_str+ (the binary buffer) into +ws_message+.
|
|
23
|
+
# - *Keepalive*: Faye's +ping: 60+ becomes the caller's job (server pings
|
|
24
|
+
# are still auto-replied by the protocol layer).
|
|
25
|
+
#
|
|
26
|
+
# Sends nothing on open — the caller sends +list_pending_message+ first,
|
|
27
|
+
# mirroring +blaze+. No per-IO read timeout by default: quiet-but-healthy
|
|
28
|
+
# connections are legitimate; liveness is the caller's policy.
|
|
29
|
+
module BlazeAsync
|
|
30
|
+
# Returns the block-less +Client.connect+ connection, preserving
|
|
31
|
+
# +blaze+'s "returns a client the caller drives" contract.
|
|
32
|
+
#
|
|
33
|
+
# +handler:+ is yield-through so the caller can supply an
|
|
34
|
+
# +Async::WebSocket::Connection+ subclass (e.g. one that correlates
|
|
35
|
+
# PONG frames). +endpoint_options:+ are forwarded to
|
|
36
|
+
# +Async::HTTP::Endpoint.parse+. Prefer not passing +timeout:+ there: it
|
|
37
|
+
# becomes the socket's per-read timeout, so a quiet-but-healthy
|
|
38
|
+
# connection dies when no frame arrives within that window. Bound the
|
|
39
|
+
# connect phase at the call site instead (see
|
|
40
|
+
# MixinBot::Blaze::Reactor#connect!).
|
|
41
|
+
def blaze_async(handler: Async::WebSocket::Connection, endpoint_options: {})
|
|
42
|
+
access_token = access_token('GET', '/', '')
|
|
43
|
+
authorization = format('Bearer %<access_token>s', access_token:)
|
|
44
|
+
|
|
45
|
+
endpoint = Async::HTTP::Endpoint.parse(
|
|
46
|
+
format('wss://%<host>s/', host: config.blaze_host),
|
|
47
|
+
# Force the HTTP/1 upgrade path: avoids the RFC 8441 (h2 CONNECT)
|
|
48
|
+
# route, which not every gateway negotiates the same way.
|
|
49
|
+
alpn_protocols: Async::HTTP::Protocol::HTTP11.names,
|
|
50
|
+
**endpoint_options
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
Async::WebSocket::Client.connect(
|
|
54
|
+
endpoint,
|
|
55
|
+
protocols: ['Mixin-Blaze-1'],
|
|
56
|
+
headers: { 'Authorization' => authorization, 'User-Agent' => "mixin_bot/#{MixinBot::VERSION}" },
|
|
57
|
+
handler: handler
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
data/lib/mixin_bot/api.rb
CHANGED
|
@@ -8,6 +8,7 @@ require_relative 'api/asset'
|
|
|
8
8
|
require_relative 'api/attachment'
|
|
9
9
|
require_relative 'api/auth'
|
|
10
10
|
require_relative 'api/blaze'
|
|
11
|
+
require_relative 'api/blaze_async'
|
|
11
12
|
require_relative 'api/chain'
|
|
12
13
|
require_relative 'api/code'
|
|
13
14
|
require_relative 'api/circle'
|
|
@@ -338,6 +339,7 @@ module MixinBot
|
|
|
338
339
|
include MixinBot::API::Attachment
|
|
339
340
|
include MixinBot::API::Auth
|
|
340
341
|
include MixinBot::API::Blaze
|
|
342
|
+
include MixinBot::API::BlazeAsync
|
|
341
343
|
include MixinBot::API::Chain
|
|
342
344
|
include MixinBot::API::Code
|
|
343
345
|
include MixinBot::API::Circle
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'async'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'protocol/websocket'
|
|
6
|
+
|
|
7
|
+
module MixinBot
|
|
8
|
+
module Blaze
|
|
9
|
+
# Fiber-based Blaze receive loop — the hosted sibling of
|
|
10
|
+
# examples/blaze_async.rb.
|
|
11
|
+
#
|
|
12
|
+
# Drives API#blaze_async inside its own Async reactor (created by #run,
|
|
13
|
+
# so it can be called from any plain thread — e.g. a Puma plugin's
|
|
14
|
+
# background thread or a forked child). The loop: connect, request
|
|
15
|
+
# pending messages, read frames serially, dispatch each decoded envelope
|
|
16
|
+
# to the configured handler, and acknowledge per the ack policy. Receipt
|
|
17
|
+
# confirmations (ACKNOWLEDGE_MESSAGE_RECEIPT frames) are consumed
|
|
18
|
+
# internally — never dispatched to the handler nor re-acknowledged.
|
|
19
|
+
# Dropped connections are re-established with bounded exponential backoff.
|
|
20
|
+
#
|
|
21
|
+
# Handlers run serially on the reactor thread — never in concurrent
|
|
22
|
+
# fibers — so handler code may use per-thread resources like ActiveRecord
|
|
23
|
+
# connections safely. Handler exceptions are logged and do not end
|
|
24
|
+
# message delivery.
|
|
25
|
+
#
|
|
26
|
+
# Reactor.new(handler: ->(envelope) { ... }).run # blocks forever
|
|
27
|
+
#
|
|
28
|
+
class Reactor
|
|
29
|
+
CONNECT_TIMEOUT = 10
|
|
30
|
+
KEEPALIVE_INTERVAL = 30
|
|
31
|
+
INITIAL_BACKOFF = 1
|
|
32
|
+
MAX_BACKOFF = 30
|
|
33
|
+
# a connection must live at least this long for the backoff to reset
|
|
34
|
+
STABLE_PERIOD = 30
|
|
35
|
+
|
|
36
|
+
attr_reader :handler, :ack_policy
|
|
37
|
+
|
|
38
|
+
##
|
|
39
|
+
# @param api [MixinBot::API] API used for frame codecs and connecting
|
|
40
|
+
# @param handler [#call] invoked with each decoded message envelope
|
|
41
|
+
# Hash (with +action+ and +data+ keys)
|
|
42
|
+
# @param ack_policy [Symbol] +:on_receipt+ (ack before the handler
|
|
43
|
+
# runs) or +:after_handler+ (ack only after the handler succeeds;
|
|
44
|
+
# failed messages are redelivered on reconnect)
|
|
45
|
+
# @param connection_factory [#call, nil] returns a connected
|
|
46
|
+
# +Async::WebSocket+ connection; defaults to +api.blaze_async+
|
|
47
|
+
# with a +connect_timeout+ connect phase
|
|
48
|
+
# @param keepalive_interval [Numeric] seconds between client pings
|
|
49
|
+
# @param connect_timeout [Numeric] seconds allowed for the connect phase
|
|
50
|
+
# (DNS + TCP + TLS + WebSocket upgrade); established connections are
|
|
51
|
+
# never timed out on read
|
|
52
|
+
# @param sleeper [#call, nil] backoff hook, called with seconds;
|
|
53
|
+
# defaults to +Kernel#sleep+ (inject for tests)
|
|
54
|
+
# @param logger [#call, nil] called with +(level, exception_or_message)+;
|
|
55
|
+
# defaults to +$stderr+
|
|
56
|
+
def initialize(handler:, api: MixinBot.api, ack_policy: MixinBot.config.blaze_ack_policy,
|
|
57
|
+
connection_factory: nil, keepalive_interval: KEEPALIVE_INTERVAL,
|
|
58
|
+
connect_timeout: CONNECT_TIMEOUT, sleeper: nil, logger: nil)
|
|
59
|
+
raise MixinBot::ArgumentError, 'handler must respond to #call' unless handler.respond_to?(:call)
|
|
60
|
+
unless %i[on_receipt after_handler].include?(ack_policy)
|
|
61
|
+
raise MixinBot::ArgumentError, "ack_policy must be :on_receipt or :after_handler, got #{ack_policy.inspect}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
@api = api
|
|
65
|
+
@handler = handler
|
|
66
|
+
@ack_policy = ack_policy
|
|
67
|
+
# No socket-level timeout: endpoint timeout: would apply to every read
|
|
68
|
+
# (a quiet-but-healthy connection would be killed), not just the
|
|
69
|
+
# connect phase. Liveness is the keepalive ping + the connect-phase
|
|
70
|
+
# timeout applied in #connect!.
|
|
71
|
+
@connection_factory = connection_factory || -> { api.blaze_async }
|
|
72
|
+
@keepalive_interval = keepalive_interval
|
|
73
|
+
@connect_timeout = connect_timeout
|
|
74
|
+
@sleeper = sleeper || ->(seconds) { sleep seconds }
|
|
75
|
+
@logger = logger || ->(level, detail) { warn "[mixin_blaze] #{level}: #{detail}" }
|
|
76
|
+
@guard = Mutex.new
|
|
77
|
+
@stopping = false
|
|
78
|
+
@connection = nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
##
|
|
82
|
+
# Runs the connect/read/reconnect loop, blocking the calling thread
|
|
83
|
+
# until #stop. Yields control to the fiber scheduler while waiting.
|
|
84
|
+
#
|
|
85
|
+
# @return [void]
|
|
86
|
+
def run
|
|
87
|
+
Async do |task|
|
|
88
|
+
@task = task
|
|
89
|
+
backoff = INITIAL_BACKOFF
|
|
90
|
+
|
|
91
|
+
until stopped?
|
|
92
|
+
started_at = monotonic_time
|
|
93
|
+
begin
|
|
94
|
+
run_connection
|
|
95
|
+
backoff = INITIAL_BACKOFF if stable?(started_at)
|
|
96
|
+
rescue StandardError => e
|
|
97
|
+
log.call :error, e
|
|
98
|
+
backoff = INITIAL_BACKOFF if stable?(started_at)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
break if stopped?
|
|
102
|
+
|
|
103
|
+
sleep_backoff backoff
|
|
104
|
+
backoff = [(backoff * 2), MAX_BACKOFF].min
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
##
|
|
110
|
+
# Stops the loop and closes the current connection. Safe to call from
|
|
111
|
+
# any thread (including while #run blocks in another one) and safe to
|
|
112
|
+
# call more than once.
|
|
113
|
+
#
|
|
114
|
+
# @return [void]
|
|
115
|
+
def stop
|
|
116
|
+
@guard.synchronize do
|
|
117
|
+
@stopping = true
|
|
118
|
+
begin
|
|
119
|
+
@connection&.close
|
|
120
|
+
rescue StandardError
|
|
121
|
+
# already dead; the read loop will notice on its own
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
def stopped?
|
|
129
|
+
@guard.synchronize { @stopping }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# One connection lifetime: connect, keepalive, pending request, read
|
|
133
|
+
# loop. Returns on a clean close; raises on a broken one.
|
|
134
|
+
def run_connection
|
|
135
|
+
return unless connect!
|
|
136
|
+
|
|
137
|
+
log.call :connected, "pid=#{Process.pid}"
|
|
138
|
+
|
|
139
|
+
keepalive = start_keepalive
|
|
140
|
+
send_frame @api.list_pending_message
|
|
141
|
+
|
|
142
|
+
while (message = @connection.read)
|
|
143
|
+
dispatch message
|
|
144
|
+
end
|
|
145
|
+
log.call :closed, 'clean close'
|
|
146
|
+
ensure
|
|
147
|
+
keepalive&.stop
|
|
148
|
+
@connection&.close
|
|
149
|
+
@connection = nil
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Establishes the connection and registers it under the guard, so a
|
|
153
|
+
# concurrent #stop can always find and close it. The connect phase
|
|
154
|
+
# (DNS + TCP + TLS + WebSocket upgrade) is bounded by #connect_timeout —
|
|
155
|
+
# a hung connect becomes an error the run loop retries, never a
|
|
156
|
+
# permanently stuck loop. Returns nil when the reactor stopped
|
|
157
|
+
# mid-connect; the fresh connection is closed here.
|
|
158
|
+
def connect!
|
|
159
|
+
fresh = @task.with_timeout(@connect_timeout) { @connection_factory.call }
|
|
160
|
+
|
|
161
|
+
@guard.synchronize do
|
|
162
|
+
if @stopping
|
|
163
|
+
begin
|
|
164
|
+
fresh&.close
|
|
165
|
+
rescue StandardError
|
|
166
|
+
nil
|
|
167
|
+
end
|
|
168
|
+
return nil
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
@connection = fresh
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
fresh
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def start_keepalive
|
|
178
|
+
Async do
|
|
179
|
+
loop do
|
|
180
|
+
sleep @keepalive_interval
|
|
181
|
+
@connection&.send_ping
|
|
182
|
+
end
|
|
183
|
+
rescue Protocol::WebSocket::ProtocolError, IOError
|
|
184
|
+
# the connection is gone; the read loop is already winding down
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Serial dispatch — handlers never run in concurrent fibers.
|
|
189
|
+
def dispatch(message)
|
|
190
|
+
raw = decode(message)
|
|
191
|
+
return if raw.nil?
|
|
192
|
+
|
|
193
|
+
# Receipt confirmations echo a message_id the client already knows and
|
|
194
|
+
# are never redelivered by LIST_PENDING_MESSAGES — they are not
|
|
195
|
+
# dispatchable events, and acknowledging one back would only
|
|
196
|
+
# round-trip a no-op frame. Skip them before the ack policy sees them.
|
|
197
|
+
return if raw['action'] == 'ACKNOWLEDGE_MESSAGE_RECEIPT'
|
|
198
|
+
|
|
199
|
+
data = raw['data'].is_a?(Hash) ? raw['data'] : {}
|
|
200
|
+
message_id = data['message_id']
|
|
201
|
+
|
|
202
|
+
send_frame @api.acknowledge_message_receipt(message_id) if @ack_policy == :on_receipt && message_id
|
|
203
|
+
|
|
204
|
+
handler_ok =
|
|
205
|
+
begin
|
|
206
|
+
handler.call raw
|
|
207
|
+
true
|
|
208
|
+
rescue StandardError => e
|
|
209
|
+
log.call :handler_error, e
|
|
210
|
+
false
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
send_frame @api.acknowledge_message_receipt(message_id) if @ack_policy == :after_handler && handler_ok && message_id
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def decode(message)
|
|
217
|
+
JSON.parse @api.ws_message(message.to_str)
|
|
218
|
+
rescue Zlib::Error, JSON::ParserError => e
|
|
219
|
+
log.call :decode_error, e
|
|
220
|
+
nil
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def send_frame(bytes)
|
|
224
|
+
@connection.write Protocol::WebSocket::BinaryMessage.new(bytes.pack('C*'))
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def sleep_backoff(seconds)
|
|
228
|
+
@sleeper.call seconds
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def stable?(started_at)
|
|
232
|
+
monotonic_time - started_at >= STABLE_PERIOD
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def monotonic_time
|
|
236
|
+
Process.clock_gettime Process::CLOCK_MONOTONIC
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def log
|
|
240
|
+
@logger
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
data/lib/mixin_bot/cli/base.rb
CHANGED
|
@@ -59,6 +59,17 @@ module MixinBot
|
|
|
59
59
|
].freeze
|
|
60
60
|
attr_accessor(*CONFIGURABLE_ATTRS)
|
|
61
61
|
|
|
62
|
+
# Acknowledgement policies for the Blaze message loop:
|
|
63
|
+
# - +:on_receipt+ (default): ack immediately on receipt, before the handler
|
|
64
|
+
# runs (at-most-once dispatch).
|
|
65
|
+
# - +:after_handler+: ack only after the handler completes without raising
|
|
66
|
+
# (at-least-once dispatch; unacked messages are redelivered on reconnect).
|
|
67
|
+
BLAZE_ACK_POLICIES = %i[on_receipt after_handler].freeze
|
|
68
|
+
|
|
69
|
+
DEFAULT_BLAZE_ACK_POLICY = :on_receipt
|
|
70
|
+
|
|
71
|
+
attr_reader :blaze_handler, :blaze_ack_policy
|
|
72
|
+
|
|
62
73
|
##
|
|
63
74
|
# Initializes a new Configuration instance.
|
|
64
75
|
#
|
|
@@ -96,6 +107,10 @@ module MixinBot
|
|
|
96
107
|
|
|
97
108
|
@debug = kwargs[:debug] || false
|
|
98
109
|
|
|
110
|
+
@blaze_ack_policy = DEFAULT_BLAZE_ACK_POLICY
|
|
111
|
+
self.blaze_handler = kwargs[:blaze_handler]
|
|
112
|
+
self.blaze_ack_policy = kwargs[:blaze_ack_policy]
|
|
113
|
+
|
|
99
114
|
self.session_private_key = kwargs[:session_private_key] || kwargs[:private_key]
|
|
100
115
|
self.server_public_key = kwargs[:server_public_key] || kwargs[:pin_token]
|
|
101
116
|
self.spend_key = kwargs[:spend_key]
|
|
@@ -203,6 +218,43 @@ module MixinBot
|
|
|
203
218
|
end
|
|
204
219
|
end
|
|
205
220
|
|
|
221
|
+
##
|
|
222
|
+
# Sets the callable invoked for each decoded Blaze message envelope.
|
|
223
|
+
#
|
|
224
|
+
# Used by MixinBot::Blaze::Reactor (and the +plugin :mixin_blaze+ Puma
|
|
225
|
+
# plugin) to dispatch messages. The callable receives the full decoded
|
|
226
|
+
# message envelope Hash (with +action+ and +data+ keys).
|
|
227
|
+
#
|
|
228
|
+
# @param callable [#call, nil] the message handler; must respond to #call
|
|
229
|
+
# @raise [ArgumentError] if a non-callable, non-nil value is given
|
|
230
|
+
#
|
|
231
|
+
def blaze_handler=(callable)
|
|
232
|
+
unless callable.nil? || callable.respond_to?(:call)
|
|
233
|
+
raise ArgumentError,
|
|
234
|
+
"blaze_handler must respond to #call, got #{callable.inspect}"
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
@blaze_handler = callable
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
##
|
|
241
|
+
# Sets the Blaze acknowledgement policy.
|
|
242
|
+
#
|
|
243
|
+
# @param policy [Symbol] +:on_receipt+ (default) or +:after_handler+
|
|
244
|
+
# @raise [ArgumentError] for unknown policies
|
|
245
|
+
#
|
|
246
|
+
def blaze_ack_policy=(policy)
|
|
247
|
+
return if policy.nil?
|
|
248
|
+
|
|
249
|
+
policy = policy.to_sym
|
|
250
|
+
unless BLAZE_ACK_POLICIES.include?(policy)
|
|
251
|
+
raise ArgumentError,
|
|
252
|
+
"blaze_ack_policy must be one of #{BLAZE_ACK_POLICIES.join(', ')}, got #{policy.inspect}"
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
@blaze_ack_policy = policy
|
|
256
|
+
end
|
|
257
|
+
|
|
206
258
|
private
|
|
207
259
|
|
|
208
260
|
def decode_key(key)
|
data/lib/mixin_bot/version.rb
CHANGED
data/lib/mixin_bot.rb
CHANGED
|
@@ -26,6 +26,7 @@ require_relative 'mixin_bot/models'
|
|
|
26
26
|
require_relative 'mixin_bot/session_store'
|
|
27
27
|
require_relative 'mixin_bot/cache_store_adapter'
|
|
28
28
|
require_relative 'mixin_bot/api'
|
|
29
|
+
require_relative 'mixin_bot/blaze/reactor'
|
|
29
30
|
require_relative 'mixin_bot/bot_auth'
|
|
30
31
|
require_relative 'mixin_bot/cli'
|
|
31
32
|
require_relative 'mixin_bot/computer'
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Puma plugin hosting the Blaze connection inside the web process tree —
|
|
4
|
+
# the Solid Queue plugin architecture:
|
|
5
|
+
#
|
|
6
|
+
# # config/puma.rb
|
|
7
|
+
# plugin :mixin_blaze
|
|
8
|
+
# mixin_blaze_mode :fork # default; :async hosts the loop in-process
|
|
9
|
+
#
|
|
10
|
+
# # config/initializers/mixin_bot.rb
|
|
11
|
+
# MixinBot.configure do
|
|
12
|
+
# self.blaze_handler = ->(envelope) { MyBot.process! envelope }
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# Modes:
|
|
16
|
+
# - *fork* (default): after boot a dedicated child process runs
|
|
17
|
+
# {MixinBot::Blaze::Reactor}; a background thread supervises it and stops
|
|
18
|
+
# Puma if the child dies (fail fast — silent non-reception is worse than a
|
|
19
|
+
# visible restart). The child stops with Puma and monitors the master.
|
|
20
|
+
# - *async*: the reactor runs in a Puma background thread inside this very
|
|
21
|
+
# process. In cluster mode that process is the master.
|
|
22
|
+
#
|
|
23
|
+
# Requires `preload_app!` in cluster mode: plugins run in the launcher
|
|
24
|
+
# process, and without preloading it holds no application code to resolve
|
|
25
|
+
# the handler.
|
|
26
|
+
require 'English'
|
|
27
|
+
require 'mixin_bot'
|
|
28
|
+
require 'puma/plugin'
|
|
29
|
+
|
|
30
|
+
module Puma
|
|
31
|
+
class DSL
|
|
32
|
+
# Selects where the Blaze connection lives (see file comment).
|
|
33
|
+
#
|
|
34
|
+
# @param mode [Symbol] +:fork+ (default) or +:async+
|
|
35
|
+
def mixin_blaze_mode(mode = :fork)
|
|
36
|
+
@options[:mixin_blaze_mode] = mode.to_sym
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
Puma::Plugin.create do
|
|
42
|
+
def start(launcher)
|
|
43
|
+
@launcher = launcher
|
|
44
|
+
@log_writer = launcher.log_writer
|
|
45
|
+
@puma_pid = $PROCESS_ID
|
|
46
|
+
@mode = launcher.options[:mixin_blaze_mode] || :fork
|
|
47
|
+
|
|
48
|
+
unless %i[fork async].include?(@mode)
|
|
49
|
+
@log_writer.error "mixin_blaze: mixin_blaze_mode must be fork or async, got #{@mode.inspect}; plugin not started"
|
|
50
|
+
return
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
if launcher.options[:workers].to_i.positive? && !launcher.options[:preload_app]
|
|
54
|
+
@log_writer.error 'mixin_blaze: Puma cluster mode requires preload_app! (the launcher process hosts the Blaze handler); ' \
|
|
55
|
+
'enable preload_app! or drop to single mode; plugin not started'
|
|
56
|
+
return
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
@mode == :async ? start_async_mode : start_fork_mode
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def start_fork_mode
|
|
65
|
+
in_background { monitor_blaze_fork }
|
|
66
|
+
|
|
67
|
+
register_lifecycle(
|
|
68
|
+
booted: -> { fork_blaze },
|
|
69
|
+
stopped: -> { stop_blaze_fork },
|
|
70
|
+
restart: -> { stop_blaze_fork }
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def start_async_mode
|
|
75
|
+
register_lifecycle(
|
|
76
|
+
booted: -> { start_blaze_async },
|
|
77
|
+
stopped: -> { stop_blaze_async },
|
|
78
|
+
restart: lambda {
|
|
79
|
+
stop_blaze_async
|
|
80
|
+
start_blaze_async
|
|
81
|
+
}
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Puma 7 renamed the lifecycle hooks; the old names remain as deprecated
|
|
86
|
+
# aliases (same branching as Solid Queue's plugin).
|
|
87
|
+
def register_lifecycle(booted:, stopped:, restart:)
|
|
88
|
+
if Gem::Version.new(Puma::Const::VERSION) < Gem::Version.new('7')
|
|
89
|
+
@launcher.events.on_booted { booted.call }
|
|
90
|
+
@launcher.events.on_stopped { stopped.call }
|
|
91
|
+
@launcher.events.on_restart { restart.call }
|
|
92
|
+
else
|
|
93
|
+
@launcher.events.after_booted { booted.call }
|
|
94
|
+
@launcher.events.after_stopped { stopped.call }
|
|
95
|
+
@launcher.events.before_restart { restart.call }
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# ---- fork mode ----
|
|
100
|
+
|
|
101
|
+
def fork_blaze
|
|
102
|
+
reactor = build_reactor
|
|
103
|
+
return if reactor.nil?
|
|
104
|
+
|
|
105
|
+
@blaze_pid = fork do
|
|
106
|
+
@blaze_pid = nil
|
|
107
|
+
# the master's INT trap must not run here; raise Interrupt on the main
|
|
108
|
+
# thread instead, so the reactor unwinds and closes the connection
|
|
109
|
+
Signal.trap(:INT) { raise Interrupt }
|
|
110
|
+
Signal.trap(:TERM) { exit!(0) }
|
|
111
|
+
Thread.new { monitor_puma_master }
|
|
112
|
+
|
|
113
|
+
begin
|
|
114
|
+
reactor.run
|
|
115
|
+
ensure
|
|
116
|
+
# skip at-exit finalization, which can deadlock on inherited
|
|
117
|
+
# database handles (same rationale as Solid Queue's plugin)
|
|
118
|
+
exit!(0)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
log "mixin_blaze: forked Blaze child (pid #{@blaze_pid})"
|
|
123
|
+
rescue NotImplementedError
|
|
124
|
+
@log_writer.error 'mixin_blaze: fork is unavailable on this platform; use mixin_blaze_mode :async'
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def stop_blaze_fork
|
|
128
|
+
return unless @blaze_pid
|
|
129
|
+
|
|
130
|
+
@shutting_down = true
|
|
131
|
+
|
|
132
|
+
begin
|
|
133
|
+
Process.waitpid(@blaze_pid, Process::WNOHANG)
|
|
134
|
+
rescue Errno::ECHILD, Errno::ESRCH
|
|
135
|
+
# already gone; the reap loop below handles it
|
|
136
|
+
end
|
|
137
|
+
log 'mixin_blaze: stopping Blaze child...'
|
|
138
|
+
begin
|
|
139
|
+
Process.kill(:INT, @blaze_pid)
|
|
140
|
+
rescue Errno::ESRCH
|
|
141
|
+
@blaze_pid = nil
|
|
142
|
+
return
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# bounded reap: never let a stuck child hold up Puma's shutdown
|
|
146
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 10.0
|
|
147
|
+
reaped = false
|
|
148
|
+
while Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
|
|
149
|
+
begin
|
|
150
|
+
reaped = true if Process.waitpid(@blaze_pid, Process::WNOHANG)
|
|
151
|
+
break if reaped
|
|
152
|
+
rescue Errno::ECHILD, Errno::ESRCH
|
|
153
|
+
reaped = true
|
|
154
|
+
break
|
|
155
|
+
end
|
|
156
|
+
sleep 0.05
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
unless reaped
|
|
160
|
+
log "mixin_blaze: Blaze child #{@blaze_pid} did not stop within 10s; killing"
|
|
161
|
+
begin
|
|
162
|
+
Process.kill(:KILL, @blaze_pid)
|
|
163
|
+
rescue Errno::ESRCH
|
|
164
|
+
nil
|
|
165
|
+
end
|
|
166
|
+
begin
|
|
167
|
+
Process.waitpid(@blaze_pid) # KILL is fatal; reap the zombie
|
|
168
|
+
rescue Errno::ECHILD, Errno::ESRCH
|
|
169
|
+
nil
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
@blaze_pid = nil
|
|
173
|
+
rescue Errno::ECHILD, Errno::ESRCH
|
|
174
|
+
@blaze_pid = nil
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def monitor_blaze_fork
|
|
178
|
+
loop do
|
|
179
|
+
if blaze_fork_dead?
|
|
180
|
+
log 'mixin_blaze: Blaze child has gone away, stopping Puma...'
|
|
181
|
+
stop_puma!
|
|
182
|
+
break
|
|
183
|
+
end
|
|
184
|
+
sleep 2
|
|
185
|
+
end
|
|
186
|
+
rescue StandardError => e
|
|
187
|
+
log "mixin_blaze: monitor thread ended (#{e.class}: #{e.message})"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def blaze_fork_dead?
|
|
191
|
+
return false if @shutting_down
|
|
192
|
+
return false unless @blaze_pid
|
|
193
|
+
|
|
194
|
+
Process.waitpid(@blaze_pid, Process::WNOHANG)
|
|
195
|
+
false
|
|
196
|
+
rescue Errno::ECHILD, Errno::ESRCH
|
|
197
|
+
true
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def monitor_puma_master
|
|
201
|
+
loop do
|
|
202
|
+
if Process.ppid != @puma_pid
|
|
203
|
+
Process.kill(:INT, $PROCESS_ID)
|
|
204
|
+
break
|
|
205
|
+
end
|
|
206
|
+
sleep 2
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# ---- async mode ----
|
|
211
|
+
|
|
212
|
+
def start_blaze_async
|
|
213
|
+
reactor = build_reactor
|
|
214
|
+
return if reactor.nil?
|
|
215
|
+
|
|
216
|
+
@reactor = reactor
|
|
217
|
+
@blaze_thread = Thread.new do
|
|
218
|
+
Thread.current.name = 'puma plugin mixin_blaze' if Thread.current.respond_to?(:name=)
|
|
219
|
+
reactor.run
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
log "mixin_blaze: Blaze reactor running in-process (pid #{Process.pid})"
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def stop_blaze_async(join_limit: 1)
|
|
226
|
+
reactor = @reactor
|
|
227
|
+
thread = @blaze_thread
|
|
228
|
+
@reactor = nil
|
|
229
|
+
@blaze_thread = nil
|
|
230
|
+
|
|
231
|
+
return unless reactor
|
|
232
|
+
|
|
233
|
+
reactor.stop
|
|
234
|
+
thread&.join(join_limit)
|
|
235
|
+
log 'mixin_blaze: in-process Blaze reactor stopped'
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# ---- shared ----
|
|
239
|
+
|
|
240
|
+
# Handler resolution happens at boot time (after the application has
|
|
241
|
+
# loaded — Puma starts plugin background work after load_and_bind).
|
|
242
|
+
def build_reactor
|
|
243
|
+
handler = MixinBot.config.blaze_handler
|
|
244
|
+
if handler.nil?
|
|
245
|
+
@log_writer.error 'mixin_blaze: no handler configured — set MixinBot.configure { self.blaze_handler = ->(envelope) { ... } }; ' \
|
|
246
|
+
'plugin not started'
|
|
247
|
+
return nil
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
MixinBot::Blaze::Reactor.new(handler: handler, logger: ->(level, detail) { log("mixin_blaze #{level}: #{detail}") })
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def stop_puma!
|
|
254
|
+
Process.kill(:INT, $PROCESS_ID)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def log(message)
|
|
258
|
+
@log_writer.log(message)
|
|
259
|
+
rescue Errno::EIO, Errno::EPIPE, Errno::EBADF
|
|
260
|
+
# the controlling terminal can disappear mid-shutdown; keep going
|
|
261
|
+
nil
|
|
262
|
+
end
|
|
263
|
+
end
|
data/llms.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# MixinBot
|
|
2
2
|
|
|
3
|
-
> Ruby SDK and CLI for Mixin Network: Safe UTXO transfers, REST API, Blaze messaging, transaction crypto, optional MVM helpers. Ruby >=
|
|
3
|
+
> Ruby SDK and CLI for Mixin Network: Safe UTXO transfers, REST API, Blaze messaging, transaction crypto, optional MVM helpers. Ruby >= 4.0. Gem version 3.0.0.
|
|
4
4
|
|
|
5
5
|
Important notes:
|
|
6
6
|
|
metadata
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: mixin_bot
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 3.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- an-lee
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: bin
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: activesupport
|
|
@@ -24,6 +23,34 @@ dependencies:
|
|
|
24
23
|
- - ">="
|
|
25
24
|
- !ruby/object:Gem::Version
|
|
26
25
|
version: '7'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: async
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '2.0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '2.0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: async-websocket
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '0.30'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0.30'
|
|
27
54
|
- !ruby/object:Gem::Dependency
|
|
28
55
|
name: awesome_print
|
|
29
56
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -271,6 +298,7 @@ files:
|
|
|
271
298
|
- docs/agent/cli.md
|
|
272
299
|
- docs/agent/cookbook.md
|
|
273
300
|
- examples/blaze.rb
|
|
301
|
+
- examples/blaze_async.rb
|
|
274
302
|
- examples/config.yml.example
|
|
275
303
|
- lib/mixin_bot.rb
|
|
276
304
|
- lib/mixin_bot/address.rb
|
|
@@ -281,6 +309,7 @@ files:
|
|
|
281
309
|
- lib/mixin_bot/api/attachment.rb
|
|
282
310
|
- lib/mixin_bot/api/auth.rb
|
|
283
311
|
- lib/mixin_bot/api/blaze.rb
|
|
312
|
+
- lib/mixin_bot/api/blaze_async.rb
|
|
284
313
|
- lib/mixin_bot/api/chain.rb
|
|
285
314
|
- lib/mixin_bot/api/circle.rb
|
|
286
315
|
- lib/mixin_bot/api/code.rb
|
|
@@ -317,6 +346,7 @@ files:
|
|
|
317
346
|
- lib/mixin_bot/api/turn.rb
|
|
318
347
|
- lib/mixin_bot/api/user.rb
|
|
319
348
|
- lib/mixin_bot/api/withdraw.rb
|
|
349
|
+
- lib/mixin_bot/blaze/reactor.rb
|
|
320
350
|
- lib/mixin_bot/bot_auth.rb
|
|
321
351
|
- lib/mixin_bot/cache_store_adapter.rb
|
|
322
352
|
- lib/mixin_bot/cli.rb
|
|
@@ -371,6 +401,7 @@ files:
|
|
|
371
401
|
- lib/mvm/nft.rb
|
|
372
402
|
- lib/mvm/registry.rb
|
|
373
403
|
- lib/mvm/scan.rb
|
|
404
|
+
- lib/puma/plugin/mixin_blaze.rb
|
|
374
405
|
- lib/tasks/api_coverage.rake
|
|
375
406
|
- llms.txt
|
|
376
407
|
homepage: https://github.com/an-lee/mixin_bot
|
|
@@ -378,7 +409,6 @@ licenses:
|
|
|
378
409
|
- MIT
|
|
379
410
|
metadata:
|
|
380
411
|
rubygems_mfa_required: 'true'
|
|
381
|
-
post_install_message:
|
|
382
412
|
rdoc_options: []
|
|
383
413
|
require_paths:
|
|
384
414
|
- lib
|
|
@@ -386,15 +416,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
386
416
|
requirements:
|
|
387
417
|
- - ">="
|
|
388
418
|
- !ruby/object:Gem::Version
|
|
389
|
-
version:
|
|
419
|
+
version: 4.0.0
|
|
390
420
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
391
421
|
requirements:
|
|
392
422
|
- - ">="
|
|
393
423
|
- !ruby/object:Gem::Version
|
|
394
424
|
version: '0'
|
|
395
425
|
requirements: []
|
|
396
|
-
rubygems_version:
|
|
397
|
-
signing_key:
|
|
426
|
+
rubygems_version: 4.0.16
|
|
398
427
|
specification_version: 4
|
|
399
428
|
summary: A Ruby SDK for Mixin Network
|
|
400
429
|
test_files: []
|