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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +60 -0
- data/Rakefile +8 -0
- data/docs/adr/000-template.md +17 -0
- data/docs/adr/README.md +3 -0
- data/index.html +62 -0
- data/lib/gienah/future.rb +89 -0
- data/lib/gienah/host.rb +395 -0
- data/lib/gienah/manifest.rb +134 -0
- data/lib/gienah/plugin.rb +140 -0
- data/lib/gienah/protocol.rb +104 -0
- data/lib/gienah/sandbox.rb +56 -0
- data/lib/gienah/testing/fake_plugin.rb +80 -0
- data/lib/gienah/transport.rb +149 -0
- data/lib/gienah/version.rb +5 -0
- data/lib/gienah.rb +47 -0
- data/sig/gienah.rbs +117 -0
- data/site.css +247 -0
- metadata +78 -0
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
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,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.
|
data/docs/adr/README.md
ADDED
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
|
data/lib/gienah/host.rb
ADDED
|
@@ -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
|