gienah 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d0b5ddd667daab9746e62685ac6aa5c2815423723dfb32edad31f0ccd884d7f1
4
+ data.tar.gz: bed2f863f325ef4ccd850e4c8e1a1b012a2bb865ff287abd61f34d4a0dd02e71
5
+ SHA512:
6
+ metadata.gz: a07f343f8c19bceae8d046574b458ff1df2a86831cee69e671ee4c784af5f4ce3e10bc7887028ccdc9903432a05f878be1a89712a87c99d3768bc3cc19a08385
7
+ data.tar.gz: a28009e92874f177dff8cfb14dab1138efb8d983cb1d4a72307769802f90c861bf41ca8fdca87ee235364a163a2e3f7bb8449cb05b7adfcbba7ecfbc9daecb10
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 - 2026-09-19
4
+
5
+ - Initial release.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Yudai Takada
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,60 @@
1
+ # Gienah
2
+
3
+ Gienah (γ Corvi) is an IAU-approved star name derived from Arabic *al-janāḥ*,
4
+ “the wing”. It is a small, Canopus-independent host for out-of-process Ruby
5
+ plugins.
6
+
7
+ ## Installation
8
+
9
+ Install the gem and add to the application's Gemfile by executing:
10
+
11
+ ```bash
12
+ bundle add gienah
13
+ ```
14
+
15
+ If bundler is not being used to manage dependencies, install the gem by executing:
16
+
17
+ ```bash
18
+ gem install gienah
19
+ ```
20
+
21
+ ## Usage
22
+
23
+ A host registers application-specific methods and activates a manifest only
24
+ when its activation reason occurs:
25
+
26
+ ```ruby
27
+ require "gienah"
28
+
29
+ host = Gienah::Host.new(api_version: 1)
30
+ host.expose("workspace/notify") { |params| puts params.fetch("message") }
31
+ host.discover(["plugins"])
32
+ instance = host.activate("example", reason: "onStartup")
33
+ instance&.call("refresh", {}).await(timeout: 2)
34
+ ```
35
+
36
+ Plugin entrypoints use the SDK:
37
+
38
+ ```ruby
39
+ require "gienah"
40
+ Gienah::Plugin.export("refresh") { {"ok" => true} }
41
+ Gienah::Plugin.run
42
+ ```
43
+
44
+ `gienah` treats capabilities as opaque strings, but rejects unknown manifest
45
+ capabilities and denies host methods until the declared capability is granted.
46
+ Dangerous filesystem, network, and process capabilities require `saiph`.
47
+
48
+ ## Development
49
+
50
+ After checking out the repo, run `bundle install` to install dependencies. Then, run `bundle exec rake spec` to run the tests.
51
+
52
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
53
+
54
+ ## Contributing
55
+
56
+ Bug reports and pull requests are welcome on GitHub at https://github.com/noxdea/gienah.
57
+
58
+ ## License
59
+
60
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -0,0 +1,17 @@
1
+ # ADR-000: Template
2
+
3
+ ## Status
4
+
5
+ Accepted
6
+
7
+ ## Context
8
+
9
+ Use this template for a decision that affects the public host or plugin protocol.
10
+
11
+ ## Decision
12
+
13
+ Describe the decision.
14
+
15
+ ## Consequences
16
+
17
+ Describe the trade-offs.
@@ -0,0 +1,3 @@
1
+ # Architecture decisions
2
+
3
+ Gienah records protocol and lifecycle decisions here.
data/index.html ADDED
@@ -0,0 +1,62 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="theme-color" content="#060a13">
7
+ <meta name="description" content="Gienah is an out-of-process Ruby plugin host with capability gates.">
8
+ <title>Gienah — An out-of-process plugin host for Ruby</title>
9
+ <link rel="stylesheet" href="site.css">
10
+ </head>
11
+ <body style="--accent:#e5adf2">
12
+ <a class="skip-link" href="#main">Skip to content</a>
13
+ <header class="site-header">
14
+ <a class="brand" href="#"><span class="star-mark"></span>Gienah</a>
15
+ <nav aria-label="Primary navigation"><a href="#features">Capabilities</a><a href="#install">Install</a><a href="https://github.com/noxdea/gienah">GitHub</a></nav>
16
+ </header>
17
+ <main id="main">
18
+ <section class="hero">
19
+ <div>
20
+ <p class="constellation">γ Corvi · plugin host</p>
21
+ <h1>Keep plugins at arm’s length.</h1>
22
+ <p class="lede">A small out-of-process Ruby plugin host with framed RPC, activation lifecycle, capability gates, and a focused plugin SDK.</p>
23
+ <div class="actions"><a class="button primary" href="#install">Install Gienah</a><a class="button" href="https://github.com/noxdea/gienah">View source</a></div>
24
+ <ul class="facts"><li><strong>Out of process</strong></li><li>Ruby ≥ 3.1</li><li>MIT licensed</li></ul>
25
+ </div>
26
+ <div class="instrument" aria-label="Plugin host activation example">
27
+ <div class="instrument-head"><span>host / example plugin</span><span class="signal">active</span></div>
28
+ <div class="instrument-body">
29
+ <div class="diagram">
30
+ <span class="orbit" aria-hidden="true"></span>
31
+ <div class="node nw"><strong>Manifest</strong>api 1<br>onStartup</div>
32
+ <div class="node ne"><strong>RPC</strong>Content-Length<br>framed</div>
33
+ <div class="node core"><strong>Gienah host</strong>plugin example<br>pid 9134</div>
34
+ <div class="node sw"><strong>Capability</strong>workspace.notify<br>granted</div>
35
+ <div class="node se"><strong>Sandbox</strong>saiph<br>required</div>
36
+ </div>
37
+ </div>
38
+ </div>
39
+ </section>
40
+ <section class="features" id="features">
41
+ <div class="section-heading"><h2>A smaller blast radius.</h2><p>Gienah keeps extension code outside the host process and checks declared capabilities before a plugin can call application methods.</p></div>
42
+ <div class="feature-grid">
43
+ <article><h3>Process isolation</h3><p>Run plugin entrypoints as child processes connected through bounded framed messages.</p></article>
44
+ <article><h3>Capability gates</h3><p>Reject unknown declarations and deny host methods until the matching capability is granted.</p></article>
45
+ <article><h3>Lifecycle control</h3><p>Discover manifests, activate on a named reason, make timeout-aware calls, and shut down cleanly.</p></article>
46
+ </div>
47
+ </section>
48
+ <section class="install" id="install">
49
+ <div class="install-copy"><h2>Host a plugin.</h2><p>Expose only application-specific methods, then activate manifests when their reason occurs.</p></div>
50
+ <div class="terminal"><div class="terminal-bar">Ruby</div><pre><span class="prompt">$</span> bundle add gienah
51
+
52
+ host = Gienah::Host.new(api_version: 1)
53
+ host.expose("workspace/notify") { |params|
54
+ puts params.fetch("message")
55
+ }
56
+ host.discover(["plugins"])
57
+ host.activate("example", reason: "onStartup")</pre></div>
58
+ </section>
59
+ </main>
60
+ <footer><p>Gienah · Pure Ruby · MIT</p><div><a href="https://rubygems.org/gems/gienah">RubyGems</a><a href="https://github.com/noxdea/gienah">GitHub</a></div></footer>
61
+ </body>
62
+ </html>
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gienah
4
+ class Future
5
+ def initialize(id, on_cancel: nil)
6
+ @id = id
7
+ @on_cancel = on_cancel
8
+ @lock = Mutex.new
9
+ @ready = ConditionVariable.new
10
+ @callbacks = []
11
+ end
12
+
13
+ attr_reader :id
14
+
15
+ def fulfill(value = nil, error: nil)
16
+ callbacks = @lock.synchronize do
17
+ return self if @done
18
+ @value = value
19
+ @error = error
20
+ @done = true
21
+ @ready.broadcast
22
+ callbacks, @callbacks = @callbacks, []
23
+ callbacks
24
+ end
25
+ callbacks.each { |callback| callback.call(@value, @error) }
26
+ self
27
+ end
28
+
29
+ def await(timeout: nil)
30
+ validate_timeout(timeout)
31
+ deadline = timeout && Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
32
+ timed_out = false
33
+ result = @lock.synchronize do
34
+ until @done
35
+ remaining = deadline && deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
36
+ if remaining && remaining <= 0
37
+ timed_out = true
38
+ break
39
+ end
40
+ @ready.wait(@lock, remaining)
41
+ end
42
+ break if timed_out
43
+ raise @error if @error
44
+ @value
45
+ end
46
+ if timed_out
47
+ cancel
48
+ raise Gienah::Timeout, "request #{@id} timed out"
49
+ end
50
+ result
51
+ end
52
+
53
+ def then(&callback)
54
+ raise ArgumentError, "callback required" unless callback
55
+ ready = @lock.synchronize do
56
+ @callbacks << callback unless @done
57
+ @done
58
+ end
59
+ callback.call(@value, @error) if ready
60
+ self
61
+ end
62
+
63
+ def cancel
64
+ callback = @lock.synchronize do
65
+ return false if @done || @cancelling
66
+ @cancelling = true
67
+ @on_cancel
68
+ end
69
+ begin
70
+ callback&.call(@id)
71
+ rescue StandardError
72
+ nil
73
+ end
74
+ fulfill(error: Error.new("request #{@id} cancelled"))
75
+ true
76
+ end
77
+
78
+ def done?
79
+ @lock.synchronize { !!@done }
80
+ end
81
+
82
+ private
83
+
84
+ def validate_timeout(timeout)
85
+ return if timeout.nil? || (timeout.is_a?(Numeric) && timeout.finite? && timeout >= 0)
86
+ raise ArgumentError, "timeout must be finite and nonnegative"
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,395 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gienah
4
+ class Host
5
+ Definition = Value.define(:method, :capability, :handler)
6
+ DEFAULT_RESTART = {attempts: 3, window: 60, backoff: [1, 5, 30]}.freeze
7
+
8
+ attr_reader :api_version
9
+
10
+ def initialize(api_version:, dispatch: nil, sandbox: true, restart: {}, limits: {}, transport_factory: nil)
11
+ raise ArgumentError, "api_version must be positive" unless api_version.is_a?(Integer) && api_version.positive?
12
+
13
+ @api_version = api_version
14
+ @dispatch = dispatch
15
+ @sandbox = sandbox
16
+ @restart = restart || {}
17
+ @limits = limits || {}
18
+ @transport_factory = transport_factory
19
+ @manifests = {}
20
+ @instances = {}
21
+ @definitions = {}
22
+ @contribution_handlers = []
23
+ @error_handlers = []
24
+ @lock = Mutex.new
25
+ @restart_history = Hash.new { |hash, key| hash[key] = [] }
26
+ @disabled = {}
27
+ @shutting_down = false
28
+ end
29
+
30
+ def expose(method, capability: nil, &handler)
31
+ raise ArgumentError, "method must be a nonempty String" unless method.is_a?(String) && !method.empty?
32
+ raise ArgumentError, "handler required" unless handler
33
+ raise ArgumentError, "capability must be a String" unless capability.nil? || capability.is_a?(String)
34
+
35
+ @definitions[method] = Definition.new(method, capability, handler)
36
+ self
37
+ end
38
+
39
+ def discover(directories)
40
+ manifests = Array(directories).flat_map do |directory|
41
+ path = File.expand_path(directory.to_s)
42
+ candidates = if File.file?(path)
43
+ [path]
44
+ else
45
+ Dir[File.join(path, "**", "plugin.{json,jsonc}")]
46
+ end
47
+ candidates.sort.map { |manifest| Manifest.load(manifest) }
48
+ end
49
+ manifests.each { |manifest| add(manifest) }
50
+ manifests
51
+ end
52
+
53
+ def add(manifest)
54
+ raise ArgumentError, "expected Gienah::Manifest" unless manifest.is_a?(Manifest)
55
+ raise LifecycleError, "unsupported plugin api version #{manifest.api_version}" unless manifest.api_version == @api_version
56
+
57
+ @lock.synchronize do
58
+ @manifests[manifest.id] = manifest
59
+ @disabled.delete(manifest.id)
60
+ end
61
+ @contribution_handlers.each { |handler| handler.call(manifest.id, manifest.contributes) }
62
+ manifest
63
+ end
64
+
65
+ def activate(id, reason:)
66
+ manifest = @lock.synchronize { @manifests.fetch(id.to_s) { raise ArgumentError, "unknown plugin: #{id}" } }
67
+ raise LifecycleError, "plugin #{id} is disabled" if @lock.synchronize { @disabled.key?(id.to_s) }
68
+ return @instances[id.to_s] if @instances.key?(id.to_s)
69
+ return nil unless activation_matches?(manifest.activation, reason.to_s)
70
+
71
+ instance = Instance.new(self, manifest)
72
+ @lock.synchronize { @instances[manifest.id] = instance }
73
+ begin
74
+ instance.start
75
+ rescue StandardError
76
+ @lock.synchronize { @instances.delete(manifest.id) }
77
+ raise
78
+ end
79
+ instance
80
+ end
81
+
82
+ def deactivate(id)
83
+ instance = @lock.synchronize { @instances.delete(id.to_s) }
84
+ instance&.shutdown
85
+ end
86
+
87
+ def instances
88
+ @lock.synchronize { @instances.values.dup }
89
+ end
90
+
91
+ def disabled?(id)
92
+ @lock.synchronize { @disabled.key?(id.to_s) }
93
+ end
94
+
95
+ def on_contribution(&block)
96
+ raise ArgumentError, "callback required" unless block
97
+
98
+ @contribution_handlers << block
99
+ self
100
+ end
101
+
102
+ def on_error(&block)
103
+ raise ArgumentError, "callback required" unless block
104
+
105
+ @error_handlers << block
106
+ self
107
+ end
108
+
109
+ def shutdown
110
+ @shutting_down = true
111
+ instances.each(&:shutdown)
112
+ @lock.synchronize { @instances.clear }
113
+ nil
114
+ end
115
+
116
+ def dispatch_request(instance, message)
117
+ method = message["method"]
118
+ definition = @definitions[method]
119
+ unless definition
120
+ return Protocol.response(message["id"], error: {"code" => -32601, "message" => "method not found: #{method}"})
121
+ end
122
+ if definition.capability && !instance.granted?(definition.capability)
123
+ return Protocol.response(message["id"], error: {"code" => -32001, "message" => "capability denied: #{definition.capability}"})
124
+ end
125
+
126
+ params = message.fetch("params", {})
127
+ result = if definition.handler.arity == 1
128
+ definition.handler.call(params)
129
+ else
130
+ definition.handler.call(instance, params)
131
+ end
132
+ Protocol.response(message["id"], result: result)
133
+ rescue CapabilityDenied => error
134
+ Protocol.response(message["id"], error: {"code" => -32001, "message" => error.message})
135
+ rescue StandardError => error
136
+ report_error(error, instance)
137
+ Protocol.response(message["id"], error: {"code" => -32000, "message" => "#{error.class}: #{error.message}"})
138
+ end
139
+
140
+ def dispatch_notification(instance, message)
141
+ @dispatch&.call(instance, message["method"], message.fetch("params", {}))
142
+ rescue StandardError => error
143
+ report_error(error, instance)
144
+ end
145
+
146
+ def report_error(error, instance = nil)
147
+ @error_handlers.each { |handler| handler.call(error, instance) }
148
+ rescue StandardError
149
+ nil
150
+ end
151
+
152
+ def instance_failed(instance, error)
153
+ report_error(error, instance)
154
+ config = DEFAULT_RESTART.merge(@restart.transform_keys(&:to_sym))
155
+ attempts = config.fetch(:attempts).to_i
156
+ return if attempts <= 0 || @shutting_down
157
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
158
+ history = @lock.synchronize do
159
+ next unless @instances[instance.id].equal?(instance)
160
+
161
+ @restart_history[instance.id].reject! { |started| now - started >= config.fetch(:window).to_f }
162
+ if @restart_history[instance.id].length < attempts
163
+ @restart_history[instance.id] << now
164
+ else
165
+ nil
166
+ end
167
+ end
168
+ unless history
169
+ @lock.synchronize { @disabled[instance.id] = true }
170
+ report_error(LifecycleError.new("plugin #{instance.id} disabled after repeated failures"), instance)
171
+ return
172
+ end
173
+
174
+ index = history.length - 1
175
+ backoff = Array(config.fetch(:backoff))
176
+ delay = backoff[index] || backoff.last || 0
177
+ Thread.new do
178
+ sleep(delay.to_f) if delay.to_f.positive?
179
+ next unless @lock.synchronize { !@shutting_down && @instances[instance.id].equal?(instance) && instance.state == :failed }
180
+
181
+ instance.restart
182
+ rescue StandardError => restart_error
183
+ report_error(restart_error, instance)
184
+ end
185
+ end
186
+
187
+ def transport_for(instance, &receive)
188
+ if @transport_factory
189
+ @transport_factory.call(instance.manifest, &receive)
190
+ else
191
+ Sandbox.ensure_safe!(instance.manifest.capabilities) if @sandbox
192
+ entry = File.expand_path(instance.manifest.entry, instance.manifest.root)
193
+ raise Error, "plugin entry does not exist: #{entry}" unless File.file?(entry)
194
+ command = [RbConfig.ruby, "-I", File.expand_path("..", __dir__), entry]
195
+ policy = @sandbox && Sandbox.available? ? Sandbox.policy_for(instance.manifest.capabilities, root: instance.manifest.root) : nil
196
+ Transport.open(command, cwd: instance.manifest.root, env: {}, policy: policy, &receive)
197
+ end
198
+ end
199
+
200
+ def request_timeout(manifest)
201
+ value = manifest.limits.fetch("request_timeout_ms", @limits.fetch(:request_timeout_ms, 2_000))
202
+ value.to_f / 1_000
203
+ end
204
+
205
+ private
206
+
207
+ def activation_matches?(patterns, reason)
208
+ patterns.empty? || patterns.any? do |pattern|
209
+ pattern == reason || (pattern.end_with?("*") && reason.start_with?(pattern.delete_suffix("*")))
210
+ end
211
+ end
212
+ end
213
+
214
+ class Instance
215
+ STATES = %i[inactive starting ready stopping failed].freeze
216
+
217
+ attr_reader :id, :manifest, :state
218
+
219
+ def initialize(host, manifest)
220
+ @host = host
221
+ @manifest = manifest
222
+ @id = manifest.id
223
+ @state = :inactive
224
+ @lock = Mutex.new
225
+ @pending = {}
226
+ @next_id = 0
227
+ end
228
+
229
+ def start
230
+ transition(:starting, from: :inactive)
231
+ @transport = @host.transport_for(self) { |message, error| receive(message, error) }
232
+ startup_timeout = @manifest.limits.fetch("startup_timeout_ms", 5_000).to_f / 1_000
233
+ response = request("initialize", {
234
+ "api_version" => @host.api_version,
235
+ "capabilities" => @manifest.capabilities,
236
+ "host" => {"name" => "gienah", "version" => Gienah::VERSION}
237
+ }, timeout: startup_timeout).await(timeout: startup_timeout)
238
+ raise LifecycleError, "plugin initialization failed" unless response.is_a?(Hash)
239
+
240
+ transition(:ready, from: :starting)
241
+ notify("initialized", {"api_version" => @host.api_version})
242
+ self
243
+ rescue StandardError => error
244
+ @lock.synchronize { @state = :failed }
245
+ @host.report_error(error, self)
246
+ close_transport
247
+ raise
248
+ end
249
+
250
+ def call(method, params = {}, timeout: nil)
251
+ ensure_ready!
252
+ request(method, params, timeout: timeout)
253
+ end
254
+
255
+ def notify(method, params = {})
256
+ ensure_ready!
257
+ @transport.write(Protocol.notification(method, params), max_size: max_message_size)
258
+ nil
259
+ end
260
+
261
+ def granted?(capability)
262
+ requested = capability.to_s
263
+ return true if @manifest.capabilities.include?(requested)
264
+
265
+ # Reserved host-pattern capabilities are intentionally matched here so
266
+ # applications can expose one generic method and still keep the gate
267
+ # fail-closed for every other host.
268
+ if requested.start_with?("net:")
269
+ host = requested.delete_prefix("net:")
270
+ return @manifest.capabilities.any? do |entry|
271
+ next false unless entry.start_with?("net:")
272
+
273
+ pattern = entry.delete_prefix("net:")
274
+ File.fnmatch?(pattern, host, File::FNM_DOTMATCH)
275
+ end
276
+ end
277
+ false
278
+ end
279
+
280
+ def kill
281
+ @lock.synchronize { @state = :inactive }
282
+ fail_pending(Error.new("plugin terminated"))
283
+ close_transport
284
+ nil
285
+ end
286
+
287
+ def restart
288
+ @lock.synchronize { @state = :inactive if @state == :failed }
289
+ start
290
+ end
291
+
292
+ def shutdown
293
+ current = @lock.synchronize { @state }
294
+ return if current == :inactive
295
+
296
+ if current == :ready
297
+ @lock.synchronize { @state = :stopping }
298
+ begin
299
+ request("shutdown", {}).await(timeout: 0.5)
300
+ rescue StandardError
301
+ nil
302
+ end
303
+ end
304
+ kill
305
+ end
306
+
307
+ private
308
+
309
+ def request(method, params, timeout: nil)
310
+ id = @lock.synchronize do
311
+ @next_id += 1
312
+ @next_id
313
+ end
314
+ future = Future.new(id, on_cancel: ->(request_id) {
315
+ @transport&.write(Protocol.notification("$/cancel", {"id" => request_id}), max_size: max_message_size)
316
+ })
317
+ @lock.synchronize { @pending[id] = future }
318
+ deadline = timeout || @host.request_timeout(@manifest)
319
+ unless deadline.is_a?(Numeric) && deadline.finite? && deadline.positive?
320
+ @lock.synchronize { @pending.delete(id) }
321
+ raise ArgumentError, "timeout must be finite and positive"
322
+ end
323
+ timer = Thread.new do
324
+ sleep(deadline)
325
+ next if future.done?
326
+
327
+ begin
328
+ @transport&.write(Protocol.notification("$/cancel", {"id" => id}), max_size: max_message_size)
329
+ rescue StandardError
330
+ nil
331
+ end
332
+ future.fulfill(error: Timeout.new("request #{id} timed out"))
333
+ end
334
+ timer.report_on_exception = false
335
+ future.then { |_value, _error| timer.kill if timer.alive? && timer != Thread.current }
336
+ begin
337
+ @transport.write(Protocol.request(id, method, params), max_size: max_message_size)
338
+ rescue StandardError => error
339
+ @lock.synchronize { @pending.delete(id) }
340
+ future.fulfill(error: error)
341
+ end
342
+ future
343
+ end
344
+
345
+ def receive(message, error)
346
+ if error
347
+ @lock.synchronize { @state = :failed unless %i[inactive stopping].include?(@state) }
348
+ fail_pending(error)
349
+ @host.instance_failed(self, error)
350
+ return
351
+ end
352
+ return unless message
353
+ if message.key?("method")
354
+ response = if message.key?("id")
355
+ @host.dispatch_request(self, message)
356
+ else
357
+ @host.dispatch_notification(self, message)
358
+ nil
359
+ end
360
+ @transport.write(response, max_size: max_message_size) if response
361
+ else
362
+ future = @lock.synchronize { @pending.delete(message["id"]) }
363
+ return unless future
364
+ message.key?("error") ? future.fulfill(error: Error.new(message["error"]["message"].to_s)) : future.fulfill(message["result"])
365
+ end
366
+ rescue StandardError => dispatch_error
367
+ @host.report_error(dispatch_error, self)
368
+ end
369
+
370
+ def ensure_ready!
371
+ raise LifecycleError, "plugin #{@id} is #{@state}" unless @lock.synchronize { @state == :ready }
372
+ end
373
+
374
+ def transition(target, from:)
375
+ @lock.synchronize do
376
+ raise LifecycleError, "cannot transition #{@state} to #{target}" unless @state == from
377
+ @state = target
378
+ end
379
+ end
380
+
381
+ def fail_pending(error)
382
+ futures = @lock.synchronize { pending, @pending = @pending.values, {}; pending }
383
+ futures.each { |future| future.fulfill(error: error) }
384
+ end
385
+
386
+ def close_transport
387
+ @transport&.close
388
+ @transport = nil
389
+ end
390
+
391
+ def max_message_size
392
+ @manifest.limits.fetch("max_message_bytes", Protocol::MAX_MESSAGE)
393
+ end
394
+ end
395
+ end