jrpc 1.1.7 → 2.0.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 +4 -4
- data/.github/workflows/ci.yml +55 -0
- data/.gitignore +1 -0
- data/.rspec +1 -0
- data/.rubocop.yml +228 -0
- data/CHANGELOG.md +84 -0
- data/Gemfile +17 -0
- data/README.md +163 -13
- data/Rakefile +3 -1
- data/bin/console +15 -0
- data/bin/jrpc +111 -0
- data/bin/jrpc-shell +109 -0
- data/bin/setup +8 -0
- data/jrpc.gemspec +9 -8
- data/lib/jrpc/errors.rb +65 -0
- data/lib/jrpc/id_generator.rb +22 -0
- data/lib/jrpc/message.rb +78 -0
- data/lib/jrpc/shared_client/outbound_queue.rb +71 -0
- data/lib/jrpc/shared_client/registry.rb +46 -0
- data/lib/jrpc/shared_client/ticket.rb +84 -0
- data/lib/jrpc/shared_client/transport_loop.rb +290 -0
- data/lib/jrpc/shared_client.rb +194 -0
- data/lib/jrpc/simple_client.rb +89 -0
- data/lib/jrpc/transport/base.rb +60 -0
- data/lib/jrpc/transport/tcp.rb +243 -0
- data/lib/jrpc/transport.rb +12 -0
- data/lib/jrpc/version.rb +3 -1
- data/lib/jrpc.rb +15 -16
- metadata +35 -76
- data/.travis.yml +0 -4
- data/lib/jrpc/base_client.rb +0 -123
- data/lib/jrpc/error/client_error.rb +0 -5
- data/lib/jrpc/error/connection_error.rb +0 -11
- data/lib/jrpc/error/error.rb +0 -5
- data/lib/jrpc/error/internal_error.rb +0 -9
- data/lib/jrpc/error/internal_server_error.rb +0 -5
- data/lib/jrpc/error/invalid_params.rb +0 -9
- data/lib/jrpc/error/invalid_request.rb +0 -9
- data/lib/jrpc/error/method_not_found.rb +0 -9
- data/lib/jrpc/error/parse_error.rb +0 -9
- data/lib/jrpc/error/server_error.rb +0 -11
- data/lib/jrpc/error/unknown_error.rb +0 -5
- data/lib/jrpc/tcp_client.rb +0 -112
- data/lib/jrpc/transport/socket_base.rb +0 -88
- data/lib/jrpc/transport/socket_tcp.rb +0 -98
- data/lib/jrpc/utils.rb +0 -9
data/bin/jrpc
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'optparse'
|
|
5
|
+
require 'jrpc'
|
|
6
|
+
|
|
7
|
+
Options = Struct.new(
|
|
8
|
+
:host,
|
|
9
|
+
:port,
|
|
10
|
+
:type,
|
|
11
|
+
:rpc_method,
|
|
12
|
+
:params,
|
|
13
|
+
:debug,
|
|
14
|
+
:connect_timeout,
|
|
15
|
+
:read_timeout,
|
|
16
|
+
:write_timeout
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
class Parser
|
|
20
|
+
def self.parse(argv)
|
|
21
|
+
args = Options.new
|
|
22
|
+
args.host = '127.0.0.1'
|
|
23
|
+
args.port = 7080
|
|
24
|
+
args.type = 'request'
|
|
25
|
+
args.connect_timeout = 5
|
|
26
|
+
args.read_timeout = 5
|
|
27
|
+
args.write_timeout = 60
|
|
28
|
+
|
|
29
|
+
opt_parser = OptionParser.new do |opts|
|
|
30
|
+
opts.banner = 'Usage: jrpc [options] method [params, ...]'
|
|
31
|
+
|
|
32
|
+
opts.on('--host=HOST', 'host (default 127.0.0.1)') do |host|
|
|
33
|
+
args.host = host
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
opts.on('-p=PORT', '--port=PORT', 'port (default 7080)') do |port|
|
|
37
|
+
args.port = port
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
opts.on('-r', '--request', 'Sets type to request (default true)') do
|
|
41
|
+
args.type = 'request'
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
opts.on('-n', '--notification', 'Sets type to notification (default false)') do
|
|
45
|
+
args.type = 'notification'
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
opts.on('--connect-timeout=TIMEOUT', Float, 'connect timeout in seconds (default 5)') do |t|
|
|
49
|
+
args.connect_timeout = t
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
opts.on('--read-timeout=TIMEOUT', Float, 'read timeout in seconds (default 5)') do |t|
|
|
53
|
+
args.read_timeout = t
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
opts.on('--write-timeout=TIMEOUT', Float, 'write timeout in seconds (default 60)') do |t|
|
|
57
|
+
args.write_timeout = t
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
opts.on('-d', '--debug', 'Debug output') do
|
|
61
|
+
args.debug = true
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
opts.on('-h', '--help', 'Prints this help and exit') do
|
|
65
|
+
puts opts
|
|
66
|
+
exit
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
opts.on('-v', '--version', 'Prints version and exit') do
|
|
70
|
+
puts "JRPC version: #{JRPC::VERSION}"
|
|
71
|
+
exit
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
opt_parser.parse!(argv)
|
|
76
|
+
args.rpc_method = argv.first
|
|
77
|
+
args.params = argv[1..]
|
|
78
|
+
args
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
options = Parser.parse(ARGV.dup)
|
|
83
|
+
|
|
84
|
+
logger = Logger.new($stdout)
|
|
85
|
+
logger.level = options.debug ? Logger::DEBUG : Logger::INFO
|
|
86
|
+
addr = "#{options.host}:#{options.port}"
|
|
87
|
+
logger.debug { "Connecting to #{addr} ..." }
|
|
88
|
+
|
|
89
|
+
client = JRPC::SimpleClient.new(
|
|
90
|
+
addr,
|
|
91
|
+
connect_timeout: options.connect_timeout,
|
|
92
|
+
read_timeout: options.read_timeout,
|
|
93
|
+
write_timeout: options.write_timeout,
|
|
94
|
+
logger: logger
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
params = options.params.empty? ? nil : options.params
|
|
98
|
+
|
|
99
|
+
logger.debug { "Sending #{options.type} #{options.rpc_method} #{params} ..." }
|
|
100
|
+
|
|
101
|
+
if options.type == 'request'
|
|
102
|
+
response = client.request(options.rpc_method, params)
|
|
103
|
+
logger.debug { "Request was sent. Response: #{response.inspect}" }
|
|
104
|
+
puts JSON.pretty_generate(response)
|
|
105
|
+
else
|
|
106
|
+
client.notification(options.rpc_method, params)
|
|
107
|
+
logger.debug { 'Notification was sent.' }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
client.close
|
|
111
|
+
logger.debug 'Exited'
|
data/bin/jrpc-shell
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'readline'
|
|
5
|
+
require 'singleton'
|
|
6
|
+
require 'jrpc'
|
|
7
|
+
|
|
8
|
+
class Command
|
|
9
|
+
include Singleton
|
|
10
|
+
|
|
11
|
+
def self.call(command, args)
|
|
12
|
+
meth = "cmd_#{command}"
|
|
13
|
+
if instance.respond_to?(meth)
|
|
14
|
+
instance.public_send(meth, *args)
|
|
15
|
+
else
|
|
16
|
+
"ERROR: invalid command #{command.inspect}\n#{instance.help_usage}"
|
|
17
|
+
end
|
|
18
|
+
rescue ArgumentError => e
|
|
19
|
+
"ERROR: ArgumentError #{e.message}\n#{instance.help_usage}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
attr_accessor :logger, :client, :help_usage
|
|
23
|
+
|
|
24
|
+
instance.logger = Logger.new($stdout)
|
|
25
|
+
instance.logger.level = Logger::INFO
|
|
26
|
+
instance.help_usage = [
|
|
27
|
+
'Usage:',
|
|
28
|
+
' connect host port',
|
|
29
|
+
' disconnect',
|
|
30
|
+
' request method param1 param2',
|
|
31
|
+
' request method {"param1": 1, "param2": 2}',
|
|
32
|
+
' notification method param1 param2',
|
|
33
|
+
' notification method {"param1": 1, "param2": 2}',
|
|
34
|
+
' help',
|
|
35
|
+
' version'
|
|
36
|
+
].join("\n")
|
|
37
|
+
|
|
38
|
+
def cmd_help
|
|
39
|
+
help_usage
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def cmd_version
|
|
43
|
+
"JRPC version: #{JRPC::VERSION}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def cmd_connect(host, port)
|
|
47
|
+
client&.close
|
|
48
|
+
self.client = JRPC::SimpleClient.new(
|
|
49
|
+
"#{host}:#{port}",
|
|
50
|
+
connect_timeout: 5,
|
|
51
|
+
read_timeout: 5,
|
|
52
|
+
write_timeout: 60,
|
|
53
|
+
logger: logger
|
|
54
|
+
)
|
|
55
|
+
'Connected.'
|
|
56
|
+
rescue JRPC::Errors::Error => e
|
|
57
|
+
"ERROR: JRPC #{e.message}\n#{help_usage}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def cmd_disconnect
|
|
61
|
+
return "ERROR: Not connected\n#{help_usage}" if client.nil?
|
|
62
|
+
|
|
63
|
+
client.close
|
|
64
|
+
self.client = nil
|
|
65
|
+
'Disconnected'
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def cmd_request(method, *params)
|
|
69
|
+
return "ERROR: Not connected\n#{help_usage}" if client.nil?
|
|
70
|
+
|
|
71
|
+
params = JSON.parse(params.first) if params.size == 1 && params[0].start_with?('{')
|
|
72
|
+
params = nil if params.empty?
|
|
73
|
+
|
|
74
|
+
response = client.request(method, params)
|
|
75
|
+
JSON.pretty_generate(response)
|
|
76
|
+
rescue JRPC::Errors::Error => e
|
|
77
|
+
"ERROR: JRPC #{e.message}\n#{help_usage}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def cmd_notification(method, *params)
|
|
81
|
+
return "ERROR: Not connected\n#{help_usage}" if client.nil?
|
|
82
|
+
|
|
83
|
+
params = JSON.parse(params.first) if params.size == 1 && params[0].start_with?('{')
|
|
84
|
+
params = nil if params.empty?
|
|
85
|
+
|
|
86
|
+
client.notification(method, params)
|
|
87
|
+
'Notification sent.'
|
|
88
|
+
rescue JRPC::Errors::Error => e
|
|
89
|
+
"ERROR: JRPC #{e.message}\n#{help_usage}"
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
EXIT_COMMANDS = %w[exit close quit].freeze
|
|
94
|
+
|
|
95
|
+
puts 'Welcome to JRPC shell'
|
|
96
|
+
while (input = Readline.readline('> ', true))
|
|
97
|
+
if EXIT_COMMANDS.include?(input)
|
|
98
|
+
break
|
|
99
|
+
elsif input == 'hist'
|
|
100
|
+
puts Readline::HISTORY.to_a
|
|
101
|
+
elsif input == ''
|
|
102
|
+
Readline::HISTORY.pop
|
|
103
|
+
else
|
|
104
|
+
command, *args = input.split
|
|
105
|
+
puts Command.call(command, args)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
puts 'Shell Exited'
|
data/bin/setup
ADDED
data/jrpc.gemspec
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
lib = File.expand_path('lib', __dir__)
|
|
3
4
|
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
|
4
5
|
require 'jrpc/version'
|
|
5
6
|
|
|
@@ -11,16 +12,16 @@ Gem::Specification.new do |spec|
|
|
|
11
12
|
|
|
12
13
|
spec.summary = 'JSON RPC client'
|
|
13
14
|
spec.description = 'JSON RPC client over TCP'
|
|
14
|
-
spec.homepage = 'https://github.com/
|
|
15
|
+
spec.homepage = 'https://github.com/didww/jrpc'
|
|
15
16
|
spec.license = 'MIT'
|
|
16
17
|
|
|
17
18
|
spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
|
18
19
|
spec.require_paths = ['lib']
|
|
20
|
+
spec.required_ruby_version = '>= 3.3'
|
|
19
21
|
|
|
20
|
-
spec.add_dependency '
|
|
21
|
-
spec.add_dependency '
|
|
22
|
+
spec.add_dependency 'concurrent-ruby', '~> 1.2'
|
|
23
|
+
spec.add_dependency 'logger'
|
|
22
24
|
|
|
23
|
-
spec.
|
|
24
|
-
spec.
|
|
25
|
-
spec.add_development_dependency 'rspec', '~> 3.0'
|
|
25
|
+
spec.executables << 'jrpc'
|
|
26
|
+
spec.executables << 'jrpc-shell'
|
|
26
27
|
end
|
data/lib/jrpc/errors.rb
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JRPC
|
|
4
|
+
module Errors
|
|
5
|
+
class Error < RuntimeError; end
|
|
6
|
+
|
|
7
|
+
class ClientError < Error; end
|
|
8
|
+
|
|
9
|
+
class ConnectionError < Error
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
class Timeout < Error; end
|
|
13
|
+
|
|
14
|
+
class ServerError < Error
|
|
15
|
+
attr_reader :code
|
|
16
|
+
|
|
17
|
+
def initialize(message, code: nil)
|
|
18
|
+
@code = code
|
|
19
|
+
super(message)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
class MalformedResponseError < ServerError
|
|
24
|
+
def initialize(message)
|
|
25
|
+
super(message, code: nil)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
class ParseError < ServerError
|
|
30
|
+
def initialize(message)
|
|
31
|
+
super(message, code: -32_700)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
class InvalidRequest < ServerError
|
|
36
|
+
def initialize(message)
|
|
37
|
+
super(message, code: -32_600)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
class MethodNotFound < ServerError
|
|
42
|
+
def initialize(message)
|
|
43
|
+
super(message, code: -32_601)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
class InvalidParams < ServerError
|
|
48
|
+
def initialize(message)
|
|
49
|
+
super(message, code: -32_602)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
class InternalError < ServerError
|
|
54
|
+
def initialize(message)
|
|
55
|
+
super(message, code: -32_603)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
class InternalServerError < ServerError
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
class UnknownError < ServerError
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module JRPC
|
|
6
|
+
class IdGenerator
|
|
7
|
+
def initialize(prefix: nil, thread_safe: false)
|
|
8
|
+
@prefix = prefix || SecureRandom.hex(8)
|
|
9
|
+
@n = 0
|
|
10
|
+
@mutex = thread_safe ? Mutex.new : nil
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def next
|
|
14
|
+
n = if @mutex
|
|
15
|
+
@mutex.synchronize { @n += 1 }
|
|
16
|
+
else
|
|
17
|
+
@n += 1
|
|
18
|
+
end
|
|
19
|
+
"#{@prefix}-#{n}"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
data/lib/jrpc/message.rb
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module JRPC
|
|
6
|
+
module Message
|
|
7
|
+
def self.build_request(method, params, id)
|
|
8
|
+
validate_params!(method, params)
|
|
9
|
+
envelope = { 'jsonrpc' => JRPC::JSON_RPC_VERSION, 'method' => method.to_s, 'id' => id }
|
|
10
|
+
envelope['params'] = params unless params.nil?
|
|
11
|
+
envelope
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.build_notification(method, params)
|
|
15
|
+
validate_params!(method, params)
|
|
16
|
+
envelope = { 'jsonrpc' => JRPC::JSON_RPC_VERSION, 'method' => method.to_s }
|
|
17
|
+
envelope['params'] = params unless params.nil?
|
|
18
|
+
envelope
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.dump(envelope)
|
|
22
|
+
JSON.generate(envelope)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.parse(json_string)
|
|
26
|
+
JSON.parse(json_string)
|
|
27
|
+
rescue JSON::ParserError => e
|
|
28
|
+
raise Errors::MalformedResponseError, "JSON parse error: #{e.message}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.validate_response!(hash, expected_id)
|
|
32
|
+
raise Errors::MalformedResponseError, 'response must be a Hash' unless hash.is_a?(Hash)
|
|
33
|
+
raise Errors::MalformedResponseError, "jsonrpc must be #{JRPC::JSON_RPC_VERSION.inspect}" unless hash['jsonrpc'] == JRPC::JSON_RPC_VERSION
|
|
34
|
+
unless hash['id'] == expected_id
|
|
35
|
+
raise Errors::MalformedResponseError, "id mismatch: expected #{expected_id.inspect}, got #{hash['id'].inspect}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
has_result = hash.key?('result')
|
|
39
|
+
has_error = hash.key?('error')
|
|
40
|
+
unless has_result ^ has_error
|
|
41
|
+
raise Errors::MalformedResponseError, "response must have exactly one of 'result' or 'error'"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
if has_error
|
|
45
|
+
err = hash['error']
|
|
46
|
+
raise Errors::MalformedResponseError, 'error must be a Hash' unless err.is_a?(Hash)
|
|
47
|
+
raise Errors::MalformedResponseError, 'error.code must be an Integer' unless err['code'].is_a?(Integer)
|
|
48
|
+
raise Errors::MalformedResponseError, 'error.message must be a String' unless err['message'].is_a?(String)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.error_to_exception(error_hash)
|
|
53
|
+
code = error_hash['code']
|
|
54
|
+
message = error_hash['message']
|
|
55
|
+
case code
|
|
56
|
+
when -32_700 then Errors::ParseError.new(message)
|
|
57
|
+
when -32_600 then Errors::InvalidRequest.new(message)
|
|
58
|
+
when -32_601 then Errors::MethodNotFound.new(message)
|
|
59
|
+
when -32_602 then Errors::InvalidParams.new(message)
|
|
60
|
+
when -32_603 then Errors::InternalError.new(message)
|
|
61
|
+
when -32_099..-32_000 then Errors::InternalServerError.new(message, code: code)
|
|
62
|
+
else Errors::UnknownError.new(message, code: code)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.validate_params!(method, params)
|
|
67
|
+
unless method.is_a?(String) || method.is_a?(Symbol)
|
|
68
|
+
raise Errors::ClientError, 'method must be a String or Symbol'
|
|
69
|
+
end
|
|
70
|
+
raise Errors::ClientError, 'method must not be empty' if method.to_s.empty?
|
|
71
|
+
unless params.nil? || params.is_a?(Array) || params.is_a?(Hash)
|
|
72
|
+
raise Errors::ClientError, 'params must be nil, Array, or Hash'
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private_class_method :validate_params!
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JRPC
|
|
4
|
+
class SharedClient
|
|
5
|
+
class OutboundQueue
|
|
6
|
+
def initialize(capacity: nil)
|
|
7
|
+
@mutex = Mutex.new
|
|
8
|
+
@arr = []
|
|
9
|
+
@capacity = capacity
|
|
10
|
+
@closed = false
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Raises ClientError("queue full") or ClientError("queue closed") on failure.
|
|
14
|
+
def push_nonblock(ticket)
|
|
15
|
+
@mutex.synchronize do
|
|
16
|
+
raise Errors::ClientError, 'queue closed' if @closed
|
|
17
|
+
raise Errors::ClientError, 'queue full' if @capacity && @arr.size >= @capacity
|
|
18
|
+
|
|
19
|
+
@arr << ticket
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Returns next Ticket or nil if empty.
|
|
24
|
+
def pop_nonblock
|
|
25
|
+
@mutex.synchronize { @arr.shift }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Yields each ticket from a snapshot; does not hold the mutex during the block.
|
|
29
|
+
def each_snapshot(&)
|
|
30
|
+
snapshot = @mutex.synchronize { @arr.dup }
|
|
31
|
+
snapshot.each(&)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Removes by object identity. Returns true if removed, false if not found.
|
|
35
|
+
def delete(ticket)
|
|
36
|
+
@mutex.synchronize do
|
|
37
|
+
idx = @arr.index { |t| t.equal?(ticket) }
|
|
38
|
+
return false unless idx
|
|
39
|
+
|
|
40
|
+
@arr.delete_at(idx)
|
|
41
|
+
true
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Returns the earliest expires_at across all queued tickets, or nil.
|
|
46
|
+
def earliest_deadline
|
|
47
|
+
@mutex.synchronize do
|
|
48
|
+
deadlines = @arr.filter_map(&:expires_at)
|
|
49
|
+
deadlines.min
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def empty?
|
|
54
|
+
@mutex.synchronize { @arr.empty? }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def size
|
|
58
|
+
@mutex.synchronize { @arr.size }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Sets closed = true, returns and clears all remaining tickets.
|
|
62
|
+
# Idempotent: returns [] on second call.
|
|
63
|
+
def close_and_drain
|
|
64
|
+
@mutex.synchronize do
|
|
65
|
+
@closed = true
|
|
66
|
+
@arr.slice!(0..)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JRPC
|
|
4
|
+
class SharedClient
|
|
5
|
+
class Registry
|
|
6
|
+
def initialize
|
|
7
|
+
@mutex = Mutex.new
|
|
8
|
+
@tickets = {}
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def register(ticket)
|
|
12
|
+
@mutex.synchronize { @tickets[ticket.id] = ticket }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def fetch_and_delete(id)
|
|
16
|
+
@mutex.synchronize { @tickets.delete(id) }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def delete(ticket)
|
|
20
|
+
@mutex.synchronize { @tickets.delete(ticket.id) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Yields each ticket while holding the registry mutex. Block must be quick — no I/O.
|
|
24
|
+
def each_ticket(&blk)
|
|
25
|
+
@mutex.synchronize { @tickets.each_value(&blk) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Signals every ticket with error and clears the registry atomically.
|
|
29
|
+
# Idempotent: tickets already in :done/:cancelled are skipped.
|
|
30
|
+
TERMINAL_STATES = %i[done cancelled].freeze
|
|
31
|
+
|
|
32
|
+
def drain_all_with(error)
|
|
33
|
+
tickets = @mutex.synchronize { @tickets.values.tap { @tickets.clear } }
|
|
34
|
+
tickets.each do |ticket|
|
|
35
|
+
next if TERMINAL_STATES.include?(ticket.state)
|
|
36
|
+
|
|
37
|
+
ticket.signal_error(error)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def empty?
|
|
42
|
+
@mutex.synchronize { @tickets.empty? }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'concurrent'
|
|
4
|
+
|
|
5
|
+
module JRPC
|
|
6
|
+
class SharedClient
|
|
7
|
+
# One in-flight request or notification together with its result future.
|
|
8
|
+
#
|
|
9
|
+
# The result is backed by a write-once Concurrent::Promises.resolvable_future.
|
|
10
|
+
# That single primitive replaces the old hand-rolled Mutex+ConditionVariable
|
|
11
|
+
# state machine and gives us three things for free:
|
|
12
|
+
#
|
|
13
|
+
# * idempotent resolution - fulfill/reject never raise on a second call,
|
|
14
|
+
# so the transport loop and the close path can both resolve a ticket
|
|
15
|
+
# without racing (fixes the old :cancelled -> :done overwrite).
|
|
16
|
+
# * a caller-side timeout - #wait takes a timeout, so a caller is no
|
|
17
|
+
# longer at the mercy of the loop thread to be woken (fixes the
|
|
18
|
+
# "loop dies, callers hang forever" hole).
|
|
19
|
+
# * fiber cooperation - the future's wait bottoms out in a
|
|
20
|
+
# scheduler-aware ConditionVariable, so blocking a fiber under Falcon /
|
|
21
|
+
# rage-rb yields to the reactor instead of stalling the thread.
|
|
22
|
+
#
|
|
23
|
+
# `cancelled` is a separate monotonic flag (the caller gave up); it is
|
|
24
|
+
# deliberately NOT part of the future, so cancellation can never race or
|
|
25
|
+
# overwrite a real result.
|
|
26
|
+
class Ticket
|
|
27
|
+
attr_reader :id, :payload, :thread, :expires_at
|
|
28
|
+
|
|
29
|
+
def initialize(id:, payload:, thread:, expires_at: nil)
|
|
30
|
+
@id = id
|
|
31
|
+
@payload = payload
|
|
32
|
+
@thread = thread
|
|
33
|
+
@expires_at = expires_at
|
|
34
|
+
@future = Concurrent::Promises.resolvable_future
|
|
35
|
+
@cancelled = Concurrent::AtomicBoolean.new(false)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# --- caller side -------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
# Block until resolved or `timeout` seconds elapse (nil waits forever).
|
|
41
|
+
# Cooperates with the fiber scheduler. Returns self; inspect afterwards.
|
|
42
|
+
def wait(timeout = nil)
|
|
43
|
+
@future.wait(timeout)
|
|
44
|
+
self
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def resolved? = @future.resolved?
|
|
48
|
+
def fulfilled? = @future.fulfilled?
|
|
49
|
+
def rejected? = @future.rejected?
|
|
50
|
+
def result = @future.fulfilled? ? @future.value : nil
|
|
51
|
+
def error = @future.rejected? ? @future.reason : nil
|
|
52
|
+
|
|
53
|
+
def cancel = @cancelled.make_true
|
|
54
|
+
def cancelled? = @cancelled.true?
|
|
55
|
+
|
|
56
|
+
# --- worker (transport loop / close) side ------------------------------
|
|
57
|
+
# All idempotent: the first resolution wins, later ones are no-ops.
|
|
58
|
+
|
|
59
|
+
def fulfill(value) = @future.fulfill(value, false)
|
|
60
|
+
def reject(err) = @future.reject(err, false)
|
|
61
|
+
|
|
62
|
+
# Resolution aliases used by Registry/OutboundQueue and the transport loop.
|
|
63
|
+
def signal_done(result:) = fulfill(result)
|
|
64
|
+
def signal_error(err) = reject(err)
|
|
65
|
+
# blocking notification: caller waits only for the send
|
|
66
|
+
def signal_sent = fulfill(nil)
|
|
67
|
+
|
|
68
|
+
# Coarse state view kept for Registry, which skips already-settled tickets.
|
|
69
|
+
def state
|
|
70
|
+
return :cancelled if cancelled?
|
|
71
|
+
return :done if resolved?
|
|
72
|
+
|
|
73
|
+
:pending
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# --- misc --------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
# fire_and_forget tickets (thread: nil) are never "alive".
|
|
79
|
+
def alive? = @thread.nil? ? false : @thread.alive?
|
|
80
|
+
|
|
81
|
+
def expired?(now) = @expires_at ? now >= @expires_at : false
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|