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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +55 -0
  3. data/.gitignore +1 -0
  4. data/.rspec +1 -0
  5. data/.rubocop.yml +228 -0
  6. data/CHANGELOG.md +84 -0
  7. data/Gemfile +17 -0
  8. data/README.md +163 -13
  9. data/Rakefile +3 -1
  10. data/bin/console +15 -0
  11. data/bin/jrpc +111 -0
  12. data/bin/jrpc-shell +109 -0
  13. data/bin/setup +8 -0
  14. data/jrpc.gemspec +9 -8
  15. data/lib/jrpc/errors.rb +65 -0
  16. data/lib/jrpc/id_generator.rb +22 -0
  17. data/lib/jrpc/message.rb +78 -0
  18. data/lib/jrpc/shared_client/outbound_queue.rb +71 -0
  19. data/lib/jrpc/shared_client/registry.rb +46 -0
  20. data/lib/jrpc/shared_client/ticket.rb +84 -0
  21. data/lib/jrpc/shared_client/transport_loop.rb +290 -0
  22. data/lib/jrpc/shared_client.rb +194 -0
  23. data/lib/jrpc/simple_client.rb +89 -0
  24. data/lib/jrpc/transport/base.rb +60 -0
  25. data/lib/jrpc/transport/tcp.rb +243 -0
  26. data/lib/jrpc/transport.rb +12 -0
  27. data/lib/jrpc/version.rb +3 -1
  28. data/lib/jrpc.rb +15 -16
  29. metadata +35 -76
  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 -98
  46. 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
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
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
 
@@ -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/senid231/jrpc'
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 '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
- spec.add_development_dependency 'bundler', '~> 1.10'
24
- spec.add_development_dependency 'rake', '~> 10.0'
25
- spec.add_development_dependency 'rspec', '~> 3.0'
25
+ spec.executables << 'jrpc'
26
+ spec.executables << 'jrpc-shell'
26
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,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