ruby-asterisk 0.1.0 → 1.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 (78) hide show
  1. checksums.yaml +5 -5
  2. data/.claude/skills/ruby-asterisk-agi/SKILL.md +317 -0
  3. data/.claude/skills/ruby-asterisk-ami/SKILL.md +187 -0
  4. data/.claude/skills/ruby-asterisk-ari/SKILL.md +174 -0
  5. data/.claude/skills/ruby-asterisk-ari-websocket/SKILL.md +148 -0
  6. data/.github/workflows/ci.yml +79 -0
  7. data/.gitignore +50 -3
  8. data/.rubocop.yml +84 -0
  9. data/CHANGELOG.md +52 -0
  10. data/Gemfile +3 -1
  11. data/LICENCE +21 -0
  12. data/README.md +454 -70
  13. data/Rakefile +3 -1
  14. data/lib/ruby-asterisk/agi/protocol.rb +160 -0
  15. data/lib/ruby-asterisk/agi/server.rb +108 -0
  16. data/lib/ruby-asterisk/agi/session.rb +187 -0
  17. data/lib/ruby-asterisk/ami/client.rb +135 -0
  18. data/lib/ruby-asterisk/ami/commands/channel.rb +55 -0
  19. data/lib/ruby-asterisk/ami/commands/conference.rb +37 -0
  20. data/lib/ruby-asterisk/ami/commands/extension.rb +17 -0
  21. data/lib/ruby-asterisk/ami/commands/mailbox.rb +21 -0
  22. data/lib/ruby-asterisk/ami/commands/monitor.rb +33 -0
  23. data/lib/ruby-asterisk/ami/commands/queue.rb +39 -0
  24. data/lib/ruby-asterisk/ami/commands/sip.rb +25 -0
  25. data/lib/ruby-asterisk/ami/commands/system.rb +50 -0
  26. data/lib/ruby-asterisk/ami/event.rb +22 -0
  27. data/lib/ruby-asterisk/ami/event_list_aggregation.rb +82 -0
  28. data/lib/ruby-asterisk/ami/parser.rb +60 -0
  29. data/lib/ruby-asterisk/ami/promise.rb +108 -0
  30. data/lib/ruby-asterisk/ami/reactor.rb +162 -0
  31. data/lib/ruby-asterisk/ari/client.rb +94 -0
  32. data/lib/ruby-asterisk/ari/resources/base.rb +23 -0
  33. data/lib/ruby-asterisk/ari/resources/bridge.rb +22 -0
  34. data/lib/ruby-asterisk/ari/resources/channel.rb +26 -0
  35. data/lib/ruby-asterisk/ari/resources/collection.rb +27 -0
  36. data/lib/ruby-asterisk/ari/resources/endpoint.rb +22 -0
  37. data/lib/ruby-asterisk/ari/resources/playback.rb +18 -0
  38. data/lib/ruby-asterisk/ari/websocket/connection.rb +143 -0
  39. data/lib/ruby-asterisk/ari/websocket/event_handlers.rb +80 -0
  40. data/lib/ruby-asterisk/ari/websocket/heartbeat.rb +65 -0
  41. data/lib/ruby-asterisk/ari/websocket/reconnect.rb +54 -0
  42. data/lib/ruby-asterisk/ari/websocket/socket_adapter.rb +23 -0
  43. data/lib/ruby-asterisk/ari/websocket.rb +155 -0
  44. data/lib/ruby-asterisk/compat.rb +7 -0
  45. data/lib/ruby-asterisk/error.rb +10 -0
  46. data/lib/ruby-asterisk/parsing_constants.rb +100 -0
  47. data/lib/ruby-asterisk/request.rb +38 -20
  48. data/lib/ruby-asterisk/response.rb +50 -137
  49. data/lib/ruby-asterisk/response_builder.rb +18 -0
  50. data/lib/ruby-asterisk/response_parser.rb +43 -0
  51. data/lib/ruby-asterisk/version.rb +4 -2
  52. data/lib/ruby-asterisk.rb +5 -147
  53. data/ruby-asterisk.gemspec +26 -17
  54. data/spec/ruby-asterisk/agi/protocol_spec.rb +239 -0
  55. data/spec/ruby-asterisk/agi/server_spec.rb +199 -0
  56. data/spec/ruby-asterisk/agi/session_spec.rb +293 -0
  57. data/spec/ruby-asterisk/ami/async_client_spec.rb +256 -0
  58. data/spec/ruby-asterisk/ami/multi_event_spec.rb +57 -0
  59. data/spec/ruby-asterisk/ami/parser_spec.rb +190 -0
  60. data/spec/ruby-asterisk/ami/reactor_spec.rb +290 -0
  61. data/spec/ruby-asterisk/ami_client_spec.rb +68 -0
  62. data/spec/ruby-asterisk/ari/client_spec.rb +168 -0
  63. data/spec/ruby-asterisk/ari/resources/bridge_spec.rb +42 -0
  64. data/spec/ruby-asterisk/ari/resources/channel_spec.rb +57 -0
  65. data/spec/ruby-asterisk/ari/resources/collection_spec.rb +66 -0
  66. data/spec/ruby-asterisk/ari/resources/endpoint_spec.rb +30 -0
  67. data/spec/ruby-asterisk/ari/resources/playback_spec.rb +33 -0
  68. data/spec/ruby-asterisk/ari/websocket_integration_spec.rb +86 -0
  69. data/spec/ruby-asterisk/ari/websocket_spec.rb +374 -0
  70. data/spec/ruby-asterisk/immutability_spec.rb +148 -0
  71. data/spec/ruby-asterisk/request_spec.rb +29 -9
  72. data/spec/ruby-asterisk/response_spec.rb +147 -148
  73. data/spec/spec_helper.rb +14 -0
  74. data/spec/support/mock_ami_server.rb +50 -0
  75. data/spec/support/mock_ari_websocket_server.rb +114 -0
  76. metadata +177 -21
  77. data/CHANGELOG +0 -3
  78. data/spec/ruby-asterisk/ruby_asterisk_spec.rb +0 -150
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyAsterisk
4
+ module AMI
5
+ module Commands
6
+ ##
7
+ #
8
+ # Mailbox commands
9
+ #
10
+ module Mailbox
11
+ def mailbox_status(mailbox:, context: 'default')
12
+ execute 'MailboxStatus', { 'Mailbox' => "#{mailbox}@#{context}" }
13
+ end
14
+
15
+ def mailbox_count(mailbox:, context: 'default')
16
+ execute 'MailboxCount', { 'Mailbox' => "#{mailbox}@#{context}" }
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyAsterisk
4
+ module AMI
5
+ module Commands
6
+ ##
7
+ #
8
+ # Monitor commands
9
+ #
10
+ module Monitor
11
+ def monitor(channel, mix: false, file: nil, format: 'wav')
12
+ execute 'Monitor', { 'Channel' => channel, 'File' => file, 'Mix' => mix, 'Format' => format }
13
+ end
14
+
15
+ def stop_monitor(channel)
16
+ execute 'StopMonitor', { 'Channel' => channel }
17
+ end
18
+
19
+ def pause_monitor(channel)
20
+ execute 'PauseMonitor', { 'Channel' => channel }
21
+ end
22
+
23
+ def unpause_monitor(channel)
24
+ execute 'UnpauseMonitor', { 'Channel' => channel }
25
+ end
26
+
27
+ def change_monitor(channel:, file:)
28
+ execute 'ChangeMonitor', { 'Channel' => channel, 'File' => file }
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyAsterisk
4
+ module AMI
5
+ module Commands
6
+ ##
7
+ #
8
+ # Queue commands
9
+ #
10
+ module Queue
11
+ def queues
12
+ execute 'Queues', {}
13
+ end
14
+
15
+ def queue_add(queue, interface, penalty: 2, paused: false, member_name: '')
16
+ execute 'QueueAdd',
17
+ { 'Queue' => queue, 'Interface' => interface, 'Penalty' => penalty, 'Paused' => paused,
18
+ 'MemberName' => member_name }
19
+ end
20
+
21
+ def queue_remove(queue:, interface:)
22
+ execute 'QueueRemove', { 'Queue' => queue, 'Interface' => interface }
23
+ end
24
+
25
+ def queue_status
26
+ execute 'QueueStatus'
27
+ end
28
+
29
+ def queue_summary(queue)
30
+ execute 'QueueSummary', { 'Queue' => queue }
31
+ end
32
+
33
+ def queue_pause(interface:, paused:, queue:, reason: 'none')
34
+ execute 'QueuePause', { 'Interface' => interface, 'Paused' => paused, 'Queue' => queue, 'Reason' => reason }
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyAsterisk
4
+ module AMI
5
+ module Commands
6
+ ##
7
+ #
8
+ # Sip commands
9
+ #
10
+ module Sip
11
+ def sip_peers
12
+ execute 'SIPpeers'
13
+ end
14
+
15
+ def sip_show_peer(peer)
16
+ execute 'SIPshowpeer', { 'Peer' => peer }
17
+ end
18
+
19
+ def sip_show_registry
20
+ execute 'SIPshowregistry'
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyAsterisk
4
+ module AMI
5
+ module Commands
6
+ ##
7
+ #
8
+ # System commands
9
+ #
10
+ module System
11
+ def parked_calls
12
+ execute 'ParkedCalls'
13
+ end
14
+
15
+ def device_state_list
16
+ execute 'DeviceStateList'
17
+ end
18
+
19
+ def skinny_devices
20
+ execute 'SKINNYdevices'
21
+ end
22
+
23
+ def skinny_lines
24
+ execute 'SKINNYlines'
25
+ end
26
+
27
+ def ping
28
+ execute 'Ping'
29
+ end
30
+
31
+ def event_mask(event_mask = 'off')
32
+ execute 'Events', { 'EventMask' => event_mask }
33
+ end
34
+
35
+ # A negative Timeout tells Asterisk to wait indefinitely, so the Promise
36
+ # must not impose a deadline either: #value then blocks until an event
37
+ # arrives (pass an explicit `value(seconds)` to bound the wait).
38
+ def wait_event(timeout: -1)
39
+ seconds = timeout.to_i
40
+ execute 'WaitEvent', { 'Timeout' => timeout },
41
+ timeout: (seconds.negative? ? nil : [@timeout, seconds].max)
42
+ end
43
+
44
+ def command(command)
45
+ execute 'Command', { 'Command' => command }
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyAsterisk
4
+ module AMI
5
+ ##
6
+ # Immutable value object representing a parsed AMI event.
7
+ # All attributes are frozen for thread-safe sharing.
8
+ #
9
+ class Event
10
+ attr_reader :name, :headers, :raw
11
+
12
+ # @param headers [Hash] frozen hash of parsed AMI headers (Key => Value strings)
13
+ # @param raw [String] frozen raw message string including the trailing \r\n\r\n
14
+ def initialize(headers, raw)
15
+ @name = headers['Event'].freeze
16
+ @headers = headers
17
+ @raw = raw
18
+ freeze
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyAsterisk
4
+ module AMI
5
+ # Frame-dispatch and EventList aggregation logic for {Reactor}.
6
+ #
7
+ # AMI list actions reply with an ack frame (Response: Success / EventList:
8
+ # start), one Event frame per item, then a terminating *Complete event —
9
+ # each a separate \r\n\r\n frame carrying the same ActionID. These methods
10
+ # buffer the follow-up events by ActionID and resolve the promise only once
11
+ # the Complete event arrives, so the full frame set reaches ResponseParser.
12
+ #
13
+ # Expects the includer to provide @promises, @promises_mutex, @buffers,
14
+ # @on_event and #resolve_promise.
15
+ module EventListAggregation
16
+ private
17
+
18
+ def dispatch(msg)
19
+ case msg[:type]
20
+ when :response
21
+ handle_response(msg[:action_id], msg[:headers], msg[:raw])
22
+ when :event
23
+ handle_event(msg[:event])
24
+ end
25
+ end
26
+
27
+ # A response frame either completes a single-reply command immediately or,
28
+ # when it opens an EventList, begins buffering the follow-up Event frames.
29
+ def handle_response(action_id, headers, raw)
30
+ if list_start?(headers)
31
+ start_buffer(action_id, raw)
32
+ else
33
+ resolve_promise(action_id, raw)
34
+ end
35
+ end
36
+
37
+ # Event frames carrying the ActionID of a buffering command are appended to
38
+ # that command's reply; the terminating Complete event resolves the promise
39
+ # with the full concatenated frame set. All other events go to on_event.
40
+ def handle_event(event)
41
+ action_id = event.headers['ActionID']
42
+ result = append_to_buffer(action_id, event.raw)
43
+ case result
44
+ when :buffered
45
+ nil # keep accumulating until the Complete event
46
+ when nil
47
+ @on_event&.call(event)
48
+ else # completed: joined raw frames
49
+ resolve_promise(action_id, result)
50
+ end
51
+ end
52
+
53
+ def start_buffer(action_id, raw)
54
+ @promises_mutex.synchronize do
55
+ @buffers[action_id] = [raw] if @promises.key?(action_id)
56
+ end
57
+ end
58
+
59
+ # Returns :buffered while accumulating, the joined raw when the list is
60
+ # complete, or nil when action_id has no active buffer.
61
+ def append_to_buffer(action_id, raw)
62
+ @promises_mutex.synchronize do
63
+ buffer = @buffers[action_id]
64
+ return nil unless buffer
65
+
66
+ buffer << raw
67
+ return :buffered unless list_terminator?(raw)
68
+
69
+ @buffers.delete(action_id).join
70
+ end
71
+ end
72
+
73
+ def list_start?(headers)
74
+ headers && headers['EventList'].to_s.casecmp?('start')
75
+ end
76
+
77
+ def list_terminator?(raw)
78
+ raw.match?(/^EventList:\s*Complete/i) || raw.match?(/^Event:.*Complete\s*$/)
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ruby-asterisk/ami/event'
4
+
5
+ module RubyAsterisk
6
+ module AMI
7
+ # Stateless module for parsing raw AMI byte streams into structured messages.
8
+ #
9
+ # All methods are class-level and side-effect free; they can be called from
10
+ # any thread or Fiber without synchronization.
11
+ module Parser
12
+ DELIMITER = "\r\n\r\n"
13
+ DELIMITER_LEN = DELIMITER.length
14
+
15
+ # Parses "Key: Value\r\n..." lines into a frozen hash.
16
+ # Lines without a colon separator (e.g. the AMI welcome banner) are skipped.
17
+ def self.parse_headers(raw)
18
+ headers = {}
19
+ raw.split(/\r?\n/).each do |line|
20
+ next if line.empty?
21
+
22
+ colon = line.index(':')
23
+ next unless colon&.positive?
24
+
25
+ headers[line[0, colon].freeze] = line[(colon + 1)..].strip.freeze
26
+ end
27
+ headers.freeze
28
+ end
29
+
30
+ # Extracts all complete AMI messages from +buffer+ (mutates it) and
31
+ # yields a frozen message hash for each one.
32
+ #
33
+ # @param buffer [String] mutable accumulation buffer
34
+ # @yieldparam msg [Hash] frozen: { type: :response|:event, headers:, raw:, action_id: }
35
+ def self.drain(buffer)
36
+ while (idx = buffer.index(DELIMITER))
37
+ raw = buffer.slice!(0, idx + DELIMITER_LEN)
38
+ msg = build_message(raw)
39
+ yield msg if msg
40
+ end
41
+ end
42
+
43
+ # Builds a single frozen message hash from a raw AMI frame, or nil if the
44
+ # frame carries no recognised Response/Event header.
45
+ def self.build_message(raw)
46
+ headers = parse_headers(raw)
47
+ return nil if headers.empty?
48
+
49
+ frozen_raw = raw.freeze
50
+ if headers.key?('Response')
51
+ { type: :response, headers: headers, raw: frozen_raw,
52
+ action_id: headers['ActionID'] }.freeze
53
+ elsif headers.key?('Event')
54
+ { type: :event,
55
+ event: RubyAsterisk::AMI::Event.new(headers, frozen_raw) }.freeze
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'timeout'
4
+ require 'ruby-asterisk/response_builder'
5
+
6
+ module RubyAsterisk
7
+ module AMI
8
+ ##
9
+ # Thread-safe promise representing a pending AMI command response.
10
+ #
11
+ # Created by {Client#execute} and resolved by the event-loop thread
12
+ # when the matching ActionID response arrives from Asterisk.
13
+ #
14
+ # Usage (blocking):
15
+ # promise = client.ping
16
+ # response = promise.value(5) # blocks up to 5 s, returns a Response
17
+ #
18
+ # Usage (async):
19
+ # promise = client.ping # returns immediately
20
+ # # ... do other work ...
21
+ # response = promise.value # blocks until resolved or timeout
22
+ #
23
+ class Promise
24
+ attr_reader :action_id
25
+
26
+ # Optional callback invoked when {#value} gives up on a timeout, used by
27
+ # the Reactor to drop the abandoned entry from its pending map.
28
+ attr_accessor :on_timeout
29
+
30
+ # @param action_id [String] the AMI ActionID this promise tracks
31
+ # @param command_type [String] the AMI action name (e.g. 'Ping')
32
+ # @param timeout [Numeric, nil] default seconds to wait in #value;
33
+ # nil waits indefinitely (used by WaitEvent with a negative Timeout)
34
+ def initialize(action_id:, command_type:, timeout: 5)
35
+ @action_id = action_id
36
+ @command_type = command_type
37
+ @timeout = timeout
38
+ @mutex = Mutex.new
39
+ @cv = ConditionVariable.new
40
+ @raw = nil
41
+ @error = nil
42
+ @resolved = false
43
+ @on_timeout = nil
44
+ end
45
+
46
+ # Resolve the promise with raw AMI response data.
47
+ # Called by the Client event-loop thread — safe to call from any thread.
48
+ #
49
+ # @param raw [String] the raw AMI response string
50
+ def resolve(raw)
51
+ @mutex.synchronize do
52
+ @raw = raw
53
+ @resolved = true
54
+ @cv.broadcast
55
+ end
56
+ end
57
+
58
+ # Reject the promise with an error.
59
+ # Called when the connection drops or the client disconnects.
60
+ #
61
+ # @param error [Exception]
62
+ def reject(error)
63
+ @mutex.synchronize do
64
+ @error = error
65
+ @resolved = true
66
+ @cv.broadcast
67
+ end
68
+ end
69
+
70
+ # Block until the response arrives and return it as a {Response}.
71
+ #
72
+ # @param timeout [Numeric, nil] seconds to wait (defaults to the value set
73
+ # at construction); nil blocks until the response arrives
74
+ # @return [RubyAsterisk::Response]
75
+ # @raise [Timeout::Error] if no response arrives within +timeout+ seconds
76
+ # @raise [RuntimeError] if the promise was rejected (e.g. disconnect)
77
+ def value(timeout = @timeout)
78
+ @mutex.synchronize do
79
+ unless @resolved
80
+ @cv.wait(@mutex, timeout)
81
+ unless @resolved
82
+ @on_timeout&.call
83
+ raise Timeout::Error,
84
+ "Timeout waiting for AMI response (ActionID: #{@action_id})"
85
+ end
86
+ end
87
+ raise @error if @error
88
+
89
+ build_response
90
+ end
91
+ end
92
+
93
+ # @return [Boolean] true if the promise has been resolved or rejected
94
+ def resolved?
95
+ @mutex.synchronize { @resolved }
96
+ end
97
+
98
+ private
99
+
100
+ def build_response
101
+ ResponseBuilder.new.tap do |builder|
102
+ builder.type = @command_type
103
+ builder.raw_response = @raw
104
+ end.build
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'socket'
4
+ require 'ruby-asterisk/ami/parser'
5
+ require 'ruby-asterisk/ami/event_list_aggregation'
6
+
7
+ module RubyAsterisk
8
+ module AMI
9
+ # Hosts the AMI connection in two plain OS threads:
10
+ #
11
+ # writer_thread — blocks on Thread::Queue#pop and writes commands to the
12
+ # AMI socket. On :stop it closes the socket, which unblocks
13
+ # the reader thread's readpartial call.
14
+ #
15
+ # reader_thread — loops on socket.readpartial, parses AMI frames with Parser,
16
+ # resolves pending Promises or fires the on_event callback.
17
+ #
18
+ # Using plain Threads (no Fiber scheduler) ensures deterministic shutdown on
19
+ # all Ruby versions: closing an IO from one Thread immediately raises IOError
20
+ # in any other Thread blocked on that IO.
21
+ #
22
+ # Thread-safety contract:
23
+ # - External callers use #send_command, #register_promise,
24
+ # #reject_all_promises, #stop — all thread-safe.
25
+ # - Promise resolution via Mutex+CV (Promise class unchanged).
26
+ # - No cross-thread IO inside the reactor; one thread reads, one writes.
27
+ class Reactor
28
+ include EventListAggregation
29
+
30
+ def initialize(host, port, on_event: nil, on_disconnect: nil)
31
+ @host = host
32
+ @port = port
33
+ @on_event = on_event
34
+ @on_disconnect = on_disconnect
35
+
36
+ @command_queue = Thread::Queue.new
37
+ @promises_mutex = Mutex.new
38
+ @promises = {}
39
+ @buffers = {} # action_id => Array<raw frame> for in-flight EventList replies
40
+ @socket = nil
41
+ @writer_thread = nil
42
+ @reader_thread = nil
43
+ @stopping = false
44
+ end
45
+
46
+ # Open the socket, consume the AMI banner, and start both threads.
47
+ # Raises if the connection fails.
48
+ def start
49
+ @socket = TCPSocket.new(@host, @port)
50
+ @socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_KEEPALIVE, true)
51
+ @socket.gets # consume AMI banner ("Asterisk Call Manager/x.y\n")
52
+
53
+ @writer_thread = Thread.new { writer_loop }
54
+ @reader_thread = Thread.new { reader_loop }
55
+ self
56
+ rescue StandardError
57
+ close_socket
58
+ raise
59
+ end
60
+
61
+ # Send a raw AMI command string from any external thread.
62
+ def send_command(cmd)
63
+ @command_queue.push(cmd.freeze)
64
+ end
65
+
66
+ # Register a Promise keyed by ActionID.
67
+ def register_promise(action_id, promise)
68
+ @promises_mutex.synchronize { @promises[action_id] = promise }
69
+ end
70
+
71
+ # Remove a pending Promise without resolving it (e.g. after a caller
72
+ # timeout) so the pending map does not grow unbounded.
73
+ def unregister_promise(action_id)
74
+ @promises_mutex.synchronize do
75
+ @promises.delete(action_id)
76
+ @buffers.delete(action_id)
77
+ end
78
+ end
79
+
80
+ # Reject all pending promises with the given error (thread-safe).
81
+ def reject_all_promises(error)
82
+ promises = @promises_mutex.synchronize do
83
+ @buffers.clear
84
+ @promises.values.tap { @promises.clear }
85
+ end
86
+ promises.each { |p| p.reject(error) }
87
+ end
88
+
89
+ # Stop both threads gracefully. Blocks until they exit.
90
+ def stop
91
+ return unless @writer_thread&.alive? || @reader_thread&.alive?
92
+
93
+ @stopping = true
94
+ @command_queue.push(:stop) # wakes writer_thread immediately
95
+ @writer_thread&.join(2)
96
+ @reader_thread&.join(2)
97
+ @writer_thread = nil
98
+ @reader_thread = nil
99
+ end
100
+
101
+ private
102
+
103
+ # Both loops bind the socket to a local before their first IO call:
104
+ # #close_socket nils out @socket, so reading the ivar inside the loop could
105
+ # raise NoMethodError on nil instead of the IOError the rescues expect.
106
+ # A closed IO object still raises IOError, which is the intended path.
107
+ def writer_loop
108
+ socket = @socket
109
+ loop do
110
+ cmd = @command_queue.pop
111
+ break if cmd == :stop
112
+
113
+ socket.write(cmd)
114
+ rescue IOError, SystemCallError
115
+ break # socket closed or peer reset — reader_loop handles caller notification
116
+ end
117
+ ensure
118
+ close_socket # raises IOError in reader_thread's readpartial
119
+ end
120
+
121
+ def reader_loop
122
+ socket = @socket
123
+ buffer = +''
124
+ loop do
125
+ chunk = socket.readpartial(4096)
126
+ buffer << chunk
127
+ Parser.drain(buffer) { |msg| dispatch(msg) }
128
+ end
129
+ rescue EOFError
130
+ handle_unexpected_disconnect('Disconnected: EOF from server') unless @stopping
131
+ rescue IOError, Errno::EBADF
132
+ handle_unexpected_disconnect('Disconnected') unless @stopping
133
+ rescue StandardError => e
134
+ # A deliberate #stop must never surface as a disconnect: the caller is
135
+ # notified by Client#disconnect, which rejects with its own message.
136
+ handle_unexpected_disconnect("Reactor read error: #{e.message}") unless @stopping
137
+ end
138
+
139
+ # Notify the client and fail every blocked caller when the link drops
140
+ # without an explicit #stop request.
141
+ def handle_unexpected_disconnect(message)
142
+ @on_disconnect&.call
143
+ reject_all_promises(RuntimeError.new(message))
144
+ end
145
+
146
+ def resolve_promise(action_id, raw_data)
147
+ promise = @promises_mutex.synchronize do
148
+ @buffers.delete(action_id)
149
+ @promises.delete(action_id)
150
+ end
151
+ promise&.resolve(raw_data)
152
+ end
153
+
154
+ def close_socket
155
+ @socket&.close
156
+ @socket = nil
157
+ rescue StandardError
158
+ nil
159
+ end
160
+ end
161
+ end
162
+ end