omq 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.
Files changed (43) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +30 -0
  3. data/LICENSE +15 -0
  4. data/README.md +145 -0
  5. data/lib/omq/pair.rb +13 -0
  6. data/lib/omq/pub_sub.rb +77 -0
  7. data/lib/omq/push_pull.rb +21 -0
  8. data/lib/omq/req_rep.rb +23 -0
  9. data/lib/omq/router_dealer.rb +36 -0
  10. data/lib/omq/socket.rb +178 -0
  11. data/lib/omq/version.rb +5 -0
  12. data/lib/omq/zmtp/codec/command.rb +207 -0
  13. data/lib/omq/zmtp/codec/frame.rb +104 -0
  14. data/lib/omq/zmtp/codec/greeting.rb +96 -0
  15. data/lib/omq/zmtp/codec.rb +18 -0
  16. data/lib/omq/zmtp/connection.rb +233 -0
  17. data/lib/omq/zmtp/engine.rb +339 -0
  18. data/lib/omq/zmtp/mechanism/null.rb +70 -0
  19. data/lib/omq/zmtp/options.rb +57 -0
  20. data/lib/omq/zmtp/reactor.rb +142 -0
  21. data/lib/omq/zmtp/readable.rb +29 -0
  22. data/lib/omq/zmtp/routing/dealer.rb +57 -0
  23. data/lib/omq/zmtp/routing/fan_out.rb +89 -0
  24. data/lib/omq/zmtp/routing/pair.rb +68 -0
  25. data/lib/omq/zmtp/routing/pub.rb +62 -0
  26. data/lib/omq/zmtp/routing/pull.rb +48 -0
  27. data/lib/omq/zmtp/routing/push.rb +57 -0
  28. data/lib/omq/zmtp/routing/rep.rb +83 -0
  29. data/lib/omq/zmtp/routing/req.rb +70 -0
  30. data/lib/omq/zmtp/routing/round_robin.rb +69 -0
  31. data/lib/omq/zmtp/routing/router.rb +88 -0
  32. data/lib/omq/zmtp/routing/sub.rb +80 -0
  33. data/lib/omq/zmtp/routing/xpub.rb +74 -0
  34. data/lib/omq/zmtp/routing/xsub.rb +80 -0
  35. data/lib/omq/zmtp/routing.rb +38 -0
  36. data/lib/omq/zmtp/transport/inproc.rb +299 -0
  37. data/lib/omq/zmtp/transport/ipc.rb +114 -0
  38. data/lib/omq/zmtp/transport/tcp.rb +98 -0
  39. data/lib/omq/zmtp/valid_peers.rb +21 -0
  40. data/lib/omq/zmtp/writable.rb +44 -0
  41. data/lib/omq/zmtp.rb +47 -0
  42. data/lib/omq.rb +19 -0
  43. metadata +110 -0
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ module ZMTP
5
+ module Codec
6
+ # ZMTP command encode/decode.
7
+ #
8
+ # Command frame body format:
9
+ # 1 byte: command name length
10
+ # N bytes: command name
11
+ # remaining: command data
12
+ #
13
+ # READY command data = property list:
14
+ # 1 byte: property name length
15
+ # N bytes: property name
16
+ # 4 bytes: property value length (big-endian)
17
+ # N bytes: property value
18
+ #
19
+ class Command
20
+ # @return [String] command name (e.g. "READY", "SUBSCRIBE")
21
+ #
22
+ attr_reader :name
23
+
24
+ # @return [String] command data (binary)
25
+ #
26
+ attr_reader :data
27
+
28
+ # @param name [String] command name
29
+ # @param data [String] command data
30
+ #
31
+ def initialize(name, data = "".b)
32
+ @name = name
33
+ @data = data.b
34
+ end
35
+
36
+ # Encodes as a command frame body.
37
+ #
38
+ # @return [String] binary body (name-length + name + data)
39
+ #
40
+ def to_body
41
+ name_bytes = @name.b
42
+ buf = IO::Buffer.new(1 + name_bytes.bytesize + @data.bytesize)
43
+ buf.set_value(:U8, 0, name_bytes.bytesize)
44
+ buf.set_string(name_bytes, 1)
45
+ buf.set_string(@data, 1 + name_bytes.bytesize)
46
+ buf.get_string(0, buf.size, Encoding::BINARY)
47
+ end
48
+
49
+ # Encodes as a complete command Frame.
50
+ #
51
+ # @return [Frame]
52
+ #
53
+ def to_frame
54
+ Frame.new(to_body, command: true)
55
+ end
56
+
57
+ # Decodes a command from a frame body.
58
+ #
59
+ # @param body [String] binary frame body
60
+ # @return [Command]
61
+ # @raise [ProtocolError] on malformed command
62
+ #
63
+ def self.from_body(body)
64
+ body = body.b
65
+ raise ProtocolError, "command body too short" if body.bytesize < 1
66
+
67
+ buf = IO::Buffer.for(body)
68
+ name_len = buf.get_value(:U8, 0)
69
+
70
+ raise ProtocolError, "command name truncated" if body.bytesize < 1 + name_len
71
+
72
+ name = buf.get_string(1, name_len, Encoding::BINARY)
73
+ data = body.byteslice(1 + name_len..)
74
+ new(name, data)
75
+ end
76
+
77
+ # Builds a READY command with Socket-Type and Identity properties.
78
+ #
79
+ # @param socket_type [String] e.g. "REQ", "REP", "PAIR"
80
+ # @param identity [String] peer identity (can be empty)
81
+ # @return [Command]
82
+ #
83
+ def self.ready(socket_type:, identity: "")
84
+ props = encode_properties(
85
+ "Socket-Type" => socket_type,
86
+ "Identity" => identity,
87
+ )
88
+ new("READY", props)
89
+ end
90
+
91
+ # Builds a SUBSCRIBE command.
92
+ #
93
+ # @param prefix [String] subscription prefix
94
+ # @return [Command]
95
+ #
96
+ def self.subscribe(prefix)
97
+ new("SUBSCRIBE", prefix.b)
98
+ end
99
+
100
+ # Builds a CANCEL command (unsubscribe).
101
+ #
102
+ # @param prefix [String] subscription prefix to cancel
103
+ # @return [Command]
104
+ #
105
+ def self.cancel(prefix)
106
+ new("CANCEL", prefix.b)
107
+ end
108
+
109
+ # Builds a PING command.
110
+ #
111
+ # @param ttl [Numeric] time-to-live in seconds (sent as deciseconds)
112
+ # @param context [String] optional context bytes (up to 16 bytes)
113
+ # @return [Command]
114
+ #
115
+ def self.ping(ttl: 0, context: "".b)
116
+ # TTL is encoded as 2-byte big-endian value in tenths of a second
117
+ ttl_ds = (ttl * 10).to_i
118
+ buf = IO::Buffer.new(2 + context.bytesize)
119
+ buf.set_value(:U16, 0, ttl_ds)
120
+ buf.set_string(context.b, 2) if context.bytesize > 0
121
+ new("PING", buf.get_string(0, buf.size, Encoding::BINARY))
122
+ end
123
+
124
+ # Builds a PONG command.
125
+ #
126
+ # @param context [String] context bytes from the PING
127
+ # @return [Command]
128
+ #
129
+ def self.pong(context: "".b)
130
+ new("PONG", context.b)
131
+ end
132
+
133
+ # Extracts TTL (in seconds) and context from a PING command's data.
134
+ #
135
+ # @return [Array(Numeric, String)] [ttl_seconds, context_bytes]
136
+ #
137
+ def ping_ttl_and_context
138
+ buf = IO::Buffer.for(@data)
139
+ ttl_ds = buf.get_value(:U16, 0)
140
+ context = @data.bytesize > 2 ? @data.byteslice(2..) : "".b
141
+ [ttl_ds / 10.0, context]
142
+ end
143
+
144
+ # Parses READY command data as a property list.
145
+ #
146
+ # @return [Hash<String, String>] property name => value
147
+ # @raise [ProtocolError] on malformed properties
148
+ #
149
+ def properties
150
+ self.class.decode_properties(@data)
151
+ end
152
+
153
+ # Encodes a hash of properties into ZMTP property list format.
154
+ #
155
+ # @param props [Hash<String, String>]
156
+ # @return [String] binary property list
157
+ #
158
+ def self.encode_properties(props)
159
+ parts = props.map do |name, value|
160
+ name_bytes = name.b
161
+ value_bytes = value.b
162
+ buf = IO::Buffer.new(1 + name_bytes.bytesize + 4 + value_bytes.bytesize)
163
+ buf.set_value(:U8, 0, name_bytes.bytesize)
164
+ buf.set_string(name_bytes, 1)
165
+ buf.set_value(:U32, 1 + name_bytes.bytesize, value_bytes.bytesize) # big-endian
166
+ buf.set_string(value_bytes, 1 + name_bytes.bytesize + 4)
167
+ buf.get_string(0, buf.size, Encoding::BINARY)
168
+ end
169
+ parts.join
170
+ end
171
+ # Decodes a ZMTP property list from binary data.
172
+ #
173
+ # @param data [String] binary property list
174
+ # @return [Hash<String, String>] property name => value
175
+ # @raise [ProtocolError] on malformed properties
176
+ #
177
+ def self.decode_properties(data)
178
+ result = {}
179
+ buf = IO::Buffer.for(data)
180
+ offset = 0
181
+
182
+ while offset < data.bytesize
183
+ raise ProtocolError, "property name truncated" if offset + 1 > data.bytesize
184
+ name_len = buf.get_value(:U8, offset)
185
+ offset += 1
186
+
187
+ raise ProtocolError, "property name truncated" if offset + name_len > data.bytesize
188
+ name = buf.get_string(offset, name_len, Encoding::BINARY)
189
+ offset += name_len
190
+
191
+ raise ProtocolError, "property value length truncated" if offset + 4 > data.bytesize
192
+ value_len = buf.get_value(:U32, offset)
193
+ offset += 4
194
+
195
+ raise ProtocolError, "property value truncated" if offset + value_len > data.bytesize
196
+ value = buf.get_string(offset, value_len, Encoding::BINARY)
197
+ offset += value_len
198
+
199
+ result[name] = value
200
+ end
201
+
202
+ result
203
+ end
204
+ end
205
+ end
206
+ end
207
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ module ZMTP
5
+ module Codec
6
+ # ZMTP frame encode/decode.
7
+ #
8
+ # Wire format:
9
+ # Byte 0: flags (bit 0=MORE, bit 1=LONG, bit 2=COMMAND)
10
+ # Next 1-8: size (1-byte if short, 8-byte big-endian if LONG)
11
+ # Next N: body
12
+ #
13
+ class Frame
14
+ FLAGS_MORE = 0x01
15
+ FLAGS_LONG = 0x02
16
+ FLAGS_COMMAND = 0x04
17
+
18
+ # Short frame: 1-byte size, max body 255 bytes.
19
+ #
20
+ SHORT_MAX = 255
21
+
22
+ # @return [String] frame body (binary)
23
+ #
24
+ attr_reader :body
25
+
26
+ # @param body [String] frame body
27
+ # @param more [Boolean] more frames follow
28
+ # @param command [Boolean] this is a command frame
29
+ #
30
+ def initialize(body, more: false, command: false)
31
+ @body = body.b
32
+ @more = more
33
+ @command = command
34
+ end
35
+
36
+ # @return [Boolean] true if more frames follow in this message
37
+ #
38
+ def more? = @more
39
+
40
+ # @return [Boolean] true if this is a command frame
41
+ #
42
+ def command? = @command
43
+
44
+ # Encodes to wire bytes.
45
+ #
46
+ # @return [String] binary wire representation (flags + size + body)
47
+ #
48
+ def to_wire
49
+ size = @body.bytesize
50
+ flags = 0
51
+ flags |= FLAGS_MORE if @more
52
+ flags |= FLAGS_COMMAND if @command
53
+
54
+ if size > SHORT_MAX
55
+ flags |= FLAGS_LONG
56
+ buf = IO::Buffer.new(9 + size)
57
+ buf.set_value(:U8, 0, flags)
58
+ buf.set_value(:U64, 1, size) # big-endian
59
+ buf.set_string(@body, 9)
60
+ buf.get_string(0, 9 + size, Encoding::BINARY)
61
+ else
62
+ buf = IO::Buffer.new(2 + size)
63
+ buf.set_value(:U8, 0, flags)
64
+ buf.set_value(:U8, 1, size)
65
+ buf.set_string(@body, 2)
66
+ buf.get_string(0, 2 + size, Encoding::BINARY)
67
+ end
68
+ end
69
+
70
+ # Reads one frame from an IO-like object.
71
+ #
72
+ # @param io [#read] must support read(n) returning exactly n bytes
73
+ # @return [Frame]
74
+ # @raise [ProtocolError] on invalid frame
75
+ # @raise [EOFError] if the connection is closed
76
+ #
77
+ def self.read_from(io)
78
+ flags_byte = io.read_exactly(1)
79
+ flags_buf = IO::Buffer.for(flags_byte)
80
+ flags = flags_buf.get_value(:U8, 0)
81
+
82
+ more = (flags & FLAGS_MORE) != 0
83
+ long = (flags & FLAGS_LONG) != 0
84
+ command = (flags & FLAGS_COMMAND) != 0
85
+
86
+ if long
87
+ size_bytes = io.read_exactly(8)
88
+ size_buf = IO::Buffer.for(size_bytes)
89
+ size = size_buf.get_value(:U64, 0) # big-endian
90
+ else
91
+ size_byte = io.read_exactly(1)
92
+ size_buf = IO::Buffer.for(size_byte)
93
+ size = size_buf.get_value(:U8, 0)
94
+ end
95
+
96
+ body = size > 0 ? io.read_exactly(size) : "".b
97
+
98
+ new(body, more: more, command: command)
99
+ end
100
+
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ module ZMTP
5
+ module Codec
6
+ # ZMTP 3.1 greeting encode/decode.
7
+ #
8
+ # The greeting is always exactly 64 bytes:
9
+ # Offset Bytes Field
10
+ # 0 1 0xFF (signature start)
11
+ # 1-8 8 0x00 padding
12
+ # 9 1 0x7F (signature end)
13
+ # 10 1 major version
14
+ # 11 1 minor version
15
+ # 12-31 20 mechanism (null-padded ASCII)
16
+ # 32 1 as-server flag (0x00 or 0x01)
17
+ # 33-63 31 filler (0x00)
18
+ #
19
+ module Greeting
20
+ SIZE = 64
21
+ SIGNATURE_START = 0xFF
22
+ SIGNATURE_END = 0x7F
23
+ VERSION_MAJOR = 3
24
+ VERSION_MINOR = 1
25
+ MECHANISM_OFFSET = 12
26
+ MECHANISM_LENGTH = 20
27
+ AS_SERVER_OFFSET = 32
28
+
29
+ # Encodes a ZMTP 3.1 greeting.
30
+ #
31
+ # @param mechanism [String] security mechanism name (e.g. "NULL")
32
+ # @param as_server [Boolean] whether this peer is the server
33
+ # @return [String] 64-byte binary greeting
34
+ #
35
+ def self.encode(mechanism: "NULL", as_server: false)
36
+ buf = IO::Buffer.new(SIZE)
37
+ buf.clear
38
+
39
+ # Signature
40
+ buf.set_value(:U8, 0, SIGNATURE_START)
41
+ # bytes 1-8 are already 0x00
42
+ buf.set_value(:U8, 9, SIGNATURE_END)
43
+
44
+ # Version
45
+ buf.set_value(:U8, 10, VERSION_MAJOR)
46
+ buf.set_value(:U8, 11, VERSION_MINOR)
47
+
48
+ # Mechanism (null-padded)
49
+ buf.set_string(mechanism.b, MECHANISM_OFFSET)
50
+
51
+ # As-server flag
52
+ buf.set_value(:U8, AS_SERVER_OFFSET, as_server ? 1 : 0)
53
+
54
+ # Filler bytes 33-63 are already 0x00
55
+ buf.get_string(0, SIZE, Encoding::BINARY)
56
+ end
57
+
58
+ # Decodes a ZMTP greeting.
59
+ #
60
+ # @param data [String] 64-byte binary greeting
61
+ # @return [Hash] { major:, minor:, mechanism:, as_server: }
62
+ # @raise [ProtocolError] on invalid greeting
63
+ #
64
+ def self.decode(data)
65
+ raise ProtocolError, "greeting too short (#{data.bytesize} bytes)" if data.bytesize < SIZE
66
+
67
+ buf = IO::Buffer.for(data.b)
68
+
69
+ # Validate signature
70
+ unless buf.get_value(:U8, 0) == SIGNATURE_START &&
71
+ buf.get_value(:U8, 9) == SIGNATURE_END
72
+ raise ProtocolError, "invalid greeting signature"
73
+ end
74
+
75
+ major = buf.get_value(:U8, 10)
76
+ minor = buf.get_value(:U8, 11)
77
+
78
+ unless major >= 3
79
+ raise ProtocolError, "unsupported ZMTP version #{major}.#{minor} (need >= 3.0)"
80
+ end
81
+
82
+ mechanism = buf.get_string(MECHANISM_OFFSET, MECHANISM_LENGTH, Encoding::BINARY)
83
+ .delete("\x00")
84
+ as_server = buf.get_value(:U8, AS_SERVER_OFFSET) == 1
85
+
86
+ {
87
+ major: major,
88
+ minor: minor,
89
+ mechanism: mechanism,
90
+ as_server: as_server,
91
+ }
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ module ZMTP
5
+ # ZMTP 3.1 wire protocol codec.
6
+ #
7
+ module Codec
8
+ end
9
+
10
+ # Raised on ZMTP protocol violations.
11
+ #
12
+ class ProtocolError < RuntimeError; end
13
+ end
14
+ end
15
+
16
+ require_relative "codec/greeting"
17
+ require_relative "codec/frame"
18
+ require_relative "codec/command"
@@ -0,0 +1,233 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ module ZMTP
5
+ # Manages one ZMTP peer connection over any transport IO.
6
+ #
7
+ # Delegates the security handshake to a Mechanism object (Null, Curve, etc.),
8
+ # then provides message send/receive and command send/receive on top of the
9
+ # framing codec.
10
+ #
11
+ class Connection
12
+ # @return [String] peer's socket type (from READY handshake)
13
+ #
14
+ attr_reader :peer_socket_type
15
+
16
+ # @return [String] peer's identity (from READY handshake)
17
+ #
18
+ attr_reader :peer_identity
19
+
20
+ # @return [Object] transport IO (#read, #write, #close)
21
+ #
22
+ attr_reader :io
23
+
24
+ # @param io [#read, #write, #close] transport IO
25
+ # @param socket_type [String] our socket type name (e.g. "REQ")
26
+ # @param identity [String] our identity
27
+ # @param as_server [Boolean] whether we are the server side
28
+ # @param mechanism [Mechanism::Null, Mechanism::Curve] security mechanism
29
+ # @param heartbeat_interval [Numeric, nil] heartbeat interval in seconds
30
+ # @param heartbeat_ttl [Numeric, nil] TTL to send in PING
31
+ # @param heartbeat_timeout [Numeric, nil] timeout for PONG
32
+ # @param max_message_size [Integer, nil] max frame size in bytes, nil = unlimited
33
+ #
34
+ def initialize(io, socket_type:, identity: "", as_server: false,
35
+ mechanism: nil,
36
+ heartbeat_interval: nil, heartbeat_ttl: nil, heartbeat_timeout: nil,
37
+ max_message_size: nil)
38
+ @io = io
39
+ @socket_type = socket_type
40
+ @identity = identity
41
+ @as_server = as_server
42
+ @mechanism = mechanism || Mechanism::Null.new
43
+ @peer_socket_type = nil
44
+ @peer_identity = nil
45
+ @mutex = Mutex.new
46
+ @heartbeat_interval = heartbeat_interval
47
+ @heartbeat_ttl = heartbeat_ttl || heartbeat_interval
48
+ @heartbeat_timeout = heartbeat_timeout || heartbeat_interval
49
+ @last_received_at = nil
50
+ @heartbeat_task = nil
51
+ @max_message_size = max_message_size
52
+ end
53
+
54
+ # Performs the full ZMTP handshake via the configured mechanism.
55
+ #
56
+ # @return [void]
57
+ # @raise [ProtocolError] on handshake failure
58
+ #
59
+ def handshake!
60
+ result = @mechanism.handshake!(
61
+ @io,
62
+ as_server: @as_server,
63
+ socket_type: @socket_type,
64
+ identity: @identity,
65
+ )
66
+
67
+ @peer_socket_type = result[:peer_socket_type]
68
+ @peer_identity = result[:peer_identity]
69
+
70
+ unless @peer_socket_type
71
+ raise ProtocolError, "peer READY missing Socket-Type"
72
+ end
73
+
74
+ unless ZMTP::VALID_PEERS[@socket_type.to_sym]&.include?(@peer_socket_type.to_sym)
75
+ raise ProtocolError,
76
+ "incompatible socket types: #{@socket_type} cannot connect to #{@peer_socket_type}"
77
+ end
78
+ end
79
+
80
+ # Sends a multi-frame message.
81
+ #
82
+ # @param parts [Array<String>] message frames
83
+ # @return [void]
84
+ #
85
+ def send_message(parts)
86
+ @mutex.synchronize do
87
+ parts.each_with_index do |part, i|
88
+ more = i < parts.size - 1
89
+ if @mechanism.encrypted?
90
+ @io.write(@mechanism.encrypt(part.b, more: more))
91
+ else
92
+ @io.write(Codec::Frame.new(part, more: more).to_wire)
93
+ end
94
+ end
95
+ end
96
+ end
97
+
98
+ # Receives a multi-frame message.
99
+ # PING/PONG commands are handled automatically by #read_frame.
100
+ #
101
+ # @return [Array<String>] message frames
102
+ # @raise [EOFError] if connection is closed
103
+ #
104
+ def receive_message
105
+ frames = []
106
+ loop do
107
+ frame = read_frame
108
+ if frame.command?
109
+ yield frame if block_given?
110
+ next
111
+ end
112
+ frames << frame.body
113
+ break unless frame.more?
114
+ end
115
+ frames
116
+ end
117
+
118
+ # Starts the heartbeat sender task. Call after handshake.
119
+ #
120
+ # @return [#stop, nil] the heartbeat task, or nil if disabled
121
+ #
122
+ def start_heartbeat
123
+ return nil unless @heartbeat_interval
124
+ @last_received_at = monotonic_now
125
+ @heartbeat_task = Reactor.spawn_pump do
126
+ loop do
127
+ sleep @heartbeat_interval
128
+ # Send PING with TTL
129
+ send_command(Codec::Command.ping(
130
+ ttl: @heartbeat_ttl || 0,
131
+ context: "".b,
132
+ ))
133
+ # Check if peer has gone silent
134
+ if @heartbeat_timeout && @last_received_at
135
+ elapsed = monotonic_now - @last_received_at
136
+ if elapsed > @heartbeat_timeout
137
+ close
138
+ break
139
+ end
140
+ end
141
+ end
142
+ rescue IOError, EOFError
143
+ # connection closed
144
+ end
145
+ end
146
+
147
+ # Sends a command.
148
+ #
149
+ # @param command [Codec::Command]
150
+ # @return [void]
151
+ #
152
+ def send_command(command)
153
+ @mutex.synchronize do
154
+ if @mechanism.encrypted?
155
+ @io.write(@mechanism.encrypt(command.to_body, command: true))
156
+ else
157
+ @io.write(command.to_frame.to_wire)
158
+ end
159
+ end
160
+ end
161
+
162
+ # Reads one frame from the wire. Handles PING/PONG automatically.
163
+ # When using an encrypted mechanism, MESSAGE commands are decrypted
164
+ # back to ZMTP frames transparently.
165
+ #
166
+ # @return [Codec::Frame]
167
+ # @raise [EOFError] if connection is closed
168
+ #
169
+ def read_frame
170
+ loop do
171
+ frame = Codec::Frame.read_from(@io)
172
+ touch_heartbeat
173
+
174
+ # When CURVE is active, every wire frame is a MESSAGE envelope
175
+ # (data frame with "\x07MESSAGE" prefix). Decrypt to recover the
176
+ # inner ZMTP frame. This check only runs when encrypted? is true,
177
+ # so user data frames are never misdetected.
178
+ if @mechanism.encrypted? && frame.body.bytesize > 8 && frame.body.byteslice(0, 8) == "\x07MESSAGE".b
179
+ frame = @mechanism.decrypt(frame)
180
+ end
181
+
182
+ if @max_message_size && !frame.command? && frame.body.bytesize > @max_message_size
183
+ close
184
+ raise ProtocolError, "frame size #{frame.body.bytesize} exceeds max_message_size #{@max_message_size}"
185
+ end
186
+ if frame.command?
187
+ cmd = Codec::Command.from_body(frame.body)
188
+ case cmd.name
189
+ when "PING"
190
+ _, context = cmd.ping_ttl_and_context
191
+ send_command(Codec::Command.pong(context: context))
192
+ next
193
+ when "PONG"
194
+ next
195
+ end
196
+ end
197
+ return frame
198
+ end
199
+ end
200
+
201
+ # Closes the connection.
202
+ #
203
+ # @return [void]
204
+ #
205
+ def close
206
+ @heartbeat_task&.stop rescue nil
207
+ @io.close
208
+ rescue IOError
209
+ # already closed
210
+ end
211
+
212
+ private
213
+
214
+ def touch_heartbeat
215
+ @last_received_at = monotonic_now if @heartbeat_interval
216
+ end
217
+
218
+ def monotonic_now
219
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
220
+ end
221
+
222
+ # Sends one frame to the wire.
223
+ #
224
+ # @param frame [Codec::Frame]
225
+ # @return [void]
226
+ #
227
+ def send_frame(frame)
228
+ @mutex.synchronize { @io.write(frame.to_wire) }
229
+ end
230
+
231
+ end
232
+ end
233
+ end