tether 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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +5 -0
- data/Rakefile +17 -0
- data/lib/tether/client.rb +298 -0
- data/lib/tether/deadline.rb +127 -0
- data/lib/tether/dispatcher.rb +109 -0
- data/lib/tether/errors.rb +204 -0
- data/lib/tether/headers.rb +256 -0
- data/lib/tether/inbox_mux.rb +113 -0
- data/lib/tether/msg.rb +90 -0
- data/lib/tether/nuid.rb +68 -0
- data/lib/tether/protocol/encoder.rb +125 -0
- data/lib/tether/protocol/parser.rb +336 -0
- data/lib/tether/protocol.rb +18 -0
- data/lib/tether/subscription.rb +123 -0
- data/lib/tether/transport.rb +114 -0
- data/lib/tether/version.rb +5 -0
- data/lib/tether.rb +28 -0
- data/sig/tether/client.rbs +45 -0
- data/sig/tether/deadline.rbs +21 -0
- data/sig/tether/dispatcher.rbs +19 -0
- data/sig/tether/errors.rbs +110 -0
- data/sig/tether/headers.rbs +50 -0
- data/sig/tether/inbox_mux.rbs +20 -0
- data/sig/tether/msg.rbs +16 -0
- data/sig/tether/nuid.rbs +21 -0
- data/sig/tether/protocol/encoder.rbs +13 -0
- data/sig/tether/protocol/parser.rbs +43 -0
- data/sig/tether/protocol.rbs +22 -0
- data/sig/tether/subscription.rbs +24 -0
- data/sig/tether/transport.rbs +22 -0
- data/sig/tether/version.rbs +3 -0
- metadata +77 -0
data/lib/tether/nuid.rb
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Tether
|
|
6
|
+
# Generator for NATS unique identifiers: a 12 character random prefix followed
|
|
7
|
+
# by a 10 character base62 sequence, incremented by a random step so that
|
|
8
|
+
# identifiers are neither guessable nor collision prone across processes.
|
|
9
|
+
#
|
|
10
|
+
# Instances are not thread safe. Use {NUID.next} for the process wide,
|
|
11
|
+
# mutex guarded generator.
|
|
12
|
+
class NUID
|
|
13
|
+
DIGITS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
|
14
|
+
BASE = 62
|
|
15
|
+
PREFIX_LENGTH = 12
|
|
16
|
+
SEQUENCE_LENGTH = 10
|
|
17
|
+
TOTAL_LENGTH = PREFIX_LENGTH + SEQUENCE_LENGTH
|
|
18
|
+
MAX_SEQUENCE = BASE**SEQUENCE_LENGTH
|
|
19
|
+
MIN_INCREMENT = 33
|
|
20
|
+
MAX_INCREMENT = 333
|
|
21
|
+
PADDING = ("0" * SEQUENCE_LENGTH).freeze
|
|
22
|
+
private_constant :PADDING
|
|
23
|
+
|
|
24
|
+
@mutex = Mutex.new
|
|
25
|
+
@generator = nil
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
# Generates an identifier from the process wide generator.
|
|
29
|
+
#
|
|
30
|
+
# @return [String] a 22 character identifier
|
|
31
|
+
def next
|
|
32
|
+
@mutex.synchronize { (@generator ||= new).next }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Seeds a generator with its own random prefix, so two instances in the
|
|
37
|
+
# same process never collide.
|
|
38
|
+
def initialize
|
|
39
|
+
reseed!
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Generates the next identifier.
|
|
43
|
+
#
|
|
44
|
+
# @return [String] a 22 character identifier
|
|
45
|
+
def next
|
|
46
|
+
@sequence += @increment
|
|
47
|
+
reseed! if @sequence >= MAX_SEQUENCE
|
|
48
|
+
|
|
49
|
+
buffer = @prefix + PADDING
|
|
50
|
+
value = @sequence
|
|
51
|
+
(TOTAL_LENGTH - 1).downto(PREFIX_LENGTH) do |index|
|
|
52
|
+
buffer.setbyte(index, DIGITS.getbyte(value % BASE))
|
|
53
|
+
value /= BASE
|
|
54
|
+
end
|
|
55
|
+
buffer
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def reseed!
|
|
61
|
+
@prefix = SecureRandom.random_bytes(PREFIX_LENGTH).each_byte.with_object(+"") do |byte, out|
|
|
62
|
+
out << DIGITS.getbyte(byte % BASE)
|
|
63
|
+
end
|
|
64
|
+
@sequence = SecureRandom.random_number(MAX_SEQUENCE)
|
|
65
|
+
@increment = MIN_INCREMENT + SecureRandom.random_number(MAX_INCREMENT - MIN_INCREMENT)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Tether
|
|
6
|
+
module Protocol
|
|
7
|
+
# Builds complete protocol frames as single binary strings.
|
|
8
|
+
#
|
|
9
|
+
# Every method returns one whole frame. The writer takes its lock around the
|
|
10
|
+
# write of that string, never around the pieces of it: frames from different
|
|
11
|
+
# threads must not interleave on the wire.
|
|
12
|
+
module Encoder
|
|
13
|
+
# The server seeds every connection with `verbose:true, pedantic:true,
|
|
14
|
+
# echo:true` and merges CONNECT into that struct, so an absent key keeps
|
|
15
|
+
# the server's default rather than clearing it. Omitting `verbose` alone
|
|
16
|
+
# makes the server acknowledge every PUB and SUB with `+OK`; omitting
|
|
17
|
+
# `headers` makes it rewrite HMSG as MSG with the header bytes cut off,
|
|
18
|
+
# which silently strips all JetStream metadata.
|
|
19
|
+
DEFAULT_CONNECT = {
|
|
20
|
+
verbose: false,
|
|
21
|
+
pedantic: false,
|
|
22
|
+
tls_required: false,
|
|
23
|
+
lang: "ruby",
|
|
24
|
+
version: Tether::VERSION,
|
|
25
|
+
protocol: 1,
|
|
26
|
+
echo: true,
|
|
27
|
+
headers: true,
|
|
28
|
+
no_responders: true
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
class << self
|
|
32
|
+
# Encodes a `PUB` frame.
|
|
33
|
+
#
|
|
34
|
+
# @param subject [String]
|
|
35
|
+
# @param payload [String, nil]
|
|
36
|
+
# @param reply_to [String, nil]
|
|
37
|
+
# @return [String] binary encoded frame
|
|
38
|
+
def pub(subject, payload = nil, reply_to = nil)
|
|
39
|
+
body = binary(payload)
|
|
40
|
+
frame = buffer(subject.bytesize + body.bytesize + 64)
|
|
41
|
+
write_head(frame, "PUB ", subject, reply_to)
|
|
42
|
+
frame << " " << body.bytesize.to_s << CRLF
|
|
43
|
+
frame << body << CRLF
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Encodes an `HPUB` frame.
|
|
47
|
+
#
|
|
48
|
+
# @param subject [String]
|
|
49
|
+
# @param payload [String, nil]
|
|
50
|
+
# @param headers [Headers, String] headers, or a pre-encoded header block
|
|
51
|
+
# @param reply_to [String, nil]
|
|
52
|
+
# @return [String] binary encoded frame
|
|
53
|
+
def hpub(subject, payload, headers, reply_to = nil)
|
|
54
|
+
block = headers.is_a?(Headers) ? headers.to_wire : binary(headers)
|
|
55
|
+
body = binary(payload)
|
|
56
|
+
frame = buffer(subject.bytesize + block.bytesize + body.bytesize + 64)
|
|
57
|
+
write_head(frame, "HPUB ", subject, reply_to)
|
|
58
|
+
frame << " " << block.bytesize.to_s
|
|
59
|
+
frame << " " << (block.bytesize + body.bytesize).to_s << CRLF
|
|
60
|
+
frame << block << body << CRLF
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Encodes a `SUB` frame.
|
|
64
|
+
#
|
|
65
|
+
# @param subject [String]
|
|
66
|
+
# @param sid [Integer, String]
|
|
67
|
+
# @param queue [String, nil] queue group
|
|
68
|
+
# @return [String] binary encoded frame
|
|
69
|
+
def sub(subject, sid, queue: nil)
|
|
70
|
+
frame = buffer(subject.bytesize + 32)
|
|
71
|
+
frame << "SUB " << binary(subject)
|
|
72
|
+
frame << " " << binary(queue) if queue
|
|
73
|
+
frame << " " << sid.to_s << CRLF
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Encodes an `UNSUB` frame.
|
|
77
|
+
#
|
|
78
|
+
# @param sid [Integer, String]
|
|
79
|
+
# @param max_msgs [Integer, nil] unsubscribe after this many messages
|
|
80
|
+
# @return [String] binary encoded frame
|
|
81
|
+
def unsub(sid, max_msgs = nil)
|
|
82
|
+
frame = buffer(32)
|
|
83
|
+
frame << "UNSUB " << sid.to_s
|
|
84
|
+
frame << " " << max_msgs.to_s if max_msgs
|
|
85
|
+
frame << CRLF
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Encodes a `CONNECT` frame over {DEFAULT_CONNECT}.
|
|
89
|
+
#
|
|
90
|
+
# @param options [Hash] overrides for the defaults
|
|
91
|
+
# @return [String] binary encoded frame
|
|
92
|
+
# @raise [ArgumentError] if no_responders is requested without headers
|
|
93
|
+
def connect(options = {})
|
|
94
|
+
payload = DEFAULT_CONNECT.merge(options)
|
|
95
|
+
if payload[:no_responders] && !payload[:headers]
|
|
96
|
+
raise ArgumentError, "no_responders requires headers; the server closes the connection otherwise"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
json = JSON.generate(payload)
|
|
100
|
+
frame = buffer(json.bytesize + 16)
|
|
101
|
+
frame << "CONNECT " << json << CRLF
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def write_head(frame, verb, subject, reply_to)
|
|
107
|
+
frame << verb << binary(subject)
|
|
108
|
+
frame << " " << binary(reply_to) if reply_to
|
|
109
|
+
frame
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def buffer(capacity)
|
|
113
|
+
String.new(capacity: capacity, encoding: BINARY)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def binary(value)
|
|
117
|
+
return EMPTY_PAYLOAD if value.nil?
|
|
118
|
+
return value if value.encoding == BINARY || value.ascii_only?
|
|
119
|
+
|
|
120
|
+
value.b
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tether
|
|
4
|
+
module Protocol
|
|
5
|
+
# Incremental parser for the server side of the NATS protocol.
|
|
6
|
+
#
|
|
7
|
+
# Bytes are appended to one buffer and consumed by advancing an index.
|
|
8
|
+
# Control lines are read in place: subjects, reply subjects, header blocks
|
|
9
|
+
# and payloads are the only substrings allocated, and integers are scanned
|
|
10
|
+
# straight out of the buffer. The buffer is reset in place once drained and
|
|
11
|
+
# compacted only when the consumed prefix passes {COMPACT_THRESHOLD}.
|
|
12
|
+
#
|
|
13
|
+
# The parser never raises into its caller. The reader thread is the only
|
|
14
|
+
# demultiplexer for the whole connection, so its death would stall every
|
|
15
|
+
# subscription at once; malformed input is reported through
|
|
16
|
+
# `sink.on_protocol_error` and the parser marks itself {#broken?}.
|
|
17
|
+
#
|
|
18
|
+
# The sink must respond to:
|
|
19
|
+
#
|
|
20
|
+
# - `on_msg(subject, sid, reply_to, header_bytes, payload)` where `sid` is
|
|
21
|
+
# the opaque token this client chose at SUB time, echoed back verbatim,
|
|
22
|
+
# and `header_bytes` is nil for `MSG` or the raw header block for `HMSG`
|
|
23
|
+
# - `on_ping`, `on_pong`, `on_ok`
|
|
24
|
+
# - `on_info(json)` with the still unparsed JSON text
|
|
25
|
+
# - `on_error(description)` for an `-ERR` line
|
|
26
|
+
# - `on_protocol_error(message)`
|
|
27
|
+
class Parser
|
|
28
|
+
COMPACT_THRESHOLD = 64 * 1024
|
|
29
|
+
|
|
30
|
+
# A leftover at or below this is copied out rather than left in place, so
|
|
31
|
+
# the buffer does not grow across reads and make every later append
|
|
32
|
+
# target a large string.
|
|
33
|
+
SHORT_REMAINDER = 1024
|
|
34
|
+
SNIPPET_LIMIT = 64
|
|
35
|
+
|
|
36
|
+
AWAITING_CONTROL_LINE = :control
|
|
37
|
+
AWAITING_PAYLOAD = :payload
|
|
38
|
+
|
|
39
|
+
MSG_LINE = /\GMSG[ \t]+(\S+)[ \t]+(\S+)[ \t]+(?:(\S+)[ \t]+)?(\d{1,9})[ \t]*\r\n/i
|
|
40
|
+
HMSG_LINE = /\GHMSG[ \t]+(\S+)[ \t]+(\S+)[ \t]+(?:(\S+)[ \t]+)?(\d{1,9})[ \t]+(\d{1,9})[ \t]*\r\n/i
|
|
41
|
+
|
|
42
|
+
CR = 13
|
|
43
|
+
LF = 10
|
|
44
|
+
UPPER_M = 77
|
|
45
|
+
LOWER_M = 109
|
|
46
|
+
UPPER_H = 72
|
|
47
|
+
LOWER_H = 104
|
|
48
|
+
UPPER_P = 80
|
|
49
|
+
LOWER_P = 112
|
|
50
|
+
UPPER_I = 73
|
|
51
|
+
LOWER_I = 105
|
|
52
|
+
PLUS = 43
|
|
53
|
+
MINUS = 45
|
|
54
|
+
UPPER_A = 65
|
|
55
|
+
UPPER_Z = 90
|
|
56
|
+
CASE_SHIFT = 32
|
|
57
|
+
private_constant :MSG_LINE, :HMSG_LINE, :CR, :LF, :UPPER_A, :UPPER_Z, :CASE_SHIFT,
|
|
58
|
+
:UPPER_M, :LOWER_M, :UPPER_H, :LOWER_H, :UPPER_P, :LOWER_P,
|
|
59
|
+
:UPPER_I, :LOWER_I, :PLUS, :MINUS
|
|
60
|
+
|
|
61
|
+
# @return [Symbol] :control or :payload
|
|
62
|
+
def state = @awaiting_payload ? AWAITING_PAYLOAD : AWAITING_CONTROL_LINE
|
|
63
|
+
|
|
64
|
+
# @return [Integer, nil] largest frame accepted, from the server's INFO
|
|
65
|
+
attr_accessor :max_payload
|
|
66
|
+
|
|
67
|
+
# @param sink [Object] receives the parsed frames
|
|
68
|
+
# @param max_control_line [Integer] sanity bound on a single control line
|
|
69
|
+
# @param max_payload [Integer, nil] reject a frame larger than the server
|
|
70
|
+
# advertised in INFO, rather than buffering toward it
|
|
71
|
+
def initialize(sink, max_control_line: DEFAULT_MAX_CONTROL_LINE, max_payload: nil)
|
|
72
|
+
@sink = sink
|
|
73
|
+
@max_control_line = max_control_line
|
|
74
|
+
@max_payload = max_payload
|
|
75
|
+
@buffer = String.new(capacity: COMPACT_THRESHOLD, encoding: BINARY)
|
|
76
|
+
reset!
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Appends bytes and consumes every complete frame in the buffer.
|
|
80
|
+
#
|
|
81
|
+
# @param data [String] bytes as read from the socket
|
|
82
|
+
# @return [self]
|
|
83
|
+
def <<(data)
|
|
84
|
+
return self if @broken || data.nil? || data.empty?
|
|
85
|
+
|
|
86
|
+
@buffer << (data.encoding == BINARY ? data : data.b)
|
|
87
|
+
run
|
|
88
|
+
self
|
|
89
|
+
end
|
|
90
|
+
alias parse <<
|
|
91
|
+
|
|
92
|
+
# Discards buffered bytes and returns to the initial state.
|
|
93
|
+
#
|
|
94
|
+
# @return [void]
|
|
95
|
+
def reset!
|
|
96
|
+
@buffer.clear
|
|
97
|
+
@position = 0
|
|
98
|
+
@awaiting_payload = false
|
|
99
|
+
@broken = false
|
|
100
|
+
clear_frame
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# A broken parser consumes nothing further; the client is expected to
|
|
104
|
+
# tear the connection down.
|
|
105
|
+
#
|
|
106
|
+
# @return [Boolean] whether a protocol violation stopped this parser
|
|
107
|
+
def broken?
|
|
108
|
+
@broken
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# @return [Integer] bytes buffered but not yet consumed, which is the tail
|
|
112
|
+
# of a frame still arriving
|
|
113
|
+
def pending_bytes
|
|
114
|
+
@buffer.bytesize - @position
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
def run
|
|
120
|
+
buffer = @buffer
|
|
121
|
+
sink = @sink
|
|
122
|
+
pos = @position
|
|
123
|
+
size = buffer.bytesize
|
|
124
|
+
|
|
125
|
+
loop do
|
|
126
|
+
if @awaiting_payload
|
|
127
|
+
needed = @needed
|
|
128
|
+
break if size - pos < needed + CRLF_SIZE
|
|
129
|
+
|
|
130
|
+
unless buffer.getbyte(pos + needed) == CR && buffer.getbyte(pos + needed + 1) == LF
|
|
131
|
+
@position = pos
|
|
132
|
+
return fail_protocol!("payload not terminated by CRLF on #{@subject}")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
if (header_size = @header_size)
|
|
136
|
+
sink.on_msg(@subject, @sid, @reply_to, buffer.byteslice(pos, header_size),
|
|
137
|
+
buffer.byteslice(pos + header_size, needed - header_size))
|
|
138
|
+
else
|
|
139
|
+
sink.on_msg(@subject, @sid, @reply_to, nil, buffer.byteslice(pos, needed))
|
|
140
|
+
end
|
|
141
|
+
pos += needed + CRLF_SIZE
|
|
142
|
+
@awaiting_payload = false
|
|
143
|
+
next
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
line_end = buffer.index(CRLF, pos)
|
|
147
|
+
unless line_end
|
|
148
|
+
if size - pos > @max_control_line
|
|
149
|
+
@position = pos
|
|
150
|
+
return fail_protocol!("control line exceeds #{@max_control_line} bytes")
|
|
151
|
+
end
|
|
152
|
+
break
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
byte = buffer.getbyte(pos)
|
|
156
|
+
if [UPPER_M, LOWER_M].include?(byte)
|
|
157
|
+
unless buffer.index(MSG_LINE, pos)
|
|
158
|
+
@position = pos
|
|
159
|
+
return malformed(pos, line_end)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
subject = ::Regexp.last_match(1)
|
|
163
|
+
sid = ::Regexp.last_match(2)
|
|
164
|
+
reply_to = ::Regexp.last_match(3)
|
|
165
|
+
header_size = nil
|
|
166
|
+
needed = ::Regexp.last_match(4).to_i
|
|
167
|
+
elsif [UPPER_H, LOWER_H].include?(byte)
|
|
168
|
+
unless buffer.index(HMSG_LINE, pos)
|
|
169
|
+
@position = pos
|
|
170
|
+
return malformed(pos, line_end)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
subject = ::Regexp.last_match(1)
|
|
174
|
+
sid = ::Regexp.last_match(2)
|
|
175
|
+
reply_to = ::Regexp.last_match(3)
|
|
176
|
+
header_size = ::Regexp.last_match(4).to_i
|
|
177
|
+
needed = ::Regexp.last_match(5).to_i
|
|
178
|
+
else
|
|
179
|
+
@position = pos
|
|
180
|
+
return unless dispatch(pos, line_end)
|
|
181
|
+
|
|
182
|
+
pos = line_end + CRLF_SIZE
|
|
183
|
+
next
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
if (header_size && header_size > needed) || (@max_payload && needed > @max_payload)
|
|
187
|
+
@position = pos
|
|
188
|
+
return reject_sizes(subject, header_size, needed)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
pos = line_end + CRLF_SIZE
|
|
192
|
+
|
|
193
|
+
# The payload is usually already buffered, in which case the frame is
|
|
194
|
+
# emitted straight from locals and never touches the object at all.
|
|
195
|
+
if size - pos >= needed + CRLF_SIZE
|
|
196
|
+
unless buffer.getbyte(pos + needed) == CR && buffer.getbyte(pos + needed + 1) == LF
|
|
197
|
+
@position = pos
|
|
198
|
+
return fail_protocol!("payload not terminated by CRLF on #{subject}")
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
if header_size
|
|
202
|
+
sink.on_msg(subject, sid, reply_to, buffer.byteslice(pos, header_size),
|
|
203
|
+
buffer.byteslice(pos + header_size, needed - header_size))
|
|
204
|
+
else
|
|
205
|
+
sink.on_msg(subject, sid, reply_to, nil, buffer.byteslice(pos, needed))
|
|
206
|
+
end
|
|
207
|
+
pos += needed + CRLF_SIZE
|
|
208
|
+
next
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
@subject = subject
|
|
212
|
+
@sid = sid
|
|
213
|
+
@reply_to = reply_to
|
|
214
|
+
@header_size = header_size
|
|
215
|
+
@needed = needed
|
|
216
|
+
@awaiting_payload = true
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
@position = pos
|
|
220
|
+
compact
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def reject_sizes(subject, header_size, needed)
|
|
224
|
+
return fail_protocol!("header size exceeds total size on #{subject}") if header_size && header_size > needed
|
|
225
|
+
|
|
226
|
+
fail_protocol!("frame of #{needed} bytes exceeds max_payload #{@max_payload} on #{subject}")
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def dispatch(start, line_end)
|
|
230
|
+
case @buffer.getbyte(start)
|
|
231
|
+
when UPPER_P, LOWER_P then dispatch_ping_pong(start, line_end)
|
|
232
|
+
when PLUS then dispatch_ok(start, line_end)
|
|
233
|
+
when MINUS then dispatch_err(start, line_end)
|
|
234
|
+
when UPPER_I, LOWER_I then dispatch_info(start, line_end)
|
|
235
|
+
else unknown_operation(start, line_end)
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def dispatch_ping_pong(start, line_end)
|
|
240
|
+
if verb?(start, line_end, "PING")
|
|
241
|
+
@sink.on_ping
|
|
242
|
+
true
|
|
243
|
+
elsif verb?(start, line_end, "PONG")
|
|
244
|
+
@sink.on_pong
|
|
245
|
+
true
|
|
246
|
+
else
|
|
247
|
+
unknown_operation(start, line_end)
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def dispatch_ok(start, line_end)
|
|
252
|
+
return unknown_operation(start, line_end) unless verb?(start, line_end, "+OK")
|
|
253
|
+
|
|
254
|
+
@sink.on_ok
|
|
255
|
+
true
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def dispatch_err(start, line_end)
|
|
259
|
+
return unknown_operation(start, line_end) unless verb?(start, line_end, "-ERR")
|
|
260
|
+
|
|
261
|
+
description = trimmed(start + 4, line_end)
|
|
262
|
+
@sink.on_error(description.delete_prefix("'").delete_suffix("'"))
|
|
263
|
+
true
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def dispatch_info(start, line_end)
|
|
267
|
+
return unknown_operation(start, line_end) unless verb?(start, line_end, "INFO")
|
|
268
|
+
|
|
269
|
+
json = trimmed(start + 4, line_end)
|
|
270
|
+
return fail_protocol!("malformed INFO: #{snippet(start, line_end)}") if json.empty?
|
|
271
|
+
|
|
272
|
+
@sink.on_info(json)
|
|
273
|
+
true
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def verb?(start, limit, verb)
|
|
277
|
+
return false if limit - start < verb.bytesize
|
|
278
|
+
|
|
279
|
+
verb.bytesize.times do |offset|
|
|
280
|
+
expected = verb.getbyte(offset)
|
|
281
|
+
actual = @buffer.getbyte(start + offset)
|
|
282
|
+
next if actual == expected
|
|
283
|
+
return false unless expected.between?(UPPER_A, UPPER_Z) && actual == expected + CASE_SHIFT
|
|
284
|
+
end
|
|
285
|
+
true
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def trimmed(from, to)
|
|
289
|
+
@buffer.byteslice(from, to - from).strip
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def snippet(start, line_end)
|
|
293
|
+
@buffer.byteslice(start, [line_end - start, SNIPPET_LIMIT].min).inspect
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def clear_frame
|
|
297
|
+
@subject = nil
|
|
298
|
+
@sid = nil
|
|
299
|
+
@reply_to = nil
|
|
300
|
+
@needed = nil
|
|
301
|
+
@header_size = nil
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Draining the buffer in place is the common case. Otherwise the leftover
|
|
305
|
+
# is copied out whenever it is short, which keeps the append target small
|
|
306
|
+
# instead of letting it grow toward COMPACT_THRESHOLD across many reads;
|
|
307
|
+
# copying a few hundred bytes costs far less than appending into a buffer
|
|
308
|
+
# that has grown to tens of kilobytes. A long remainder, meaning a large
|
|
309
|
+
# frame still arriving, is left alone until the threshold.
|
|
310
|
+
def compact
|
|
311
|
+
remaining = @buffer.bytesize - @position
|
|
312
|
+
if remaining.zero?
|
|
313
|
+
@buffer.clear
|
|
314
|
+
@position = 0
|
|
315
|
+
elsif @position >= COMPACT_THRESHOLD || (@position.positive? && remaining <= SHORT_REMAINDER)
|
|
316
|
+
@buffer = @buffer.byteslice(@position..) || String.new(encoding: BINARY)
|
|
317
|
+
@position = 0
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def malformed(start, line_end)
|
|
322
|
+
fail_protocol!("malformed control line: #{snippet(start, line_end)}")
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def unknown_operation(start, line_end)
|
|
326
|
+
fail_protocol!("unknown protocol operation: #{snippet(start, line_end)}")
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def fail_protocol!(message)
|
|
330
|
+
@broken = true
|
|
331
|
+
@sink.on_protocol_error(message)
|
|
332
|
+
false
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tether
|
|
4
|
+
# The NATS client protocol: a line based text protocol over TCP where every
|
|
5
|
+
# control line ends in CRLF and payloads are length prefixed.
|
|
6
|
+
module Protocol
|
|
7
|
+
CRLF = "\r\n"
|
|
8
|
+
CRLF_SIZE = 2
|
|
9
|
+
EMPTY_PAYLOAD = ""
|
|
10
|
+
|
|
11
|
+
PING = "PING#{CRLF}".freeze
|
|
12
|
+
PONG = "PONG#{CRLF}".freeze
|
|
13
|
+
|
|
14
|
+
DEFAULT_MAX_CONTROL_LINE = 1024 * 1024
|
|
15
|
+
|
|
16
|
+
BINARY = Encoding::BINARY
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tether
|
|
4
|
+
# One subscription, with its own bounded queue and worker thread.
|
|
5
|
+
#
|
|
6
|
+
# The reader thread only ever enqueues here, never runs the callback: a slow
|
|
7
|
+
# handler must not delay delivery to other subscriptions, and must not delay
|
|
8
|
+
# the PONG that keeps the connection from being dropped as stale.
|
|
9
|
+
class Subscription
|
|
10
|
+
DEFAULT_MAX_PENDING = 65_536
|
|
11
|
+
|
|
12
|
+
# @return [String] the token sent to the server and echoed back on every message
|
|
13
|
+
attr_reader :sid
|
|
14
|
+
|
|
15
|
+
# @return [String]
|
|
16
|
+
attr_reader :subject
|
|
17
|
+
|
|
18
|
+
# @return [String, nil]
|
|
19
|
+
attr_reader :queue_group
|
|
20
|
+
|
|
21
|
+
# @return [Integer] messages delivered to this subscription
|
|
22
|
+
attr_reader :delivered
|
|
23
|
+
|
|
24
|
+
# @return [Integer] messages dropped because the queue was full
|
|
25
|
+
attr_reader :dropped
|
|
26
|
+
|
|
27
|
+
# @return [Client, nil] used by {Msg#respond}
|
|
28
|
+
attr_reader :responder
|
|
29
|
+
|
|
30
|
+
# Starts a worker thread when a callback is given. Without one, messages
|
|
31
|
+
# queue up until {#next_message} takes them on the caller's thread.
|
|
32
|
+
#
|
|
33
|
+
# @param sid [String]
|
|
34
|
+
# @param subject [String]
|
|
35
|
+
# @param queue_group [String, nil]
|
|
36
|
+
# @param max_pending [Integer] queue depth before this is a slow consumer
|
|
37
|
+
# @param on_slow_consumer [Proc, nil] called with self when a message is dropped
|
|
38
|
+
# @param responder [Client, nil] lets {Msg#respond} publish a reply
|
|
39
|
+
# @param callback [Proc, nil] when given, messages are delivered to it on a worker thread
|
|
40
|
+
def initialize(sid:, subject:, queue_group: nil, max_pending: DEFAULT_MAX_PENDING,
|
|
41
|
+
on_slow_consumer: nil, responder: nil, &callback)
|
|
42
|
+
@responder = responder
|
|
43
|
+
@sid = sid
|
|
44
|
+
@subject = subject
|
|
45
|
+
@queue_group = queue_group
|
|
46
|
+
@queue = Thread::SizedQueue.new(max_pending)
|
|
47
|
+
@on_slow_consumer = on_slow_consumer
|
|
48
|
+
@callback = callback
|
|
49
|
+
@delivered = 0
|
|
50
|
+
@dropped = 0
|
|
51
|
+
@lock = Mutex.new
|
|
52
|
+
@worker = start_worker if callback
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Enqueues a message. Called on the reader thread, so it never blocks.
|
|
56
|
+
#
|
|
57
|
+
# @param msg [Msg]
|
|
58
|
+
# @return [Boolean] false when the message was dropped
|
|
59
|
+
def deliver(msg)
|
|
60
|
+
@queue.push(msg, true)
|
|
61
|
+
@lock.synchronize { @delivered += 1 }
|
|
62
|
+
true
|
|
63
|
+
rescue ThreadError
|
|
64
|
+
@lock.synchronize { @dropped += 1 }
|
|
65
|
+
@on_slow_consumer&.call(self)
|
|
66
|
+
false
|
|
67
|
+
rescue ClosedQueueError
|
|
68
|
+
false
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Takes the next message, for a subscription with no callback.
|
|
72
|
+
#
|
|
73
|
+
# @param deadline [Deadline]
|
|
74
|
+
# @return [Msg]
|
|
75
|
+
# @raise [TimeoutError] if the deadline passes first
|
|
76
|
+
def next_message(deadline = Deadline.infinite)
|
|
77
|
+
msg = @queue.pop(timeout: deadline.remaining)
|
|
78
|
+
raise TimeoutError, "no message on #{@subject} within the deadline" if msg.nil? && !@queue.closed?
|
|
79
|
+
raise ConnectionClosedError, "subscription on #{@subject} was closed" if msg.nil?
|
|
80
|
+
|
|
81
|
+
msg
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Depth of the queue between the reader thread and this subscription's
|
|
85
|
+
# worker. Growth here means the handler is not keeping up.
|
|
86
|
+
#
|
|
87
|
+
# @return [Integer] messages waiting to be handled
|
|
88
|
+
def pending
|
|
89
|
+
@queue.size
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# @return [Boolean] whether delivery has stopped; a closed subscription never resumes
|
|
93
|
+
def closed?
|
|
94
|
+
@queue.closed?
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Stops delivery and lets the worker finish what it already took.
|
|
98
|
+
#
|
|
99
|
+
# @return [void]
|
|
100
|
+
def close
|
|
101
|
+
@queue.close
|
|
102
|
+
@worker&.join(1)
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def start_worker
|
|
109
|
+
Thread.new do
|
|
110
|
+
Thread.current.name = "tether:sub:#{@subject}"
|
|
111
|
+
while (msg = @queue.pop)
|
|
112
|
+
invoke(msg)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def invoke(msg)
|
|
118
|
+
@callback.call(msg)
|
|
119
|
+
rescue StandardError => e
|
|
120
|
+
@on_slow_consumer.nil? ? warn("tether: subscription callback raised: #{e.class}: #{e.message}") : nil
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|