jrpc 1.1.8 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +55 -0
  3. data/.rspec +1 -0
  4. data/.rubocop.yml +228 -0
  5. data/CHANGELOG.md +67 -0
  6. data/Gemfile +17 -0
  7. data/README.md +240 -13
  8. data/Rakefile +3 -1
  9. data/bin/console +1 -0
  10. data/bin/jrpc +37 -26
  11. data/bin/jrpc-shell +34 -24
  12. data/jrpc.gemspec +6 -8
  13. data/lib/jrpc/errors.rb +65 -0
  14. data/lib/jrpc/id_generator.rb +22 -0
  15. data/lib/jrpc/message.rb +78 -0
  16. data/lib/jrpc/payload_logging.rb +19 -0
  17. data/lib/jrpc/shared_client/outbound_queue.rb +71 -0
  18. data/lib/jrpc/shared_client/registry.rb +46 -0
  19. data/lib/jrpc/shared_client/ticket.rb +84 -0
  20. data/lib/jrpc/shared_client/transport_loop.rb +298 -0
  21. data/lib/jrpc/shared_client.rb +194 -0
  22. data/lib/jrpc/simple_client.rb +98 -0
  23. data/lib/jrpc/transport/base.rb +63 -0
  24. data/lib/jrpc/transport/tcp.rb +292 -0
  25. data/lib/jrpc/transport/test.rb +333 -0
  26. data/lib/jrpc/transport.rb +12 -0
  27. data/lib/jrpc/version.rb +3 -1
  28. data/lib/jrpc.rb +14 -16
  29. metadata +25 -71
  30. data/.travis.yml +0 -4
  31. data/lib/jrpc/base_client.rb +0 -123
  32. data/lib/jrpc/error/client_error.rb +0 -5
  33. data/lib/jrpc/error/connection_error.rb +0 -11
  34. data/lib/jrpc/error/error.rb +0 -5
  35. data/lib/jrpc/error/internal_error.rb +0 -9
  36. data/lib/jrpc/error/internal_server_error.rb +0 -5
  37. data/lib/jrpc/error/invalid_params.rb +0 -9
  38. data/lib/jrpc/error/invalid_request.rb +0 -9
  39. data/lib/jrpc/error/method_not_found.rb +0 -9
  40. data/lib/jrpc/error/parse_error.rb +0 -9
  41. data/lib/jrpc/error/server_error.rb +0 -11
  42. data/lib/jrpc/error/unknown_error.rb +0 -5
  43. data/lib/jrpc/tcp_client.rb +0 -112
  44. data/lib/jrpc/transport/socket_base.rb +0 -88
  45. data/lib/jrpc/transport/socket_tcp.rb +0 -132
  46. data/lib/jrpc/utils.rb +0 -9
data/bin/jrpc CHANGED
@@ -1,19 +1,19 @@
1
1
  #!/usr/bin/env ruby
2
- # coding: utf-8
2
+ # frozen_string_literal: true
3
3
 
4
4
  require 'optparse'
5
5
  require 'jrpc'
6
6
 
7
7
  Options = Struct.new(
8
- :host,
9
- :port,
10
- :type,
11
- :method,
12
- :params,
13
- :id,
14
- :debug,
15
- :namespace,
16
- :timeout
8
+ :host,
9
+ :port,
10
+ :type,
11
+ :rpc_method,
12
+ :params,
13
+ :debug,
14
+ :connect_timeout,
15
+ :read_timeout,
16
+ :write_timeout
17
17
  )
18
18
 
19
19
  class Parser
@@ -22,7 +22,9 @@ class Parser
22
22
  args.host = '127.0.0.1'
23
23
  args.port = 7080
24
24
  args.type = 'request'
25
- args.timeout = 5
25
+ args.connect_timeout = 5
26
+ args.read_timeout = 5
27
+ args.write_timeout = 60
26
28
 
27
29
  opt_parser = OptionParser.new do |opts|
28
30
  opts.banner = 'Usage: jrpc [options] method [params, ...]'
@@ -39,20 +41,20 @@ class Parser
39
41
  args.type = 'request'
40
42
  end
41
43
 
42
- opts.on('-n', '--notification', 'Sets type to is notification (default false)') do
44
+ opts.on('-n', '--notification', 'Sets type to notification (default false)') do
43
45
  args.type = 'notification'
44
46
  end
45
47
 
46
- opts.on('--namespace=NAMESPACE', 'Sets method namespace') do |namespace|
47
- args.namespace = namespace
48
+ opts.on('--connect-timeout=TIMEOUT', Float, 'connect timeout in seconds (default 5)') do |t|
49
+ args.connect_timeout = t
48
50
  end
49
51
 
50
- opts.on('--id=ID', 'Request ID (will be generated randomly by default)') do |id|
51
- args.id = id
52
+ opts.on('--read-timeout=TIMEOUT', Float, 'read timeout in seconds (default 5)') do |t|
53
+ args.read_timeout = t
52
54
  end
53
55
 
54
- opts.on('--timeout=TIMEOUT', 'timeout for socket') do |timeout|
55
- args.timeout = timeout
56
+ opts.on('--write-timeout=TIMEOUT', Float, 'write timeout in seconds (default 60)') do |t|
57
+ args.write_timeout = t
56
58
  end
57
59
 
58
60
  opts.on('-d', '--debug', 'Debug output') do
@@ -71,10 +73,9 @@ class Parser
71
73
  end
72
74
 
73
75
  opt_parser.parse!(argv)
74
- args.method = argv.first
75
- args.params = argv[1..-1]
76
- # puts "PARSED:\n#{args.inspect}\n#{argv.inspect}"
77
- return args
76
+ args.rpc_method = argv.first
77
+ args.params = argv[1..]
78
+ args
78
79
  end
79
80
  end
80
81
 
@@ -84,16 +85,26 @@ logger = Logger.new($stdout)
84
85
  logger.level = options.debug ? Logger::DEBUG : Logger::INFO
85
86
  addr = "#{options.host}:#{options.port}"
86
87
  logger.debug { "Connecting to #{addr} ..." }
87
- client = JRPC::TcpClient.new(addr, namespace: options.namespace, timeout: options.timeout, logger: logger)
88
88
 
89
- logger.debug { "Sending #{options.type} #{options.method} #{options.params} ..." }
90
- response = client.perform_request(options.method, params: options.params, type: options.type.to_sym)
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} ..." }
91
100
 
92
101
  if options.type == 'request'
102
+ response = client.request(options.rpc_method, params)
93
103
  logger.debug { "Request was sent. Response: #{response.inspect}" }
94
104
  puts JSON.pretty_generate(response)
95
105
  else
96
- logger.debug 'Notification was sent.'
106
+ client.notification(options.rpc_method, params)
107
+ logger.debug { 'Notification was sent.' }
97
108
  end
98
109
 
99
110
  client.close
data/bin/jrpc-shell CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env ruby
2
- # coding: utf-8
2
+ # frozen_string_literal: true
3
3
 
4
4
  require 'readline'
5
5
  require 'singleton'
@@ -20,18 +20,19 @@ class Command
20
20
  end
21
21
 
22
22
  attr_accessor :logger, :client, :help_usage
23
- instance.logger = Logger.new(STDOUT)
23
+
24
+ instance.logger = Logger.new($stdout)
24
25
  instance.logger.level = Logger::INFO
25
26
  instance.help_usage = [
26
- 'Usage:',
27
- ' connect host port',
28
- ' disconnect',
29
- ' request method param1 param2',
30
- ' request method {"param1": 1. "param2": 2}',
31
- ' notification method param1 param2',
32
- ' notification method {"param1": 1. "param2": 2}',
33
- ' help',
34
- ' version'
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'
35
36
  ].join("\n")
36
37
 
37
38
  def cmd_help
@@ -44,9 +45,15 @@ class Command
44
45
 
45
46
  def cmd_connect(host, port)
46
47
  client&.close
47
- self.client = JRPC::TcpClient.new("#{host}:#{port}", namespace: '', timeout: 5, logger: logger)
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
+ )
48
55
  'Connected.'
49
- rescue JRPC::Error => e
56
+ rescue JRPC::Errors::Error => e
50
57
  "ERROR: JRPC #{e.message}\n#{help_usage}"
51
58
  end
52
59
 
@@ -61,37 +68,40 @@ class Command
61
68
  def cmd_request(method, *params)
62
69
  return "ERROR: Not connected\n#{help_usage}" if client.nil?
63
70
 
64
- params = JSON.parse(params.first) if params.size == 1 && params[0] == '{'
71
+ params = JSON.parse(params.first) if params.size == 1 && params[0].start_with?('{')
72
+ params = nil if params.empty?
65
73
 
66
- response = client.perform_request(method, params: params)
74
+ response = client.request(method, params)
67
75
  JSON.pretty_generate(response)
68
- rescue JRPC::Error => e
76
+ rescue JRPC::Errors::Error => e
69
77
  "ERROR: JRPC #{e.message}\n#{help_usage}"
70
78
  end
71
79
 
72
80
  def cmd_notification(method, *params)
73
81
  return "ERROR: Not connected\n#{help_usage}" if client.nil?
74
82
 
75
- params = JSON.parse(params.first) if params.size == 1 && params[0] == '{'
83
+ params = JSON.parse(params.first) if params.size == 1 && params[0].start_with?('{')
84
+ params = nil if params.empty?
76
85
 
77
- response = client.perform_request(method, params: params, type: :notification)
78
- JSON.pretty_generate(response)
79
- rescue JRPC::Error => e
86
+ client.notification(method, params)
87
+ 'Notification sent.'
88
+ rescue JRPC::Errors::Error => e
80
89
  "ERROR: JRPC #{e.message}\n#{help_usage}"
81
90
  end
82
91
  end
83
92
 
93
+ EXIT_COMMANDS = %w[exit close quit].freeze
94
+
84
95
  puts 'Welcome to JRPC shell'
85
- while input = Readline.readline('> ', true)
86
- if %w[exit close quit].include?(input)
96
+ while (input = Readline.readline('> ', true))
97
+ if EXIT_COMMANDS.include?(input)
87
98
  break
88
99
  elsif input == 'hist'
89
100
  puts Readline::HISTORY.to_a
90
101
  elsif input == ''
91
- # Remove blank lines from history
92
102
  Readline::HISTORY.pop
93
103
  else
94
- command, *args = input.split(' ')
104
+ command, *args = input.split
95
105
  puts Command.call(command, args)
96
106
  end
97
107
  end
data/jrpc.gemspec CHANGED
@@ -1,5 +1,6 @@
1
- # coding: utf-8
2
- lib = File.expand_path('../lib', __FILE__)
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
 
@@ -16,14 +17,11 @@ Gem::Specification.new do |spec|
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 'netstring', '~> 0'
21
- spec.add_dependency 'oj', '~> 3.0'
22
+ spec.add_dependency 'concurrent-ruby', '~> 1.2'
23
+ spec.add_dependency 'logger'
22
24
 
23
25
  spec.executables << 'jrpc'
24
26
  spec.executables << 'jrpc-shell'
25
-
26
- spec.add_development_dependency 'bundler'
27
- spec.add_development_dependency 'rake', '~> 13.0'
28
- spec.add_development_dependency 'rspec', '~> 3.0'
29
27
  end
@@ -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
@@ -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,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JRPC
4
+ # Debug-level wire-payload logging shared by the clients. When a `logger` is
5
+ # configured, every request/response payload (the raw JSON netstring body,
6
+ # exactly as written/read) is emitted at DEBUG. Without a logger it is a no-op.
7
+ module PayloadLogging
8
+ SEND_MARK = '>>'
9
+ RECV_MARK = '<<'
10
+
11
+ def log_sent(payload)
12
+ @logger&.debug("[#{log_tag}] #{SEND_MARK} #{payload}")
13
+ end
14
+
15
+ def log_received(payload)
16
+ @logger&.debug("[#{log_tag}] #{RECV_MARK} #{payload}")
17
+ end
18
+ end
19
+ 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