unison-client 0.1.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.
data/Cargo.toml ADDED
@@ -0,0 +1,11 @@
1
+ # Ruby client gem の native 拡張 workspace。
2
+ #
3
+ # gem root に置くのは rb_sys(rake-compiler)の要請: `cargo metadata` を
4
+ # gem root から実行するため、ここに manifest が無いと cargo が親へ遡り
5
+ # club-unison の workspace を誤って拾う。
6
+ #
7
+ # 親 (club-unison) の Cargo workspace は `crates/*` のみを member とするため
8
+ # この workspace とは独立。両者が干渉することはない。
9
+ [workspace]
10
+ members = ["ext/unison_client"]
11
+ resolver = "3"
data/README.md ADDED
@@ -0,0 +1,110 @@
1
+ # unison-client (Ruby)
2
+
3
+ [Unison protocol](https://github.com/chronista-club/club-unison) の Ruby client。
4
+
5
+ ## アーキテクチャ
6
+
7
+ これは **言語バインディング**であって protocol の再実装ではない。
8
+ QUIC トランスポート・channel 多重化・wire framing は Rust の `club-unison`
9
+ crate が実装しており、この gem はそれを **Magnus**(Rust 製 Ruby native
10
+ extension)経由で薄く包む。
11
+
12
+ ```
13
+ Ruby (require "unison")
14
+ └─ native ext (Magnus, ext/unison_client/)
15
+ └─ club-unison crate (ProtocolClient — QUIC / channel / wire)
16
+ ```
17
+
18
+ 理由: Ruby に成熟した native QUIC スタックが無いため、protocol を Ruby で
19
+ 再実装するより Rust core を FFI で binding する方が経済的。TS SDK は browser
20
+ の WebTransport に乗れたので完全再実装したが、Ruby にはその前提が無い。
21
+
22
+ ## 状態
23
+
24
+ 接続ライフサイクルと channel 層を実装済み。
25
+
26
+ ```ruby
27
+ require "unison"
28
+
29
+ client = Unison::Client.new
30
+ client.connect("quic://[::1]:7878")
31
+
32
+ ch = client.open_channel("greeter")
33
+ ch.request("Hello", { "name" => "Mako" }) #=> レスポンス Hash
34
+ ch.send_event("Ping", { "seq" => 1 }) # 応答不要
35
+ ch.recv # 次の event を待つ(Hash)
36
+ ch.close
37
+
38
+ client.disconnect
39
+ ```
40
+
41
+ > **注意**: `Unison::Client.new` は証明書検証を行わない insecure な client を
42
+ > 構築する(loopback / 開発用途)。trust anchor を明示する secure constructor は
43
+ > 今後のフェーズ。
44
+
45
+ channel payload は native な Ruby 値(`Hash` / `Array` / scalar)で渡せる。
46
+ Rust 側で `serde_magnus` が `serde_json::Value` へ双方向変換し、channel の
47
+ JSON codec が処理する。
48
+
49
+ async は extension 内に埋めた tokio runtime で `block_on` する。ブロッキング
50
+ 呼び出しは `rb_thread_call_without_gvl` で **GVL を解放**するため、待機中も他の
51
+ Ruby スレッドは動き続ける(呼び出し自体の中断・タイムアウトは未対応 — 今後の
52
+ refinement)。
53
+
54
+ 失敗はすべて `Unison::Error`(`< StandardError`)として raise される。
55
+
56
+ 次フェーズ: GVL 解放中の呼び出しの中断(unblock function)、`recv` の timeout 版。
57
+
58
+ ## ビルド・テスト
59
+
60
+ ```
61
+ bundle install
62
+ bundle exec rake compile # native 拡張をビルド
63
+ bundle exec rake test # compile → 単体テスト(ネットワーク不要)
64
+ bundle exec rake test:e2e # compile → E2E(`unison mock` を subprocess 起動)
65
+ ```
66
+
67
+ `rake test:e2e` は `unison` バイナリ(`cargo build -p unison-cli` で生成、または
68
+ `UNISON_MOCK_BIN` で指定)を要する。見つからない場合は skip される。
69
+
70
+ **Ruby 3.4 以上が必須。** Ruby 3.4 系と 4.0 系の両方で動作する。開発環境の
71
+ version は `.mise.toml` に固定(既定 3.4.9、 4.0.5 も同居)。
72
+
73
+ ## ベンチマーク
74
+
75
+ ```
76
+ ruby bench/bench.rb > bench/runs/<date>-<tag>.kdl
77
+ ```
78
+
79
+ `unison mock` を subprocess 起動し、(1) `Channel#request` の RTT / throughput と
80
+ (2) GVL 解放の効果(ブロッキング呼び出し中に背景スレッドが進む割合)を計測し、
81
+ structured-log KDL を出力する。run は `bench/runs/` に immutable に蓄積し、
82
+ `bench/index.kdl` が append-only インデックスとして参照する。
83
+
84
+ ## 対応 protocol 世代
85
+
86
+ `1.0.0` GA — npm [`@chronista-club/unison-client@1.0.0`](https://www.npmjs.com/package/@chronista-club/unison-client) /
87
+ crates.io [`club-unison@1.0.0`](https://crates.io/crates/club-unison) と同世代。
88
+ gem 側の API は scaffold stage を抜けるまで gem 単独で stabilize する方針
89
+ (gem version は `0.x` 系)。
90
+
91
+ ## インストール
92
+
93
+ ```ruby
94
+ # Gemfile
95
+ gem "unison-client"
96
+ ```
97
+
98
+ ```bash
99
+ bundle install
100
+ # または
101
+ gem install unison-client
102
+ ```
103
+
104
+ source-only gem(gem ファイルに Rust source を bundle)なので、 install 時に
105
+ **Rust toolchain (`rustc` / `cargo`、 1.85 以上推奨)** が要求される。 toolchain が
106
+ 無い環境では rustup の `curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh`
107
+ 等で先に入れる。
108
+
109
+ > 将来的に platform-specific な prebuilt binary gem へ移行予定(nokogiri / grpc
110
+ > 等と同じ rake-compiler-dock + GitHub Actions matrix)。 0.1.0 では source-only。
@@ -0,0 +1,32 @@
1
+ # crate 名は拡張ターゲット名(unison_client)と一致させる。gem 名はハイフン
2
+ # 区切りの `unison-client`(gem 慣習)で、両者が別表記なのは想定どおり。
3
+ [package]
4
+ name = "unison_client"
5
+ version = "0.1.0"
6
+ edition = "2024"
7
+ publish = false
8
+ license = "MIT"
9
+ description = "Native extension for the unison-client Ruby gem — Magnus binding over club-unison."
10
+
11
+ [lib]
12
+ crate-type = ["cdylib"]
13
+
14
+ [dependencies]
15
+ magnus = "0.8"
16
+ # club-unison 本体(QUIC / channel / wire)。crate の package 名は club-unison、
17
+ # lib 名は unison なので Rust 側は `use unison::...`。
18
+ club-unison = "1.0.0"
19
+ # block_on で async API を Ruby の同期呼び出しへ橋渡しするための runtime。
20
+ tokio = { version = "1", features = ["rt-multi-thread"] }
21
+ # channel payload の Ruby 値 ⇄ serde_json::Value 変換。serde_magnus は magnus
22
+ # エコシステム標準で、magnus version を一致させる必要がある(0.11 → magnus 0.8)。
23
+ serde_json = "1"
24
+ serde_magnus = "0.11"
25
+ # GVL 解放(rb_thread_call_without_gvl)の raw FFI。magnus 0.8 はこの C API を
26
+ # 安全ラッパとして公開していないため rb-sys を直接使う。magnus が間接依存する
27
+ # のと同じ crate(version 一致で unify)。
28
+ rb-sys = "0.9"
29
+
30
+ # cargo 単体(clippy / check)は rb-sys が Ruby ABI を要求するため通らない。
31
+ # ビルド・lint は `bundle exec rake compile` 経由で行う(rb_sys が Ruby env を
32
+ # 注入する)。
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Generates a Makefile that builds the Rust crate in this directory into the
4
+ # gem's native extension, via rb-sys.
5
+ require "mkmf"
6
+ require "rb_sys/mkmf"
7
+
8
+ create_rust_makefile("unison_client/unison_client")
@@ -0,0 +1,255 @@
1
+ //! Native extension for the `unison-client` Ruby gem.
2
+ //!
3
+ //! Wraps the Rust `club-unison` crate as Ruby objects. The protocol itself
4
+ //! (QUIC transport, channel multiplexing, wire framing) lives in Rust; this
5
+ //! layer only bridges Ruby ⇄ Rust values and Ruby's blocking calls ⇄ the
6
+ //! async runtime.
7
+ //!
8
+ //! - `Unison::Client` — connection lifecycle, wraps `ProtocolClient`
9
+ //! - `Unison::Channel` — request/response + event push, wraps `UnisonChannel`
10
+ //! - `Unison::Error` — base class for every failure this binding raises
11
+ //!
12
+ //! Channel payloads cross the boundary as native Ruby values: `serde_magnus`
13
+ //! converts Ruby `Hash`/`Array`/… ⇄ `serde_json::Value`, which the channel's
14
+ //! JSON codec consumes.
15
+ //!
16
+ //! Blocking calls release the GVL while parked on the network (see
17
+ //! [`without_gvl`]), so other Ruby threads keep running.
18
+
19
+ use std::ffi::c_void;
20
+ use std::sync::OnceLock;
21
+
22
+ use magnus::{Error, ExceptionClass, Ruby, Value, function, method, prelude::*};
23
+ use serde_json::Value as JsonValue;
24
+ use tokio::runtime::Runtime;
25
+ use unison::{NetworkError, ProtocolClient, UnisonChannel};
26
+
27
+ /// Process-wide multi-thread tokio runtime backing every blocking bridge.
28
+ ///
29
+ /// One long-lived runtime: the QUIC reactor should outlive individual calls,
30
+ /// and building a runtime per call would be wasteful. Created lazily on first
31
+ /// use.
32
+ fn runtime() -> &'static Runtime {
33
+ static RT: OnceLock<Runtime> = OnceLock::new();
34
+ RT.get_or_init(|| Runtime::new().expect("failed to build the Unison tokio runtime"))
35
+ }
36
+
37
+ /// Runs `f` with Ruby's GVL released, letting other Ruby threads proceed.
38
+ ///
39
+ /// `f` MUST NOT touch any Ruby value or Ruby C API — it executes without the
40
+ /// GVL. It runs on the *calling* thread (`rb_thread_call_without_gvl` does not
41
+ /// move work elsewhere), so non-`Send` captures are fine.
42
+ ///
43
+ /// Limitations, both future refinements:
44
+ /// - No unblock function is registered, so a blocked call cannot be
45
+ /// interrupted by Ruby (e.g. `Thread#kill`).
46
+ /// - A panic inside `f` crosses an `extern "C"` boundary and aborts the
47
+ /// process. The closures here (`block_on` of QUIC ops) are not expected to
48
+ /// panic.
49
+ fn without_gvl<F, R>(f: F) -> R
50
+ where
51
+ F: FnOnce() -> R,
52
+ {
53
+ /// Carries the closure in and its result out across the C boundary.
54
+ struct Payload<F, R> {
55
+ func: Option<F>,
56
+ result: Option<R>,
57
+ }
58
+
59
+ unsafe extern "C" fn trampoline<F, R>(arg: *mut c_void) -> *mut c_void
60
+ where
61
+ F: FnOnce() -> R,
62
+ {
63
+ // SAFETY: `arg` is the `&mut Payload` passed below; Ruby invokes this
64
+ // exactly once, on this thread, before `rb_thread_call_without_gvl`
65
+ // returns.
66
+ let payload = unsafe { &mut *(arg as *mut Payload<F, R>) };
67
+ let func = payload.func.take().expect("without_gvl closure already ran");
68
+ payload.result = Some(func());
69
+ std::ptr::null_mut()
70
+ }
71
+
72
+ let mut payload = Payload::<F, R> {
73
+ func: Some(f),
74
+ result: None,
75
+ };
76
+ // SAFETY: `trampoline::<F, R>` interprets the data pointer as
77
+ // `&mut Payload<F, R>`, which is exactly what we hand it; no unblock
78
+ // function is used.
79
+ unsafe {
80
+ rb_sys::rb_thread_call_without_gvl(
81
+ Some(trampoline::<F, R>),
82
+ (&raw mut payload).cast(),
83
+ None,
84
+ std::ptr::null_mut(),
85
+ );
86
+ }
87
+ payload.result.expect("without_gvl closure did not run")
88
+ }
89
+
90
+ /// Current `Ruby` handle.
91
+ ///
92
+ /// Safe inside any function bound into Ruby: such code only runs while a Ruby
93
+ /// method is on the stack, so the handle is always available.
94
+ fn ruby() -> Ruby {
95
+ Ruby::get().expect("Unison binding used outside a Ruby thread")
96
+ }
97
+
98
+ /// Builds a `Unison::Error` exception carrying `msg`.
99
+ ///
100
+ /// `Unison::Error` is defined once in `init`; this re-fetches it via the
101
+ /// (idempotent) module handle rather than caching — error construction is
102
+ /// never a hot path.
103
+ fn unison_error(msg: impl Into<String>) -> Error {
104
+ let class: ExceptionClass = ruby()
105
+ .define_module("Unison")
106
+ .and_then(|m| m.const_get("Error"))
107
+ .expect("Unison::Error class is not defined");
108
+ Error::new(class, msg.into())
109
+ }
110
+
111
+ /// Turns a `NetworkError` into a `Unison::Error`.
112
+ fn net_err(e: NetworkError) -> Error {
113
+ unison_error(e.to_string())
114
+ }
115
+
116
+ /// The Unison protocol generation this client is built against.
117
+ fn protocol_target() -> &'static str {
118
+ "1.0.0-rc.1"
119
+ }
120
+
121
+ /// `Unison::Client` — a QUIC-backed Unison protocol client.
122
+ ///
123
+ /// `ProtocolClient`'s methods all take `&self`, so no interior mutability is
124
+ /// needed: the wrapped value is shared read-only and the QUIC state is
125
+ /// managed internally by the Rust crate.
126
+ #[magnus::wrap(class = "Unison::Client", free_immediately, size)]
127
+ struct Client {
128
+ inner: ProtocolClient,
129
+ }
130
+
131
+ impl Client {
132
+ /// `Unison::Client.new` — builds a default QUIC-backed client.
133
+ ///
134
+ /// Does not open a connection; call `#connect` for that.
135
+ ///
136
+ /// **Warning**: backed by `ProtocolClient::new_default()`, which builds an
137
+ /// **insecure** client — TLS certificate verification is skipped (intended
138
+ /// for loopback / development). A secure constructor taking explicit trust
139
+ /// anchors is future work.
140
+ fn new() -> Result<Self, Error> {
141
+ let inner = ProtocolClient::new_default()
142
+ .map_err(|e| unison_error(format!("Unison::Client.new failed: {e}")))?;
143
+ Ok(Self { inner })
144
+ }
145
+
146
+ /// `client.connect(url)` — opens the QUIC connection to `url`.
147
+ ///
148
+ /// Blocks the calling thread until the handshake completes (the GVL is
149
+ /// released, so other Ruby threads keep running). Raises `Unison::Error`
150
+ /// on failure.
151
+ fn connect(&self, url: String) -> Result<(), Error> {
152
+ without_gvl(|| runtime().block_on(self.inner.connect(&url))).map_err(net_err)
153
+ }
154
+
155
+ /// `client.connected?` — whether the QUIC connection is currently open.
156
+ fn connected(&self) -> bool {
157
+ without_gvl(|| runtime().block_on(self.inner.is_connected()))
158
+ }
159
+
160
+ /// `client.disconnect` — closes the QUIC connection.
161
+ ///
162
+ /// Raises `Unison::Error` only if the close itself errors.
163
+ fn disconnect(&self) -> Result<(), Error> {
164
+ without_gvl(|| runtime().block_on(self.inner.disconnect())).map_err(net_err)
165
+ }
166
+
167
+ /// `client.open_channel(name)` — opens a named channel, returning a
168
+ /// `Unison::Channel`. Raises `Unison::Error` on failure.
169
+ fn open_channel(&self, name: String) -> Result<Channel, Error> {
170
+ let inner = without_gvl(|| runtime().block_on(self.inner.open_channel(&name)))
171
+ .map_err(net_err)?;
172
+ Ok(Channel { inner })
173
+ }
174
+ }
175
+
176
+ /// `Unison::Channel` — a request/response + event-push channel.
177
+ ///
178
+ /// Constructed only via `Unison::Client#open_channel`; it has no public
179
+ /// allocator. Payloads are native Ruby values (`Hash`/`Array`/scalars),
180
+ /// carried over the channel's JSON codec.
181
+ #[magnus::wrap(class = "Unison::Channel", free_immediately, size)]
182
+ struct Channel {
183
+ inner: UnisonChannel,
184
+ }
185
+
186
+ impl Channel {
187
+ /// `channel.request(method, payload)` — sends a request and blocks until
188
+ /// the matching response arrives. Returns the response payload as a Ruby
189
+ /// value. Raises `Unison::Error` on a protocol error or timeout.
190
+ fn request(&self, method: String, payload: Value) -> Result<Value, Error> {
191
+ let ruby = ruby();
192
+ // Ruby → Rust conversion needs the GVL; do it before releasing it.
193
+ let req: JsonValue = serde_magnus::deserialize(&ruby, payload)?;
194
+ let resp: JsonValue = without_gvl(|| {
195
+ runtime().block_on(self.inner.request::<JsonValue, JsonValue>(&method, &req))
196
+ })
197
+ .map_err(net_err)?;
198
+ serde_magnus::serialize(&ruby, &resp)
199
+ }
200
+
201
+ /// `channel.send_event(method, payload)` — sends a fire-and-forget event
202
+ /// (no response is awaited).
203
+ fn send_event(&self, method: String, payload: Value) -> Result<(), Error> {
204
+ let event: JsonValue = serde_magnus::deserialize(&ruby(), payload)?;
205
+ without_gvl(|| runtime().block_on(self.inner.send_event(&method, &event)))
206
+ .map_err(net_err)
207
+ }
208
+
209
+ /// `channel.recv` — blocks until the next inbound event (server push or
210
+ /// other non-response message), returned as a Ruby `Hash` with keys
211
+ /// `"id"`, `"type"`, `"method"`, `"payload"`.
212
+ ///
213
+ /// The GVL is released while waiting, so other Ruby threads run; the call
214
+ /// itself is not interruptible and has no timeout (future refinements).
215
+ fn recv(&self) -> Result<Value, Error> {
216
+ let msg = without_gvl(|| runtime().block_on(self.inner.recv())).map_err(net_err)?;
217
+ let payload = msg.payload_as_value().map_err(net_err)?;
218
+ let out = serde_json::json!({
219
+ "id": msg.id,
220
+ "type": msg.msg_type,
221
+ "method": msg.method,
222
+ "payload": payload,
223
+ });
224
+ serde_magnus::serialize(&ruby(), &out)
225
+ }
226
+
227
+ /// `channel.close` — closes the channel and stops its receive loop.
228
+ fn close(&self) -> Result<(), Error> {
229
+ without_gvl(|| runtime().block_on(self.inner.close())).map_err(net_err)
230
+ }
231
+ }
232
+
233
+ #[magnus::init]
234
+ fn init(ruby: &Ruby) -> Result<(), Error> {
235
+ let module = ruby.define_module("Unison")?;
236
+ module.define_module_function("protocol_target", function!(protocol_target, 0))?;
237
+
238
+ // `Unison::Error` — base class for every failure raised by this binding.
239
+ module.define_error("Error", ruby.exception_standard_error())?;
240
+
241
+ let client = module.define_class("Client", ruby.class_object())?;
242
+ client.define_singleton_method("new", function!(Client::new, 0))?;
243
+ client.define_method("connect", method!(Client::connect, 1))?;
244
+ client.define_method("connected?", method!(Client::connected, 0))?;
245
+ client.define_method("disconnect", method!(Client::disconnect, 0))?;
246
+ client.define_method("open_channel", method!(Client::open_channel, 1))?;
247
+
248
+ let channel = module.define_class("Channel", ruby.class_object())?;
249
+ channel.define_method("request", method!(Channel::request, 2))?;
250
+ channel.define_method("send_event", method!(Channel::send_event, 2))?;
251
+ channel.define_method("recv", method!(Channel::recv, 0))?;
252
+ channel.define_method("close", method!(Channel::close, 0))?;
253
+
254
+ Ok(())
255
+ }
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unison
4
+ # Gem version. The gem is in scaffold stage; the Unison protocol generation
5
+ # it targets is reported by the native extension (`Unison.protocol_target`).
6
+ VERSION = "0.1.0"
7
+ end
data/lib/unison.rb ADDED
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "unison/version"
4
+
5
+ # Load the Rust native extension (built by `rake compile` into
6
+ # `lib/unison_client/`). It defines the Rust-backed `Unison` module functions.
7
+ require "unison_client/unison_client"
8
+
9
+ # Unison — a Ruby client for the Unison protocol.
10
+ #
11
+ # This gem is a thin **language binding**, not a re-implementation: the
12
+ # protocol itself (QUIC transport, channel multiplexing, wire framing) lives
13
+ # in the Rust `club-unison` crate. The native extension (Magnus) wraps that
14
+ # crate's `ProtocolClient`.
15
+ #
16
+ # `Unison::Client` wraps the crate's `ProtocolClient` (connection lifecycle:
17
+ # `new` / `connect` / `connected?` / `disconnect` / `open_channel`), and
18
+ # `Unison::Channel` wraps `UnisonChannel` (`request` / `send_event` / `recv` /
19
+ # `close`). Channel payloads are native Ruby values.
20
+ module Unison
21
+ end
metadata ADDED
@@ -0,0 +1,65 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: unison-client
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Mako
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rb_sys
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.9'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.9'
26
+ description: A thin Ruby binding over the Rust `club-unison` crate's ProtocolClient,
27
+ via a Magnus native extension. The protocol — QUIC transport, channel multiplexing,
28
+ wire framing — is implemented in Rust; this gem is the language binding.
29
+ email:
30
+ - mito@chronista.club
31
+ executables: []
32
+ extensions:
33
+ - ext/unison_client/extconf.rb
34
+ extra_rdoc_files: []
35
+ files:
36
+ - Cargo.lock
37
+ - Cargo.toml
38
+ - README.md
39
+ - ext/unison_client/Cargo.toml
40
+ - ext/unison_client/extconf.rb
41
+ - ext/unison_client/src/lib.rs
42
+ - lib/unison.rb
43
+ - lib/unison/version.rb
44
+ homepage: https://github.com/chronista-club/club-unison
45
+ licenses:
46
+ - MIT
47
+ metadata: {}
48
+ rdoc_options: []
49
+ require_paths:
50
+ - lib
51
+ required_ruby_version: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: 3.4.0
56
+ required_rubygems_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ requirements: []
62
+ rubygems_version: 3.6.9
63
+ specification_version: 4
64
+ summary: Ruby client for the Unison protocol.
65
+ test_files: []