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
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gienah
|
|
4
|
+
Manifest = Value.define(:id, :name, :version, :api_version, :entry, :activation,
|
|
5
|
+
:capabilities, :contributes, :limits, :root)
|
|
6
|
+
|
|
7
|
+
class Manifest
|
|
8
|
+
REQUIRED = %w[id name version api_version entry].freeze
|
|
9
|
+
EXACT_CAPABILITIES = %w[
|
|
10
|
+
buffer.read buffer.edit workspace.read ui.panel ui.command ui.statusbar
|
|
11
|
+
ui.decoration completion.provide language.define process.exec exec
|
|
12
|
+
].freeze
|
|
13
|
+
LIMITS = %w[memory_mb request_timeout_ms max_message_bytes max_concurrent_requests startup_timeout_ms].freeze
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
def load(path)
|
|
17
|
+
path = File.expand_path(path.to_s)
|
|
18
|
+
raise ArgumentError, "manifest does not exist" unless File.file?(path)
|
|
19
|
+
|
|
20
|
+
from_hash(JSON.parse(strip_jsonc(File.binread(path))), root: File.dirname(path))
|
|
21
|
+
rescue JSON::ParserError => error
|
|
22
|
+
raise ProtocolError, "invalid manifest JSON: #{error.message}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def from_hash(value, root: Dir.pwd)
|
|
26
|
+
raise ProtocolError, "manifest must be an object" unless value.is_a?(Hash)
|
|
27
|
+
value = value.transform_keys(&:to_s)
|
|
28
|
+
REQUIRED.each { |key| raise ProtocolError, "manifest missing #{key}" unless value.key?(key) }
|
|
29
|
+
id = text(value["id"], "id")
|
|
30
|
+
name = text(value["name"], "name")
|
|
31
|
+
version = text(value["version"], "version")
|
|
32
|
+
api_version = positive_integer(value["api_version"], "api_version")
|
|
33
|
+
entry = text(value["entry"], "entry")
|
|
34
|
+
raise ProtocolError, "entry must be relative" if Pathname.new(entry).absolute? || entry.split(File::SEPARATOR).include?("..")
|
|
35
|
+
|
|
36
|
+
activation = array(value.fetch("activation", []), "activation").map { |item| text(item, "activation") }
|
|
37
|
+
capabilities = array(value.fetch("capabilities", []), "capabilities").map do |item|
|
|
38
|
+
capability = text(item, "capability")
|
|
39
|
+
validate_capability(capability)
|
|
40
|
+
capability
|
|
41
|
+
end.uniq.freeze
|
|
42
|
+
contributes = value.fetch("contributes", {})
|
|
43
|
+
raise ProtocolError, "contributes must be an object" unless contributes.is_a?(Hash)
|
|
44
|
+
limits = validate_limits(value.fetch("limits", {}))
|
|
45
|
+
|
|
46
|
+
new(id, name, version, api_version, entry, activation.freeze, capabilities,
|
|
47
|
+
deep_freeze(contributes), limits, File.expand_path(root.to_s)).freeze
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def text(value, field)
|
|
53
|
+
raise ProtocolError, "#{field} must be a nonempty String" unless value.is_a?(String) && !value.empty? && !value.include?("\0")
|
|
54
|
+
|
|
55
|
+
value
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def array(value, field)
|
|
59
|
+
raise ProtocolError, "#{field} must be an Array" unless value.is_a?(Array)
|
|
60
|
+
|
|
61
|
+
value
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def positive_integer(value, field)
|
|
65
|
+
raise ProtocolError, "#{field} must be a positive Integer" unless value.is_a?(Integer) && value.positive?
|
|
66
|
+
|
|
67
|
+
value
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def validate_capability(value)
|
|
71
|
+
return if EXACT_CAPABILITIES.include?(value)
|
|
72
|
+
return if value.match?(/\A(?:fs\.read|fs\.write|net):.+\z/)
|
|
73
|
+
|
|
74
|
+
raise ProtocolError, "unknown capability: #{value}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def validate_limits(value)
|
|
78
|
+
raise ProtocolError, "limits must be an object" unless value.is_a?(Hash)
|
|
79
|
+
|
|
80
|
+
normalized = value.transform_keys(&:to_s)
|
|
81
|
+
normalized.each do |key, number|
|
|
82
|
+
raise ProtocolError, "unknown limit: #{key}" unless LIMITS.include?(key)
|
|
83
|
+
raise ProtocolError, "#{key} must be positive" unless number.is_a?(Numeric) && number.finite? && number.positive?
|
|
84
|
+
end
|
|
85
|
+
deep_freeze(normalized)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def deep_freeze(value)
|
|
89
|
+
case value
|
|
90
|
+
when Hash
|
|
91
|
+
value.each { |key, item| deep_freeze(key); deep_freeze(item) }
|
|
92
|
+
when Array
|
|
93
|
+
value.each { |item| deep_freeze(item) }
|
|
94
|
+
end
|
|
95
|
+
value.freeze
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def strip_jsonc(source)
|
|
99
|
+
output = +""
|
|
100
|
+
quote = false
|
|
101
|
+
escaped = false
|
|
102
|
+
index = 0
|
|
103
|
+
while index < source.length
|
|
104
|
+
char = source[index]
|
|
105
|
+
if quote
|
|
106
|
+
output << char
|
|
107
|
+
if escaped
|
|
108
|
+
escaped = false
|
|
109
|
+
elsif char == "\\"
|
|
110
|
+
escaped = true
|
|
111
|
+
elsif char == '"'
|
|
112
|
+
quote = false
|
|
113
|
+
end
|
|
114
|
+
elsif char == '"'
|
|
115
|
+
quote = true
|
|
116
|
+
output << char
|
|
117
|
+
elsif char == "/" && source[index + 1] == "/"
|
|
118
|
+
index += 2
|
|
119
|
+
index += 1 while index < source.length && source[index] != "\n"
|
|
120
|
+
output << "\n"
|
|
121
|
+
elsif char == "/" && source[index + 1] == "*"
|
|
122
|
+
index += 2
|
|
123
|
+
index += 1 while index + 1 < source.length && source[index, 2] != "*/"
|
|
124
|
+
index += 1
|
|
125
|
+
else
|
|
126
|
+
output << char
|
|
127
|
+
end
|
|
128
|
+
index += 1
|
|
129
|
+
end
|
|
130
|
+
output.gsub(/,\s*([}\]])/, '\1')
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gienah
|
|
4
|
+
module Plugin
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def export(name, &handler)
|
|
8
|
+
raise ArgumentError, "name must be a nonempty String" unless name.is_a?(String) && !name.empty?
|
|
9
|
+
raise ArgumentError, "handler required" unless handler
|
|
10
|
+
|
|
11
|
+
exports[name] = handler
|
|
12
|
+
nil
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def on(event, &handler)
|
|
16
|
+
raise ArgumentError, "event must be a nonempty String" unless event.is_a?(String) && !event.empty?
|
|
17
|
+
raise ArgumentError, "handler required" unless handler
|
|
18
|
+
|
|
19
|
+
listeners[event] << handler
|
|
20
|
+
nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def call(method, params = {})
|
|
24
|
+
raise LifecycleError, "plugin is not running" unless running?
|
|
25
|
+
|
|
26
|
+
id = next_id
|
|
27
|
+
write(Protocol.request(id, method, params))
|
|
28
|
+
loop do
|
|
29
|
+
message = Protocol.read(@input)
|
|
30
|
+
raise Error, "host closed the connection" unless message
|
|
31
|
+
return response_value(message) if message["id"] == id && !message.key?("method")
|
|
32
|
+
|
|
33
|
+
dispatch(message)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def notify(method, params = {})
|
|
38
|
+
raise LifecycleError, "plugin is not running" unless running?
|
|
39
|
+
|
|
40
|
+
write(Protocol.notification(method, params))
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def capability?(name)
|
|
45
|
+
@capabilities.include?(name.to_s)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def run(input: $stdin, output: $stdout)
|
|
49
|
+
@input = input
|
|
50
|
+
@output = output
|
|
51
|
+
@input.binmode if @input.respond_to?(:binmode)
|
|
52
|
+
@output.binmode if @output.respond_to?(:binmode)
|
|
53
|
+
@running = true
|
|
54
|
+
loop do
|
|
55
|
+
message = Protocol.read(@input)
|
|
56
|
+
break unless message
|
|
57
|
+
|
|
58
|
+
dispatch(message)
|
|
59
|
+
break if @stopped
|
|
60
|
+
end
|
|
61
|
+
nil
|
|
62
|
+
ensure
|
|
63
|
+
@running = false
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def reset!
|
|
67
|
+
@exports = {}
|
|
68
|
+
@listeners = Hash.new { |hash, key| hash[key] = [] }
|
|
69
|
+
@capabilities = []
|
|
70
|
+
@sequence = 0
|
|
71
|
+
@running = false
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def exports
|
|
76
|
+
@exports ||= {}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def listeners
|
|
80
|
+
@listeners ||= Hash.new { |hash, key| hash[key] = [] }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def running?
|
|
84
|
+
!!@running
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def next_id
|
|
88
|
+
@sequence = (@sequence || 0) + 1
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def write(message)
|
|
92
|
+
@output.write(Protocol.frame(message))
|
|
93
|
+
@output.flush
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def dispatch(message)
|
|
97
|
+
if message.key?("method")
|
|
98
|
+
if message.key?("id")
|
|
99
|
+
write(handle_request(message))
|
|
100
|
+
else
|
|
101
|
+
handle_notification(message)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def handle_request(message)
|
|
107
|
+
method = message["method"]
|
|
108
|
+
params = message.fetch("params", {})
|
|
109
|
+
case method
|
|
110
|
+
when "initialize"
|
|
111
|
+
@capabilities = Array(params["capabilities"]).map(&:to_s).freeze
|
|
112
|
+
Protocol.response(message["id"], result: {"api_version" => params["api_version"]})
|
|
113
|
+
when "shutdown"
|
|
114
|
+
@stopped = true
|
|
115
|
+
Protocol.response(message["id"], result: nil)
|
|
116
|
+
else
|
|
117
|
+
handler = exports[method]
|
|
118
|
+
unless handler
|
|
119
|
+
return Protocol.response(message["id"], error: {"code" => -32601, "message" => "method not found: #{method}"})
|
|
120
|
+
end
|
|
121
|
+
result = handler.arity == 1 ? handler.call(params) : handler.call(self, params)
|
|
122
|
+
Protocol.response(message["id"], result: result)
|
|
123
|
+
end
|
|
124
|
+
rescue StandardError => error
|
|
125
|
+
Protocol.response(message["id"], error: {"code" => -32000, "message" => "#{error.class}: #{error.message}"})
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def handle_notification(message)
|
|
129
|
+
listeners.fetch(message["method"], []).each { |listener| listener.call(message.fetch("params", {})) }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def response_value(message)
|
|
133
|
+
raise Error, message.fetch("error").fetch("message") if message.key?("error")
|
|
134
|
+
|
|
135
|
+
message["result"]
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
reset!
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gienah
|
|
4
|
+
module Protocol
|
|
5
|
+
MAX_MESSAGE = 4 * 1024 * 1024
|
|
6
|
+
MAX_HEADER = 16 * 1024
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def frame(message, max_size: MAX_MESSAGE)
|
|
10
|
+
validate_message(message)
|
|
11
|
+
body = JSON.generate(message).b
|
|
12
|
+
raise ProtocolError, "message exceeds #{max_size} bytes" unless body.bytesize.between?(1, max_size)
|
|
13
|
+
|
|
14
|
+
"Content-Length: #{body.bytesize}\r\n\r\n".b + body
|
|
15
|
+
rescue JSON::GeneratorError => error
|
|
16
|
+
raise ProtocolError, "invalid JSON: #{error.message}"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def read(io, max_size: MAX_MESSAGE)
|
|
20
|
+
headers = {}
|
|
21
|
+
bytes = 0
|
|
22
|
+
loop do
|
|
23
|
+
line = io.gets("\r\n", MAX_HEADER + 1)
|
|
24
|
+
return nil if line.nil? && headers.empty?
|
|
25
|
+
raise ProtocolError, "truncated header" unless line&.end_with?("\r\n")
|
|
26
|
+
raise ProtocolError, "oversized header" if line.bytesize > MAX_HEADER
|
|
27
|
+
break if line == "\r\n"
|
|
28
|
+
|
|
29
|
+
bytes += line.bytesize
|
|
30
|
+
raise ProtocolError, "oversized headers" if bytes > MAX_HEADER
|
|
31
|
+
raise ProtocolError, "non-ASCII header" unless line.ascii_only?
|
|
32
|
+
|
|
33
|
+
key, value = line.delete_suffix("\r\n").split(":", 2)
|
|
34
|
+
raise ProtocolError, "invalid header" unless key&.match?(/\A[A-Za-z][A-Za-z0-9-]*\z/) && value
|
|
35
|
+
key = key.downcase
|
|
36
|
+
raise ProtocolError, "duplicate header" if headers.key?(key)
|
|
37
|
+
headers[key] = value.strip
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
raw_length = headers["content-length"]
|
|
41
|
+
raise ProtocolError, "missing or invalid Content-Length" unless raw_length&.match?(/\A\d+\z/)
|
|
42
|
+
length = Integer(raw_length, 10)
|
|
43
|
+
raise ProtocolError, "message exceeds #{max_size} bytes" unless length.between?(1, max_size)
|
|
44
|
+
body = read_exact(io, length)
|
|
45
|
+
raise ProtocolError, "truncated body" unless body&.bytesize == length
|
|
46
|
+
body.force_encoding(Encoding::UTF_8)
|
|
47
|
+
raise ProtocolError, "invalid UTF-8 body" unless body.valid_encoding?
|
|
48
|
+
validate_message(JSON.parse(body))
|
|
49
|
+
rescue JSON::ParserError => error
|
|
50
|
+
raise ProtocolError, "invalid JSON: #{error.message}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def validate_message(message)
|
|
54
|
+
raise ProtocolError, "message must be an object" unless message.is_a?(Hash)
|
|
55
|
+
version = message["jsonrpc"] || message[:jsonrpc]
|
|
56
|
+
raise ProtocolError, "jsonrpc must be \"2.0\"" unless version == "2.0"
|
|
57
|
+
method = message["method"] || message[:method]
|
|
58
|
+
id_present = message.key?("id") || message.key?(:id)
|
|
59
|
+
has_result = message.key?("result") || message.key?(:result)
|
|
60
|
+
has_error = message.key?("error") || message.key?(:error)
|
|
61
|
+
raise ProtocolError, "method must be a nonempty String" if method && (!method.is_a?(String) || method.empty?)
|
|
62
|
+
if method
|
|
63
|
+
raise ProtocolError, "request cannot contain result or error" if has_result || has_error
|
|
64
|
+
params = message["params"] || message[:params]
|
|
65
|
+
raise ProtocolError, "params must be an object or array" if params && !params.is_a?(Hash) && !params.is_a?(Array)
|
|
66
|
+
elsif id_present
|
|
67
|
+
id = message["id"] || message[:id]
|
|
68
|
+
raise ProtocolError, "invalid response id" unless id.is_a?(Integer) || id.is_a?(String)
|
|
69
|
+
raise ProtocolError, "response must contain exactly one result or error" unless has_result ^ has_error
|
|
70
|
+
error = message["error"] || message[:error]
|
|
71
|
+
raise ProtocolError, "invalid response error" if has_error && (!error.is_a?(Hash) || !error["message"].is_a?(String))
|
|
72
|
+
else
|
|
73
|
+
raise ProtocolError, "message must contain method or id"
|
|
74
|
+
end
|
|
75
|
+
message
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def request(id, method, params = {})
|
|
79
|
+
{"jsonrpc" => "2.0", "id" => id, "method" => method, "params" => params}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def notification(method, params = {})
|
|
83
|
+
{"jsonrpc" => "2.0", "method" => method, "params" => params}
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def response(id, result: nil, error: nil)
|
|
87
|
+
raise ArgumentError, "result and error are exclusive" if !error.nil? && !result.nil?
|
|
88
|
+
value = {"jsonrpc" => "2.0", "id" => id}
|
|
89
|
+
error.nil? ? value["result"] = result : value["error"] = error
|
|
90
|
+
value
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def read_exact(io, size)
|
|
94
|
+
data = +"".b
|
|
95
|
+
while data.bytesize < size
|
|
96
|
+
chunk = io.read(size - data.bytesize)
|
|
97
|
+
return nil if chunk.nil? || chunk.empty?
|
|
98
|
+
data << chunk
|
|
99
|
+
end
|
|
100
|
+
data
|
|
101
|
+
end
|
|
102
|
+
private_class_method :read_exact
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gienah
|
|
4
|
+
module Sandbox
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
DANGEROUS = /\A(?:fs\.write:|net:|exec\z|process\.exec\z)/
|
|
8
|
+
|
|
9
|
+
def dangerous?(capabilities)
|
|
10
|
+
Array(capabilities).any? { |capability| capability.match?(DANGEROUS) }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def policy_for(capabilities, root:)
|
|
14
|
+
require "saiph"
|
|
15
|
+
root = File.expand_path(root.to_s)
|
|
16
|
+
reads = []
|
|
17
|
+
writes = []
|
|
18
|
+
network = false
|
|
19
|
+
exec = false
|
|
20
|
+
Array(capabilities).each do |capability|
|
|
21
|
+
case capability
|
|
22
|
+
when /\Afs\.read:(.+)/
|
|
23
|
+
reads << path_for(Regexp.last_match(1), root)
|
|
24
|
+
when /\Afs\.write:(.+)/
|
|
25
|
+
writes << path_for(Regexp.last_match(1), root)
|
|
26
|
+
when /\Anet:/
|
|
27
|
+
network = true
|
|
28
|
+
when "exec", "process.exec"
|
|
29
|
+
exec = true
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
Saiph::Policy.new((reads + [root]).uniq, writes.uniq, network, exec, ENV.keys)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def available?
|
|
36
|
+
require "saiph"
|
|
37
|
+
Saiph.available?
|
|
38
|
+
rescue LoadError, StandardError
|
|
39
|
+
false
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def ensure_safe!(capabilities)
|
|
43
|
+
raise Error, "OS sandbox is unavailable for dangerous capabilities" if dangerous?(capabilities) && !available?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def path_for(pattern, root)
|
|
47
|
+
pattern = pattern.sub("${workspaceFolder}", root)
|
|
48
|
+
return root if pattern.include?("*") && !pattern.start_with?("/")
|
|
49
|
+
|
|
50
|
+
path = pattern.start_with?("/") ? pattern : File.join(root, pattern)
|
|
51
|
+
wildcard = path.index(/[*?\[]/)
|
|
52
|
+
File.expand_path(wildcard ? path[0...wildcard].sub(%r{[/\\]\z}, "") : path)
|
|
53
|
+
end
|
|
54
|
+
private_class_method :path_for
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gienah
|
|
4
|
+
module Testing
|
|
5
|
+
class FakePlugin
|
|
6
|
+
attr_reader :capabilities
|
|
7
|
+
|
|
8
|
+
def initialize(capabilities: [])
|
|
9
|
+
@exports = {}
|
|
10
|
+
@notifications = []
|
|
11
|
+
@capabilities = capabilities.map(&:to_s)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def export(method, &handler)
|
|
15
|
+
@exports[method] = handler
|
|
16
|
+
self
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def on_notification(&handler)
|
|
20
|
+
@notifications << handler
|
|
21
|
+
self
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def transport(manifest, &receive)
|
|
25
|
+
FakeTransport.new(self, manifest, receive)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def dispatch(message)
|
|
29
|
+
method = message["method"]
|
|
30
|
+
params = message.fetch("params", {})
|
|
31
|
+
case method
|
|
32
|
+
when "initialize"
|
|
33
|
+
@capabilities = Array(params["capabilities"]).map(&:to_s)
|
|
34
|
+
Protocol.response(message["id"], result: {"api_version" => params["api_version"]})
|
|
35
|
+
when "shutdown"
|
|
36
|
+
Protocol.response(message["id"], result: nil)
|
|
37
|
+
else
|
|
38
|
+
handler = @exports[method]
|
|
39
|
+
return Protocol.response(message["id"], error: {"code" => -32601, "message" => "method not found: #{method}"}) unless handler
|
|
40
|
+
result = handler.arity == 1 ? handler.call(params) : handler.call(self, params)
|
|
41
|
+
Protocol.response(message["id"], result: result)
|
|
42
|
+
end
|
|
43
|
+
rescue StandardError => error
|
|
44
|
+
Protocol.response(message["id"], error: {"code" => -32000, "message" => "#{error.class}: #{error.message}"})
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def notify(method, params = {})
|
|
48
|
+
@notifications.each { |handler| handler.call(method, params) }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
class FakeTransport
|
|
53
|
+
attr_reader :pid
|
|
54
|
+
|
|
55
|
+
def initialize(plugin, manifest, receive)
|
|
56
|
+
@plugin = plugin
|
|
57
|
+
@manifest = manifest
|
|
58
|
+
@receive = receive
|
|
59
|
+
@pid = Process.pid
|
|
60
|
+
@closed = false
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def write(message, **)
|
|
64
|
+
raise Error, "transport is closed" if @closed
|
|
65
|
+
response = @plugin.dispatch(message)
|
|
66
|
+
@receive.call(response, nil) if response
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def alive?
|
|
71
|
+
!@closed
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def close(**)
|
|
75
|
+
@closed = true
|
|
76
|
+
true
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gienah
|
|
4
|
+
class Transport
|
|
5
|
+
attr_reader :pid
|
|
6
|
+
|
|
7
|
+
def self.windows?
|
|
8
|
+
/mswin|mingw/.match?(RbConfig::CONFIG.fetch("host_os"))
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.open(command, cwd: nil, env: {}, policy: nil, &receive)
|
|
12
|
+
raise ArgumentError, "receiver required" unless receive
|
|
13
|
+
raise ArgumentError, "command must be a nonempty Array" unless command.is_a?(Array) && !command.empty?
|
|
14
|
+
|
|
15
|
+
input_read, input_write = IO.pipe
|
|
16
|
+
output_read, output_write = IO.pipe
|
|
17
|
+
error_read, error_write = IO.pipe
|
|
18
|
+
[input_read, input_write, output_read, output_write, error_read, error_write].each(&:binmode)
|
|
19
|
+
options = {in: input_read, out: output_write, err: error_write, close_others: true}
|
|
20
|
+
options[:chdir] = cwd if cwd
|
|
21
|
+
options[:new_pgroup] = true if windows?
|
|
22
|
+
pid = if policy
|
|
23
|
+
require "saiph"
|
|
24
|
+
Saiph.spawn(command, policy: policy, **options, env: env)
|
|
25
|
+
else
|
|
26
|
+
Process.spawn(env, *command, **options)
|
|
27
|
+
end
|
|
28
|
+
[input_read, output_write, error_write].each(&:close)
|
|
29
|
+
new(input_write, output_read, error_read, pid, receive)
|
|
30
|
+
rescue Exception
|
|
31
|
+
[input_read, input_write, output_read, output_write, error_read, error_write].compact.each do |io|
|
|
32
|
+
io.close unless io.closed?
|
|
33
|
+
end
|
|
34
|
+
Process.kill("KILL", pid) if pid
|
|
35
|
+
Process.wait(pid) if pid
|
|
36
|
+
raise
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def initialize(input, output, error, pid, receive)
|
|
40
|
+
@input = input
|
|
41
|
+
@output = output
|
|
42
|
+
@error = error
|
|
43
|
+
@pid = pid
|
|
44
|
+
@receive = receive
|
|
45
|
+
@write_lock = Mutex.new
|
|
46
|
+
@closed = false
|
|
47
|
+
@reader = Thread.new { read_loop }
|
|
48
|
+
@reader.report_on_exception = false
|
|
49
|
+
@stderr_reader = Thread.new { drain_stderr }
|
|
50
|
+
@stderr_reader.report_on_exception = false
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def write(message, max_size: Protocol::MAX_MESSAGE)
|
|
54
|
+
frame = Protocol.frame(message, max_size: max_size)
|
|
55
|
+
@write_lock.synchronize do
|
|
56
|
+
raise Error, "transport is closed" if closed?
|
|
57
|
+
|
|
58
|
+
@input.write(frame)
|
|
59
|
+
@input.flush
|
|
60
|
+
end
|
|
61
|
+
nil
|
|
62
|
+
rescue IOError, Errno::EPIPE => error
|
|
63
|
+
raise Error, "transport write failed: #{error.message}"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def alive?
|
|
67
|
+
!closed? && (@pid.nil? || process_alive?)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def close(grace: 0.5)
|
|
71
|
+
@closed = true
|
|
72
|
+
@input.close unless @input.closed?
|
|
73
|
+
joined = @reader == Thread.current || @reader.join(grace)
|
|
74
|
+
unless joined
|
|
75
|
+
terminate("TERM")
|
|
76
|
+
joined = @reader.join(grace)
|
|
77
|
+
end
|
|
78
|
+
unless joined
|
|
79
|
+
terminate("KILL")
|
|
80
|
+
@reader.join
|
|
81
|
+
end
|
|
82
|
+
@stderr_reader.join(grace) unless @stderr_reader == Thread.current
|
|
83
|
+
[@output, @error].each { |io| io.close unless io.closed? }
|
|
84
|
+
if @pid && process_alive?
|
|
85
|
+
terminate("TERM")
|
|
86
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + grace
|
|
87
|
+
until Process.waitpid(@pid, Process::WNOHANG)
|
|
88
|
+
break if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
89
|
+
|
|
90
|
+
sleep(0.01)
|
|
91
|
+
end
|
|
92
|
+
if process_alive?
|
|
93
|
+
terminate("KILL")
|
|
94
|
+
Process.wait(@pid)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
true
|
|
98
|
+
rescue IOError, Errno::ECHILD
|
|
99
|
+
true
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def read_loop
|
|
105
|
+
loop do
|
|
106
|
+
message = Protocol.read(@output)
|
|
107
|
+
break unless message
|
|
108
|
+
|
|
109
|
+
@receive.call(message, nil)
|
|
110
|
+
end
|
|
111
|
+
@receive.call(nil, Error.new("transport closed")) unless @closed
|
|
112
|
+
rescue StandardError => error
|
|
113
|
+
@receive.call(nil, error) unless @closed
|
|
114
|
+
ensure
|
|
115
|
+
@closed = true
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def drain_stderr
|
|
119
|
+
while @error.read(8_192)
|
|
120
|
+
break if @closed
|
|
121
|
+
end
|
|
122
|
+
rescue IOError
|
|
123
|
+
nil
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def closed?
|
|
127
|
+
@closed
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def process_alive?
|
|
131
|
+
Process.kill(0, @pid)
|
|
132
|
+
true
|
|
133
|
+
rescue Errno::ESRCH, Errno::ECHILD
|
|
134
|
+
false
|
|
135
|
+
rescue Errno::EPERM
|
|
136
|
+
true
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def terminate(signal)
|
|
140
|
+
Process.kill(windows? ? "KILL" : signal, @pid)
|
|
141
|
+
rescue Errno::ESRCH, Errno::ECHILD
|
|
142
|
+
nil
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def windows?
|
|
146
|
+
self.class.windows?
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|