jrpc 1.1.8 → 2.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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +55 -0
  3. data/.rspec +1 -0
  4. data/.rubocop.yml +228 -0
  5. data/CHANGELOG.md +67 -0
  6. data/Gemfile +17 -0
  7. data/README.md +240 -13
  8. data/Rakefile +3 -1
  9. data/bin/console +1 -0
  10. data/bin/jrpc +37 -26
  11. data/bin/jrpc-shell +34 -24
  12. data/jrpc.gemspec +6 -8
  13. data/lib/jrpc/errors.rb +65 -0
  14. data/lib/jrpc/id_generator.rb +22 -0
  15. data/lib/jrpc/message.rb +78 -0
  16. data/lib/jrpc/payload_logging.rb +19 -0
  17. data/lib/jrpc/shared_client/outbound_queue.rb +71 -0
  18. data/lib/jrpc/shared_client/registry.rb +46 -0
  19. data/lib/jrpc/shared_client/ticket.rb +84 -0
  20. data/lib/jrpc/shared_client/transport_loop.rb +298 -0
  21. data/lib/jrpc/shared_client.rb +194 -0
  22. data/lib/jrpc/simple_client.rb +98 -0
  23. data/lib/jrpc/transport/base.rb +63 -0
  24. data/lib/jrpc/transport/tcp.rb +292 -0
  25. data/lib/jrpc/transport/test.rb +333 -0
  26. data/lib/jrpc/transport.rb +12 -0
  27. data/lib/jrpc/version.rb +3 -1
  28. data/lib/jrpc.rb +14 -16
  29. metadata +25 -71
  30. data/.travis.yml +0 -4
  31. data/lib/jrpc/base_client.rb +0 -123
  32. data/lib/jrpc/error/client_error.rb +0 -5
  33. data/lib/jrpc/error/connection_error.rb +0 -11
  34. data/lib/jrpc/error/error.rb +0 -5
  35. data/lib/jrpc/error/internal_error.rb +0 -9
  36. data/lib/jrpc/error/internal_server_error.rb +0 -5
  37. data/lib/jrpc/error/invalid_params.rb +0 -9
  38. data/lib/jrpc/error/invalid_request.rb +0 -9
  39. data/lib/jrpc/error/method_not_found.rb +0 -9
  40. data/lib/jrpc/error/parse_error.rb +0 -9
  41. data/lib/jrpc/error/server_error.rb +0 -11
  42. data/lib/jrpc/error/unknown_error.rb +0 -5
  43. data/lib/jrpc/tcp_client.rb +0 -112
  44. data/lib/jrpc/transport/socket_base.rb +0 -88
  45. data/lib/jrpc/transport/socket_tcp.rb +0 -132
  46. data/lib/jrpc/utils.rb +0 -9
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e116c8eef34ce5bd8a4ced0e2e693ad37df69895e67c3d28d68537c60711d012
4
- data.tar.gz: 8a60df19ba2676a28c03925d0be33b8cda720f9da147e4854a87cba9f25dd251
3
+ metadata.gz: c257edbf0909e62cffe47dc7b8a79e928b4b4c24f7166bbbbec3b7141d01270f
4
+ data.tar.gz: 671298a50f68328de11586e0e80eeba0ae18814ce79827b57dcd54699605c20c
5
5
  SHA512:
6
- metadata.gz: 10a940570df20f1246c61c6df6d2c950e9acf5e16e397727c6693822f121bfeff14409529b732e385f2f8806ed9c26d32ddcd9c545406ab54c3b26b5e9ee6db2
7
- data.tar.gz: e79fc4f45058b4aa8570928866d97fb9e473b805fbc2c730879d0d6478824e91c9a37728900fde9e827a58753978b3b9468dd03d145d6c2cd09310bafceb49e8
6
+ metadata.gz: 807cca67ef7ae784989daa7cf89797d9e255f77087822d1c1cae607839d02b2aed75916e5b1e75c47a11bfba5526eccd8df257fd1e36cf38029ba02e137ded28
7
+ data.tar.gz: c987f2beba104d457137aeaa74821db2fb6835dee9161c39c50f1e409f254efc924c8c23178cc43e07867e083d7c11ebfd1dbf8f3a70d617c652380267abc224
@@ -0,0 +1,55 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - master
7
+ pull_request:
8
+
9
+ permissions:
10
+ contents: read
11
+ pull-requests: write
12
+
13
+ jobs:
14
+ rspec:
15
+ name: RSpec (Ruby ${{ matrix.ruby }})
16
+ runs-on: ubuntu-latest
17
+ strategy:
18
+ fail-fast: false
19
+ matrix:
20
+ ruby:
21
+ - '3.3'
22
+ - '3.4'
23
+ steps:
24
+ - uses: actions/checkout@v4
25
+
26
+ - name: Set up Ruby
27
+ uses: ruby/setup-ruby@v1
28
+ with:
29
+ ruby-version: ${{ matrix.ruby }}
30
+ bundler-cache: true
31
+
32
+ - name: Run RuboCop
33
+ run: bundle exec rubocop --parallel
34
+
35
+ - name: Run tests
36
+ run: bundle exec rake spec
37
+
38
+ - name: Code Coverage Summary
39
+ if: matrix.ruby == '3.4'
40
+ uses: irongut/CodeCoverageSummary@v1.3.0
41
+ with:
42
+ filename: coverage/coverage.xml
43
+ format: markdown
44
+ output: both
45
+
46
+ - name: Write coverage to job summary
47
+ if: matrix.ruby == '3.4'
48
+ run: cat code-coverage-results.md >> "$GITHUB_STEP_SUMMARY"
49
+
50
+ - name: Add coverage comment to PR
51
+ if: matrix.ruby == '3.4' && github.event_name == 'pull_request'
52
+ uses: marocchino/sticky-pull-request-comment@v2
53
+ with:
54
+ recreate: true
55
+ path: code-coverage-results.md
data/.rspec CHANGED
@@ -1,2 +1,3 @@
1
1
  --format documentation
2
2
  --color
3
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,228 @@
1
+ plugins:
2
+ - rubocop-performance
3
+ - rubocop-rspec
4
+ - rubocop-rake
5
+
6
+ AllCops:
7
+ TargetRubyVersion: 3.3
8
+ DisplayStyleGuide: true
9
+ ExtraDetails: true
10
+ NewCops: enable
11
+ Exclude:
12
+ - 'vendor/**/*'
13
+ - 'tmp/**/*'
14
+
15
+ ##################### Bundler ###############################
16
+
17
+ Bundler/OrderedGems:
18
+ Exclude:
19
+ - Gemfile
20
+
21
+ ##################### Gemspec ###############################
22
+
23
+ Gemspec/RequireMFA:
24
+ Enabled: false
25
+
26
+ Gemspec/RequiredRubyVersion:
27
+ Enabled: false
28
+
29
+ ##################### Lint ###############################
30
+
31
+ Lint/UnderscorePrefixedVariableName:
32
+ Enabled: false
33
+
34
+ Lint/AmbiguousOperatorPrecedence:
35
+ Enabled: false
36
+
37
+ Lint/MissingSuper:
38
+ Enabled: false
39
+
40
+ Lint/AmbiguousBlockAssociation:
41
+ Exclude:
42
+ - 'spec/**/*.rb'
43
+
44
+ Lint/UselessAccessModifier:
45
+ Enabled: true
46
+ MethodCreatingMethods:
47
+ - delegate
48
+ - attribute
49
+ - service_entity
50
+ - parameter
51
+ - parameters
52
+ ContextCreatingMethods:
53
+ - concerning
54
+ - included
55
+
56
+ ##################### Layout #############################
57
+
58
+ Layout/LineLength:
59
+ Max: 311
60
+
61
+ ##################### Performance #########################
62
+
63
+ Performance/CollectionLiteralInLoop:
64
+ Exclude:
65
+ - 'spec/**/*.rb'
66
+
67
+ ##################### Style ###############################
68
+
69
+ Style/StringLiterals:
70
+ EnforcedStyle: single_quotes
71
+
72
+ Style/StringLiteralsInInterpolation:
73
+ EnforcedStyle: single_quotes
74
+
75
+ Style/KeywordParametersOrder:
76
+ Enabled: false
77
+
78
+ Style/IfUnlessModifier:
79
+ Enabled: false
80
+
81
+ Style/WhenThen:
82
+ Enabled: false
83
+
84
+ Style/SafeNavigation:
85
+ Enabled: false
86
+
87
+ Style/Documentation:
88
+ Enabled: false
89
+
90
+ Style/SymbolArray:
91
+ Enabled: false
92
+
93
+ Style/HashAsLastArrayItem:
94
+ Enabled: false
95
+
96
+ Style/ClassAndModuleChildren:
97
+ Enabled: false
98
+
99
+ Style/GuardClause:
100
+ Enabled: false
101
+
102
+ Style/Lambda:
103
+ Enabled: false
104
+ EnforcedStyle: literal
105
+
106
+ Style/WordArray:
107
+ Enabled: false
108
+
109
+ Style/BlockDelimiters:
110
+ Enabled: true
111
+ EnforcedStyle: braces_for_chaining
112
+
113
+ Style/InvertibleUnlessCondition:
114
+ Enabled: true
115
+ InverseMethods:
116
+ :!=: :==
117
+ :>: :<=
118
+ :<=: :>
119
+ :<: :>=
120
+ :>=: :<
121
+ :!~: :=~
122
+ :zero?: :nonzero?
123
+ :nonzero?: :zero?
124
+ :any?: :none?
125
+ :none?: :any?
126
+ :even?: :odd?
127
+ :odd?: :even?
128
+ :present?: :blank?
129
+ :blank?: :present?
130
+
131
+ ##################### Metrics #############################
132
+
133
+ Metrics/PerceivedComplexity:
134
+ Max: 45
135
+
136
+ Metrics/ClassLength:
137
+ Max: 1060
138
+
139
+ Metrics/BlockNesting:
140
+ Max: 5
141
+
142
+ Metrics/BlockLength:
143
+ Enabled: false
144
+
145
+ Metrics/CyclomaticComplexity:
146
+ Max: 45
147
+
148
+ Metrics/MethodLength:
149
+ Enabled: false
150
+
151
+ Metrics/ModuleLength:
152
+ Enabled: false
153
+
154
+ Metrics/AbcSize:
155
+ Max: 241
156
+
157
+ Metrics/ParameterLists:
158
+ Max: 9
159
+
160
+ ##################### Naming ##############################
161
+
162
+ Naming/MethodParameterName:
163
+ AllowedNames:
164
+ - "iv"
165
+ - "by"
166
+ - "to"
167
+ - "id"
168
+ - "io"
169
+ - "on"
170
+
171
+ Naming/VariableNumber:
172
+ Enabled: false
173
+
174
+ Naming/PredicatePrefix:
175
+ Enabled: false
176
+
177
+ Naming/PredicateMethod:
178
+ Enabled: false
179
+
180
+ ##################### RSpec ###############################
181
+
182
+ RSpec/NamedSubject:
183
+ Enabled: false
184
+
185
+ RSpec/MultipleMemoizedHelpers:
186
+ Enabled: false
187
+
188
+ RSpec/ExampleLength:
189
+ Max: 141
190
+
191
+ RSpec/MultipleExpectations:
192
+ Max: 79
193
+
194
+ RSpec/DescribeClass:
195
+ Enabled: false
196
+
197
+ RSpec/MessageSpies:
198
+ Enabled: false
199
+
200
+ RSpec/SharedExamples:
201
+ Enabled: false
202
+
203
+ RSpec/NestedGroups:
204
+ Max: 18
205
+
206
+ RSpec/ContextWording:
207
+ Enabled: false
208
+
209
+ RSpec/ExampleWording:
210
+ Enabled: false
211
+
212
+ RSpec/ExpectChange:
213
+ Enabled: false
214
+
215
+ RSpec/AnyInstance:
216
+ Enabled: false
217
+
218
+ RSpec/SpecFilePathFormat:
219
+ Enabled: false
220
+
221
+ RSpec/IndexedLet:
222
+ Enabled: false
223
+
224
+ RSpec/ExpectInLet:
225
+ Enabled: true
226
+
227
+ RSpec/IncludeExamples:
228
+ Enabled: false
data/CHANGELOG.md CHANGED
@@ -2,6 +2,73 @@
2
2
 
3
3
  ### Unreleased
4
4
 
5
+ **New**
6
+
7
+ * Debug-level wire-payload logging. When a `logger:` is configured, both
8
+ `SimpleClient` and `SharedClient` emit every request/response payload (the raw
9
+ JSON frame, exactly as written/read) at `DEBUG`, tagged `[JRPC::SimpleClient]`
10
+ / `[JRPC::SharedClient]` with `>>` (sent) / `<<` (received) markers. No logger,
11
+ no logging.
12
+ * `JRPC::Transport::Test` — an in-process transport double for testing code that
13
+ uses JRPC, without a real server. Not required by default: `require
14
+ 'jrpc/transport/test'`, then inject via `transport:` on either client. Stub
15
+ methods with `on('method') { |params| ... }` (return value becomes the result;
16
+ raise a `JRPC::Errors::ServerError` for an error response, or a transport error
17
+ to simulate a socket failure); records `requests`/`notifications`/`sent` for
18
+ assertions. A raw escape hatch (`push_response`/`push_raise`, `strict: false`)
19
+ covers malformed-response, id-mismatch, and orphan-frame cases. Works with both
20
+ `SimpleClient` and `SharedClient`. (Closes #10.)
21
+ * Optional TCP MD5 Signature (RFC2385) support. Pass `tcp_md5_pass:` to
22
+ `SimpleClient`/`SharedClient` (or the transport directly) to authenticate the
23
+ connection with a per-peer MD5 key. Linux-only (`TCP_MD5SIG`); the key is
24
+ installed on the socket before connect, and a connect on a kernel/platform
25
+ without `TCP_MD5SIG` raises `ConnectionError`.
26
+
27
+ ### 2.0.0
28
+
29
+ Full rewrite. JRPC 2.0 is not API-compatible with 1.x.
30
+
31
+ **New**
32
+
33
+ * `JRPC::SharedClient` — one shared instance, one connection, serving many caller
34
+ threads and/or fibers. Owns a dedicated transport thread that multiplexes
35
+ responses by id. Supports Puma threads, rage-rb/Falcon fibers, and mixed
36
+ thread/fiber callers. Fiber callers require a spec-compliant `Fiber.scheduler`.
37
+ (Internally drafted as `ThreadQueueClient`; never shipped under that name.)
38
+ * `JRPC::SimpleClient` — single-threaded client, the functional replacement for
39
+ the old `TcpClient`.
40
+ * `concurrent-ruby` (`~> 1.2`) added as a runtime dependency (backs the shared
41
+ client's result futures).
42
+ * `logger` added as an explicit runtime dependency (no longer guaranteed bundled
43
+ on Ruby 3.5+).
44
+
45
+ **Removed / breaking**
46
+
47
+ * `JRPC::TcpClient` removed — use `JRPC::SimpleClient`.
48
+ * `JRPC::BaseClient` removed, including the `BaseClient.connect` block helper.
49
+ * All top-level error constants moved under `JRPC::Errors::*`.
50
+ * `method_missing` magic removed — pass the full method name as a String or Symbol.
51
+ * `invoke_request` / `invoke_notification` removed.
52
+ * `perform_request` removed — use `request` and `notification`.
53
+ * `namespace:` option removed.
54
+ * Umbrella `timeout:` option removed — use `read_timeout` / `write_timeout` /
55
+ `connect_timeout` (`SimpleClient`), or `ttl:` (`SharedClient`).
56
+ * `close_after_sent:` renamed to `autoclose:`.
57
+ * `connect_retry_count` default changed from `10` to `0`.
58
+ * Constructors no longer connect eagerly — the first call connects.
59
+ * Malformed responses now raise `Errors::MalformedResponseError` (a `ServerError`),
60
+ not `ClientError`. In 1.x the missing-comma-terminator case raised `ClientError`.
61
+ * `SimpleClient` read/write/connect timeouts now raise `Errors::Timeout`, not
62
+ `ConnectionError`.
63
+ * `oj` runtime dependency dropped — JRPC uses stdlib `json`. For Oj speed,
64
+ `require 'oj'; Oj.mimic_JSON` yourself.
65
+ * `netstring` is no longer a dependency — framing is owned in-tree by the transport.
66
+ * `required_ruby_version` set to `>= 3.3` (the floor where the
67
+ `ConditionVariable` ↔ `Fiber.scheduler` cooperation that fiber callers depend on
68
+ is verified).
69
+ * `bin/jrpc` and `bin/jrpc-shell` rewritten on top of `SimpleClient`; flag/usage
70
+ changes (see `README.md` and `jrpc --help`).
71
+
5
72
  ### 1.1.8
6
73
  * handling FIN signal for TCP socket [didww/jrpc#19](https://github.com/didww/jrpc/pull/19)
7
74
  * add gem executables [didww/jrpc#19](https://github.com/didww/jrpc/pull/19)
data/Gemfile CHANGED
@@ -1,4 +1,21 @@
1
+ # frozen_string_literal: true
2
+
1
3
  source 'https://rubygems.org'
2
4
 
3
5
  # Specify your gem's dependencies in jrpc.gemspec
4
6
  gemspec
7
+
8
+ gem 'bundler'
9
+ gem 'rake', '~> 13.0'
10
+ gem 'rspec', '~> 3.0'
11
+
12
+ # Provides a real Fiber.scheduler for the fiber-caller specs (SharedClient §9.8).
13
+ gem 'async', '~> 2.0'
14
+
15
+ gem 'rubocop', '~> 1.21'
16
+ gem 'rubocop-performance'
17
+ gem 'rubocop-rspec'
18
+ gem 'rubocop-rake', '~> 0.7.1'
19
+
20
+ gem 'simplecov', '~> 0.22', require: false
21
+ gem 'simplecov-cobertura', '~> 3.1', require: false
data/README.md CHANGED
@@ -1,33 +1,260 @@
1
1
  # JRPC
2
2
 
3
- JSON RPC TCP client
3
+ [![Gem Version](https://badge.fury.io/rb/jrpc.svg)](https://rubygems.org/gems/jrpc)
4
+ [![CI](https://github.com/didww/jrpc/actions/workflows/ci.yml/badge.svg)](https://github.com/didww/jrpc/actions/workflows/ci.yml)
4
5
 
5
- ## Installation
6
+ A JSON-RPC 2.0 client for Ruby, over TCP, with netstring framing.
7
+
8
+ JRPC ships two clients with sharp, separate responsibilities:
9
+
10
+ | | `JRPC::SimpleClient` | `JRPC::SharedClient` |
11
+ |---|---|---|
12
+ | Concurrency | single thread/fiber only | shared across many threads **and/or** fibers |
13
+ | Connection | one socket, lazy connect | one shared socket, dedicated transport thread |
14
+ | Multiplexing | one in-flight call at a time | many in-flight calls, id-demuxed |
15
+ | Timeouts | per-call `read_timeout`/`write_timeout` | per-message `ttl` |
16
+ | Use it for | CLI tools, scripts, one-shot calls, per-thread pools | Rails+Puma, rage-rb, Falcon, any long-lived shared client |
6
17
 
7
- Add this line to your application's Gemfile:
18
+ Pick `SimpleClient` unless you need one client instance to serve concurrent callers. It is not thread-safe or fiber-safe; use one instance per thread/fiber (or a pool). Pick `SharedClient` when a single process-wide instance must serve many caller threads or fibers over a single connection.
19
+
20
+ ## Installation
8
21
 
9
22
  ```ruby
10
23
  gem 'jrpc'
11
24
  ```
12
25
 
13
- And then execute:
26
+ ```sh
27
+ $ bundle install
28
+ ```
14
29
 
15
- $ bundle
30
+ Requires **Ruby >= 3.3**. Fiber callers additionally require a spec-compliant `Fiber.scheduler` (e.g. [`async`](https://github.com/socketry/async)) on their thread — see [Fiber callers](#fiber-callers).
16
31
 
17
- Or install it yourself as:
32
+ ## SimpleClient
18
33
 
19
- $ gem install jrpc
34
+ ```ruby
35
+ client = JRPC::SimpleClient.new(
36
+ "127.0.0.1:1234",
37
+ connect_timeout: 60, # total wall-clock budget for connect, across retries (seconds)
38
+ read_timeout: 60,
39
+ write_timeout: 60,
40
+ connect_retry_count: 0, # retries after the first failed connect
41
+ autoclose: false, # close the socket after every call
42
+ id_prefix: nil, # random per instance if nil
43
+ tcp_md5_pass: nil, # RFC2385 TCP MD5 Signature key (Linux-only); nil disables
44
+ logger: nil # when set, logs every wire payload at DEBUG; nil disables
45
+ )
20
46
 
21
- ## Usage
47
+ result = client.request(:sum, [1, 2])
48
+ result = client.request(:sum, [1, 2], read_timeout: 10, write_timeout: 10)
49
+ client.notification(:log, { msg: "hi" })
50
+ client.notification(:log, { msg: "hi" }, write_timeout: 10)
22
51
 
23
- TODO: Write usage instructions here
52
+ client.close # terminal; the instance cannot be reused
53
+ client.closed? # => true
54
+ client.server # => "127.0.0.1:1234"
55
+ ```
24
56
 
25
- ## Contributing
57
+ Behavior:
26
58
 
27
- Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/jrpc.
59
+ - The constructor does **not** open the connection. The first `request`/`notification` connects.
60
+ - `autoclose: true` closes the socket in an `ensure` after each call. The **client is still reusable** — the next call reconnects. `autoclose` controls the socket, not the client.
61
+ - `#close` is **terminal**. After it, `#closed?` is `true` and every call raises `ClientError("client closed")`. There is no reopen — make a new client.
62
+ - Not thread-safe, not fiber-safe.
28
63
 
64
+ ## SharedClient
29
65
 
30
- ## License
66
+ One instance, one connection, many concurrent callers. A dedicated transport thread owns the socket and demultiplexes responses by id.
67
+
68
+ ```ruby
69
+ client = JRPC::SharedClient.new(
70
+ "127.0.0.1:1234",
71
+ connect_timeout: 60,
72
+ connect_retry_count: 0,
73
+ connect_retry_interval: 0.5,
74
+ write_timeout: 5, # MUST be < default_ttl (see below)
75
+ reap_timeout: nil, # nil = never close an idle connection
76
+ default_ttl: 30, # per-message lifetime, seconds
77
+ max_queue_size: 10_000, # bounded; pass nil for unbounded (opt-in OOM risk)
78
+ id_prefix: nil,
79
+ tcp_md5_pass: nil, # RFC2385 TCP MD5 Signature key (Linux-only); nil disables
80
+ logger: nil # when set, logs every wire payload at DEBUG; nil disables
81
+ )
82
+
83
+ result = client.request(:sum, [1, 2])
84
+ result = client.request(:sum, [1, 2], ttl: 10)
85
+
86
+ client.notification(:log, { msg: "hi" })
87
+ client.notification(:log, { msg: "hi" }, ttl: 5)
88
+ client.notification(:metric, [1], fire_and_forget: true) # send errors/TTL expiry are logged, not raised
89
+
90
+ client.close # graceful shutdown, default timeout: 5 seconds
91
+ client.close(timeout: 10)
92
+ client.closed?
93
+ client.server
94
+ ```
95
+
96
+ Behavior:
97
+
98
+ - **TTL, not per-call timeout.** Each message carries `expires_at = now + ttl`. The transport thread is the timer authority; the caller blocks until the message resolves, fails, or its TTL elapses. `ttl: nil` blocks forever (opt-in).
99
+ - **`write_timeout < default_ttl` is enforced.** While the transport thread is parked in a single `write_frame`, it cannot fire TTL deadlines for other messages, so `write_timeout` is the maximum TTL-firing lag. The constructor raises `ArgumentError` if `write_timeout >= default_ttl`.
100
+ - **`notification` blocks until sent by default** (send errors propagate). Pass `fire_and_forget: true` to return immediately; then send errors and TTL expiry are logged, not raised. `request` never accepts `fire_and_forget`.
101
+ - **Bounded queue.** When the outbound queue is at `max_queue_size`, enqueue raises `ClientError("queue full")`.
102
+ - **Connection drops** resolve every in-flight request with `ConnectionError`; the transport thread keeps running and reconnects on the next message.
103
+ - **Reaping.** With `reap_timeout` set, the connection closes after that many idle seconds (no in-flight messages, empty queue, no bytes received) and reopens on the next message.
104
+ - `#close` is graceful: it lets in-flight work drain up to `timeout`, then force-closes. It returns `true` on a clean join, `false` on a forced close. Idempotent.
105
+ - A crash in the transport thread is surfaced, not hidden: in-flight requests fail with `ConnectionError`, the client transitions to an unusable state, and every subsequent call raises `ClientError("client unusable: transport thread exited")`. The client does not auto-restart — instantiate a new one.
106
+
107
+ ### Fiber callers
31
108
 
32
- The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
109
+ `SharedClient` is shareable across the full Ruby concurrency matrix:
110
+
111
+ | Deployment | Caller is a... |
112
+ |---|---|
113
+ | Rails + Puma | Thread |
114
+ | rage-rb | Fiber under a single-thread Async reactor |
115
+ | Rails + Falcon | Fiber under a multi-thread Async reactor |
116
+ | Mixed | Some threads, some fibers, one client instance |
117
+
118
+ A caller blocks in a scheduler-aware wait, so a fiber under `Async`/`Falcon`/`rage-rb` **yields to the reactor** instead of stalling its OS thread; other fibers keep running and the response is routed back to the right fiber. This requires:
119
+
120
+ - Ruby **>= 3.3** (where the `ConditionVariable` ↔ `Fiber.scheduler` cooperation is verified), and
121
+ - a spec-compliant `Fiber.scheduler` active on the caller's thread, with correct cross-thread `unblock` (Async and Polyphony qualify).
122
+
123
+ No scheduler library is a runtime dependency — callers bring their own. Plain (non-scheduler) fibers are unsupported: they would block the OS thread on every wait. Use `SimpleClient` for non-scheduler code.
124
+
125
+ ## Errors
126
+
127
+ All errors live under `JRPC::Errors::*` and descend from `JRPC::Errors::Error`. The four public-facing classes are siblings (no inheritance between them), so rescue each by name or rescue `Errors::Error` to catch all:
128
+
129
+ ```
130
+ Errors::Error (RuntimeError)
131
+ ├── Errors::ClientError # caller-side: bad args, bad URI, client closed, queue full
132
+ ├── Errors::ConnectionError # cannot connect, or connection died (see Exception#cause)
133
+ ├── Errors::Timeout # message TTL elapsed, or SimpleClient read/write/connect timeout
134
+ └── Errors::ServerError # peer returned an error, or the response was unusable
135
+ attr_reader :code # nil for malformed responses
136
+ ├── Errors::ParseError # -32700
137
+ ├── Errors::InvalidRequest # -32600
138
+ ├── Errors::MethodNotFound # -32601
139
+ ├── Errors::InvalidParams # -32602
140
+ ├── Errors::InternalError # -32603
141
+ ├── Errors::InternalServerError # -32099..-32000
142
+ ├── Errors::UnknownError # any other code
143
+ └── Errors::MalformedResponseError # bad framing/JSON, id mismatch, wrong jsonrpc version
144
+ ```
145
+
146
+ `MalformedResponseError` is a `ServerError`, not a `ClientError`: a malformed response is the peer's fault.
147
+
148
+ ```ruby
149
+ begin
150
+ client.request(:do_thing, [1, 2])
151
+ rescue JRPC::Errors::ServerError => e
152
+ warn "rpc error #{e.code}: #{e.message}"
153
+ rescue JRPC::Errors::Timeout
154
+ warn "timed out"
155
+ rescue JRPC::Errors::ConnectionError => e
156
+ warn "connection: #{e.message} (cause: #{e.cause})"
157
+ end
158
+ ```
159
+
160
+ ## JSON serialization
161
+
162
+ JRPC uses the stdlib `json`. To swap in [oj](https://github.com/ohler55/oj) for speed, monkey-patch it yourself before use:
163
+
164
+ ```ruby
165
+ require 'oj'
166
+ Oj.mimic_JSON
167
+ ```
168
+
169
+ ## TCP MD5 Signature (RFC2385)
170
+
171
+ Both clients accept `tcp_md5_pass:` to enable per-connection authentication via the
172
+ [TCP MD5 Signature option](https://www.rfc-editor.org/rfc/rfc2385). The kernel signs and
173
+ verifies every TCP segment with `MD5(key + segment + addresses/ports)`; a peer with a
174
+ mismatched or absent key has its segments silently dropped, so the handshake never
175
+ completes.
176
+
177
+ ```ruby
178
+ client = JRPC::SimpleClient.new("10.0.0.2:1234", tcp_md5_pass: "shared-secret")
179
+ ```
180
+
181
+ - **Linux-only.** It relies on the `TCP_MD5SIG` socket option (and a kernel built with
182
+ `CONFIG_TCP_MD5SIG`). When `tcp_md5_pass` is set on a platform/kernel without it, the
183
+ first connect raises `ConnectionError` — the option never silently no-ops.
184
+ - **The server must be configured with the same key for this client's address.** JRPC
185
+ only sets the client side; the peer (e.g. a router/BGP-style endpoint, or another
186
+ socket with a matching `TCP_MD5SIG`) must agree on the key.
187
+ - **Key length is capped at 80 bytes** (`TCP_MD5SIG_MAXKEYLEN`); a longer key raises
188
+ `ConnectionError`.
189
+ - The key is installed on the socket **before** connect, so it also protects the
190
+ handshake itself. It survives reconnects (reaping, connection drops) transparently.
191
+
192
+ ## Testing
193
+
194
+ `JRPC::Transport::Test` is an in-process transport double for testing code that
195
+ talks to a JSON-RPC server, without standing up a real one. It is **not** loaded
196
+ by default — require it explicitly from your test setup:
197
+
198
+ ```ruby
199
+ require 'jrpc/transport/test'
200
+
201
+ transport = JRPC::Transport::Test.new
202
+ transport.on('sum') { |params| params['a'] + params['b'] }
203
+
204
+ client = JRPC::SimpleClient.new('test', transport: transport)
205
+ client.request('sum', { 'a' => 1, 'b' => 2 }) # => 3
206
+
207
+ transport.last_request # => { "jsonrpc" => "2.0", "method" => "sum", "params" => {...}, "id" => "..." }
208
+ ```
209
+
210
+ Inject it through the `transport:` option of either `SimpleClient` or `SharedClient`.
211
+
212
+ **Handlers** are the high-level API. A handler's return value is encoded as a result
213
+ response echoing the request id. Raise to produce other outcomes:
214
+
215
+ ```ruby
216
+ # JSON-RPC error response (mapped back to the matching JRPC::Errors class on the caller):
217
+ transport.on('lookup') { raise JRPC::Errors::MethodNotFound, 'no such method' }
218
+
219
+ # Simulated socket-level failure, raised when the client reads the response:
220
+ transport.on('flaky') { raise JRPC::Transport::Base::ConnectionError, 'peer reset' }
221
+ ```
222
+
223
+ In **strict mode (the default)** a request for a method with no handler raises
224
+ `JRPC::Transport::Test::UnexpectedRequest` at write time, so a missing stub fails
225
+ loudly instead of hanging. Pass `strict: false` to drive reads entirely with the
226
+ raw escape hatch:
227
+
228
+ ```ruby
229
+ transport = JRPC::Transport::Test.new(strict: false)
230
+ # Feed literal response frames — for malformed responses, id mismatches, orphans:
231
+ transport.push_response({ 'jsonrpc' => '2.0', 'id' => 'abc', 'result' => 42 })
232
+ transport.push_raise(JRPC::Transport::Base::MalformedFrame.new('garbage'))
233
+ ```
234
+
235
+ Other helpers: `fail_connect(error)` arms `connect` to raise; `requests`,
236
+ `notifications`, and `sent` expose recordings for assertions; `reset` clears
237
+ recordings and queued frames (keeping handlers). The transport opens a Unix
238
+ socketpair so `SharedClient`'s `IO.select` loop works — call `shutdown` (e.g. in an
239
+ `after` hook) for deterministic FD cleanup, or let the GC finalizer reclaim it.
240
+
241
+ ## CLI tools
242
+
243
+ Two executables ship with the gem:
244
+
245
+ - `jrpc` — one-shot request/notification from the shell (`jrpc --help`).
246
+ - `jrpc-shell` — an interactive REPL (`connect`, `request`, `notification`, `disconnect`).
247
+
248
+ Both use `SimpleClient`.
249
+
250
+ ## Upgrading from 1.x
251
+
252
+ 2.0 is a full rewrite with many breaking changes (`JRPC::TcpClient`/`BaseClient` removed, error constants moved under `JRPC::Errors::*`, `method_missing`/`namespace:` dropped, no eager connect, and more). See the [CHANGELOG](CHANGELOG.md) for the complete list.
253
+
254
+ ## Contributing
255
+
256
+ Bug reports and pull requests are welcome on GitHub at https://github.com/didww/jrpc.
257
+
258
+ ## License
33
259
 
260
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile CHANGED
@@ -1,6 +1,8 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'bundler/gem_tasks'
2
4
  require 'rspec/core/rake_task'
3
5
 
4
6
  RSpec::Core::RakeTask.new(:spec)
5
7
 
6
- task :default => :spec
8
+ task default: :spec
data/bin/console CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
2
3
 
3
4
  require 'bundler/setup'
4
5
  require 'jrpc'