sping 1.0.2 → 1.0.4
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 +4 -4
- data/bin/sping +11 -11
- data/lib/errors.rb +20 -0
- data/lib/last_acks.rb +24 -9
- data/lib/session.rb +196 -182
- data/lib/session_manager.rb +244 -220
- metadata +4 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9f7679b3fb31d07d034182114e576f3e272ef8223954f592bb30a98378b98fd5
|
|
4
|
+
data.tar.gz: b8d21d6a58ffb3c57481cc421d61f8fc25da32646fb8d5ae160829017d01fb14
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a6a2c6897ead8f7371b2f1a83719c1a8f2c07d44957a9c302bc548855d25671858435fe0d6e8162e14737e065f6bbcede37defd2ea21e23561d19e7f8d5ef63f
|
|
7
|
+
data.tar.gz: 2618e70abea52acc38364f7fb431dafa12a4d48ad8f07665f93ac76e316784e6a2f8f5d2b463a55053da88b10f46dc23adf16846abb1a4b1d4677eb9eef09e7f
|
data/bin/sping
CHANGED
|
@@ -7,10 +7,10 @@ require 'msgpack'
|
|
|
7
7
|
require_relative '../lib/session_manager'
|
|
8
8
|
|
|
9
9
|
MessagePack::DefaultFactory.register_type(
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
MessagePack::Timestamp::TYPE,
|
|
11
|
+
Time,
|
|
12
|
+
packer: MessagePack::Time::Packer,
|
|
13
|
+
unpacker: MessagePack::Time::Unpacker
|
|
14
14
|
)
|
|
15
15
|
|
|
16
16
|
$logger = Logger.new $stdout
|
|
@@ -21,18 +21,18 @@ sess_mgr.run
|
|
|
21
21
|
sess_mgr.run_server
|
|
22
22
|
|
|
23
23
|
ARGV.each do |host|
|
|
24
|
-
|
|
24
|
+
sess_mgr.new_session(host)
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
Signal.trap('INT') do
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
puts 'Stop server...'
|
|
29
|
+
sess_mgr.stop_server
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
puts 'Stop sessions...'
|
|
32
|
+
sess_mgr.stop_sessions
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
puts 'Stop client...'
|
|
35
|
+
sess_mgr.stop
|
|
36
36
|
end
|
|
37
37
|
|
|
38
38
|
sess_mgr.join
|
data/lib/errors.rb
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# sharable_constant_value: literal
|
|
3
|
+
|
|
4
|
+
module SPing
|
|
5
|
+
class SPingError < StandardError; end
|
|
6
|
+
|
|
7
|
+
class SessionManagerError < SPingError; end
|
|
8
|
+
class OutOfSessions < SessionManagerError; end
|
|
9
|
+
|
|
10
|
+
class SessionError < SPingError; end
|
|
11
|
+
class TCPHandshakeError < SessionError; end
|
|
12
|
+
|
|
13
|
+
class PeerError < SPingError; end
|
|
14
|
+
class InvalidPacketError < PeerError; end
|
|
15
|
+
class InvalidMessageError < InvalidPacketError; end
|
|
16
|
+
class UnknownPacketError < InvalidPacketError; end
|
|
17
|
+
class SignalizedError < PeerError; end
|
|
18
|
+
class UnknownVersionError < PeerError; end
|
|
19
|
+
class UnknownSessionError < PeerError; end
|
|
20
|
+
end
|
data/lib/last_acks.rb
CHANGED
|
@@ -1,21 +1,36 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
# sharable_constant_value: literal
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
attr_reader :
|
|
4
|
+
module SPing
|
|
5
|
+
class LastAcks
|
|
6
|
+
attr_reader :size
|
|
7
7
|
|
|
8
8
|
def initialize(size = 32)
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
@size = size
|
|
10
|
+
@acks = []
|
|
11
|
+
@acks_mutex = Mutex.new
|
|
12
|
+
|
|
13
|
+
@size.times do |index|
|
|
14
|
+
@acks[index] = {
|
|
15
|
+
'R' => 0,
|
|
16
|
+
'U' => Time.at(0),
|
|
17
|
+
'X' => Time.at(0)
|
|
18
|
+
}
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def acks
|
|
23
|
+
@acks_mutex.synchronize do
|
|
24
|
+
return @acks.dup
|
|
25
|
+
end
|
|
11
26
|
end
|
|
12
27
|
|
|
13
28
|
def add_ack(ack)
|
|
29
|
+
@acks_mutex.synchronize do
|
|
14
30
|
@acks << ack
|
|
15
31
|
|
|
16
|
-
if @acks.length > @size
|
|
17
|
-
|
|
18
|
-
end
|
|
32
|
+
@acks.shift if @acks.length > @size
|
|
33
|
+
end
|
|
19
34
|
end
|
|
20
|
-
|
|
35
|
+
end
|
|
21
36
|
end
|
data/lib/session.rb
CHANGED
|
@@ -2,228 +2,242 @@
|
|
|
2
2
|
# sharable_constant_value: literal
|
|
3
3
|
|
|
4
4
|
module SPing
|
|
5
|
+
require_relative 'errors'
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
class Session
|
|
8
|
+
attr_reader :created, :last_rx, :madebyme
|
|
9
|
+
attr_accessor :session_id, :tcp_handshake_complete, :udp_handshake_complete
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
require 'socket'
|
|
12
|
+
require 'timeout'
|
|
13
|
+
require 'msgpack'
|
|
14
|
+
require_relative 'last_acks'
|
|
10
15
|
|
|
11
|
-
|
|
12
|
-
|
|
16
|
+
def initialize(host, port, socket, madebyme)
|
|
17
|
+
@host = host
|
|
18
|
+
@port = port
|
|
19
|
+
@socket = socket
|
|
20
|
+
@created = Time.now
|
|
21
|
+
@madebyme = madebyme
|
|
13
22
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
require 'msgpack'
|
|
17
|
-
require_relative 'last_acks'
|
|
23
|
+
@tcp_handshake_complete = false
|
|
24
|
+
@udp_handshake_complete = false
|
|
18
25
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
@port = port
|
|
22
|
-
@socket = socket
|
|
23
|
-
@created = Time.now
|
|
24
|
-
@madebyme = madebyme
|
|
26
|
+
@last_acks = SPing::LastAcks.new
|
|
27
|
+
end
|
|
25
28
|
|
|
26
|
-
|
|
27
|
-
|
|
29
|
+
def do_tcp_handshake1(socket, session_id)
|
|
30
|
+
socket.write "sping-0.3-https://codeberg.org/mark22k/sping\r\n"
|
|
31
|
+
socket.flush
|
|
28
32
|
|
|
29
|
-
|
|
30
|
-
end
|
|
33
|
+
invite = socket.readpartial 6
|
|
31
34
|
|
|
32
|
-
|
|
33
|
-
socket.write "sping-0.3-https://codeberg.org/mark22k/sping\r\n"
|
|
34
|
-
socket.flush
|
|
35
|
+
raise TCPHandshakeError, 'Peer didn\'t invite us.' unless invite.chomp == 'INVITE'
|
|
35
36
|
|
|
36
|
-
|
|
37
|
+
@session_id = session_id
|
|
37
38
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
end
|
|
39
|
+
socket.write "#{session_id}\r\n"
|
|
40
|
+
socket.flush
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
socket.close
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
@tcp_handshake_complete = true
|
|
45
|
+
end
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
def do_tcp_handshake2
|
|
48
|
+
socket = TCPSocket.new @host, @port
|
|
49
|
+
banner = socket.readpartial 9001
|
|
50
|
+
if banner.length > 9000
|
|
51
|
+
socket.close
|
|
52
|
+
raise TCPHandshakeError, 'Host banner too big'
|
|
53
|
+
end
|
|
48
54
|
|
|
49
|
-
|
|
50
|
-
|
|
55
|
+
unless banner.start_with? 'sping-0.3-'
|
|
56
|
+
socket.close
|
|
57
|
+
raise TCPHandshakeError, 'Host banner not sping'
|
|
58
|
+
end
|
|
51
59
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
banner = socket.readpartial 9001
|
|
55
|
-
if banner.length > 9000
|
|
56
|
-
socket.close
|
|
57
|
-
raise TCPHandshakeError, 'Host banner too big'
|
|
58
|
-
end
|
|
60
|
+
@remote_version = banner.chomp
|
|
61
|
+
$logger.info "Peer uses the following program version: #{@remote_version.dump}"
|
|
59
62
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
raise TCPHandshakeError, 'Host banner not sping'
|
|
63
|
-
end
|
|
63
|
+
socket.write "INVITE\r\n"
|
|
64
|
+
socket.flush
|
|
64
65
|
|
|
65
|
-
|
|
66
|
-
|
|
66
|
+
invite_buf = socket.readpartial 32
|
|
67
|
+
if invite_buf.length > 31 || invite_buf.empty?
|
|
68
|
+
socket.close
|
|
69
|
+
raise TCPHandshakeError, 'Invite banner wrong size'
|
|
70
|
+
end
|
|
67
71
|
|
|
68
|
-
|
|
69
|
-
socket.flush
|
|
72
|
+
@session_id = invite_buf.chomp.to_i
|
|
70
73
|
|
|
71
|
-
|
|
72
|
-
if inviteBuf.length > 31 || inviteBuf.length.zero?
|
|
73
|
-
socket.close
|
|
74
|
-
raise TCPHandshakeError, 'Invite banner wrong size'
|
|
75
|
-
end
|
|
74
|
+
socket.close
|
|
76
75
|
|
|
77
|
-
|
|
76
|
+
@tcp_handshake_complete = true
|
|
77
|
+
end
|
|
78
78
|
|
|
79
|
-
|
|
79
|
+
def set_endpoint(host, port)
|
|
80
|
+
@host = host
|
|
81
|
+
@port = port
|
|
82
|
+
end
|
|
80
83
|
|
|
81
|
-
|
|
82
|
-
|
|
84
|
+
def start_udp_handshake_sender(send_interval = 5)
|
|
85
|
+
raise 'UDP handshake sender is already running.' if @udp_handshake_sender
|
|
83
86
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
+
@udp_handshake_sender = Thread.new(send_interval) do |th_send_interval|
|
|
88
|
+
loop do
|
|
89
|
+
send_udp_handshake
|
|
90
|
+
sleep th_send_interval
|
|
87
91
|
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
88
94
|
|
|
89
|
-
|
|
90
|
-
|
|
95
|
+
def send_udp_handshake
|
|
96
|
+
packet = {
|
|
97
|
+
'Y' => 'h'.ord,
|
|
98
|
+
'M' => 11_181,
|
|
99
|
+
'V' => 3,
|
|
100
|
+
'S' => @session_id
|
|
101
|
+
}.to_msgpack
|
|
91
102
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
sleep send_interval
|
|
96
|
-
end
|
|
97
|
-
end
|
|
98
|
-
end
|
|
103
|
+
$logger.debug "Send UDP handshake to #{@host} port #{@port}."
|
|
104
|
+
@socket.send packet, 0, @host, @port
|
|
105
|
+
end
|
|
99
106
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
'Y' => 'h'.ord,
|
|
103
|
-
'M' => 11181,
|
|
104
|
-
'V' => 3,
|
|
105
|
-
'S' => @session_id
|
|
106
|
-
}.to_msgpack
|
|
107
|
+
def stop_udp_handshake_sender
|
|
108
|
+
raise 'UDP handshake sender is not running and therefore cannot be terminated.' unless @udp_handshake_sender
|
|
107
109
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
110
|
+
@udp_handshake_sender.kill
|
|
111
|
+
@udp_handshake_sender = nil
|
|
112
|
+
end
|
|
111
113
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
end
|
|
114
|
+
def udp_handshake_recived
|
|
115
|
+
stop_udp_handshake_sender if @madebyme
|
|
116
|
+
@udp_handshake_complete = true
|
|
117
|
+
end
|
|
117
118
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
119
|
+
def start_pinger
|
|
120
|
+
raise 'Pinger is already running.' if @pinger
|
|
121
|
+
|
|
122
|
+
@pinger = Thread.new do
|
|
123
|
+
loop do
|
|
124
|
+
ping
|
|
125
|
+
sleep 1
|
|
121
126
|
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
122
129
|
|
|
123
|
-
|
|
124
|
-
|
|
130
|
+
def stop_pinger
|
|
131
|
+
raise 'Pinger sender is not running and therefore cannot be terminated.' unless @pinger
|
|
125
132
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
sleep 1
|
|
130
|
-
end
|
|
131
|
-
end
|
|
132
|
-
end
|
|
133
|
+
@pinger.kill
|
|
134
|
+
@pinger = nil
|
|
135
|
+
end
|
|
133
136
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
137
|
+
def ping
|
|
138
|
+
current_id = (Time.now.to_i % 255) + 1
|
|
139
|
+
data = {
|
|
140
|
+
'Y' => 't'.ord,
|
|
141
|
+
'M' => 11_181,
|
|
142
|
+
'S' => @session_id,
|
|
143
|
+
'I' => current_id,
|
|
144
|
+
'T' => Time.now,
|
|
145
|
+
'E' => 0,
|
|
146
|
+
'A' => @last_acks.acks
|
|
147
|
+
}.to_msgpack
|
|
148
|
+
|
|
149
|
+
$logger.debug "Send ping to #{@host} port #{@port}."
|
|
150
|
+
@socket.send data, 0, @host, @port
|
|
151
|
+
end
|
|
139
152
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
153
|
+
def stop
|
|
154
|
+
@pinger&.kill
|
|
155
|
+
@udp_handshake_sender&.kill
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def handle_ping(packet, rxtime, peeraddr)
|
|
159
|
+
if !(packet.keys - %w[M Y E I T A S]).empty? ||
|
|
160
|
+
# M, Y are already checked in handle_packet from session manager
|
|
161
|
+
!packet['E'].is_a?(Integer) ||
|
|
162
|
+
!packet['I'].is_a?(Integer) ||
|
|
163
|
+
!packet['T'].is_a?(Time) ||
|
|
164
|
+
!packet['A'].is_a?(Array)
|
|
165
|
+
raise InvalidPacketError, 'The peer has sent an invalid message. The ping packet is incorrectly coded.'
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
raise SignalizedError, "Package displays an error message: #{packet['E']} Processing aborted." if packet['E'] != 0
|
|
169
|
+
|
|
170
|
+
id = packet['I']
|
|
171
|
+
txtime = packet['T']
|
|
172
|
+
remote_last_acks = packet['A']
|
|
173
|
+
|
|
174
|
+
if remote_last_acks.length != 32
|
|
175
|
+
raise InvalidMessageError, 'The peer has sent an invalid message. It does not contain any 32-Acks.'
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
remote_last_acks.each do |block_ack|
|
|
179
|
+
if !(block_ack.keys - %w[R U X]).empty? ||
|
|
180
|
+
!block_ack['R'].is_a?(Integer) ||
|
|
181
|
+
!block_ack['U'].is_a?(Time) ||
|
|
182
|
+
!block_ack['X'].is_a?(Time)
|
|
183
|
+
raise InvalidMessageError, 'The peer has sent an invalid message. An Ack is formatted invalid.'
|
|
154
184
|
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
ack = {
|
|
188
|
+
'R' => id,
|
|
189
|
+
'U' => txtime,
|
|
190
|
+
'X' => rxtime
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
@last_acks.add_ack(ack)
|
|
194
|
+
@last_rx = rxtime
|
|
195
|
+
|
|
196
|
+
# This is the A
|
|
197
|
+
# The peer is B
|
|
198
|
+
# TX => Time from A to B
|
|
199
|
+
# RX => Time from B to A
|
|
200
|
+
|
|
201
|
+
# Remove zero acks
|
|
202
|
+
remote_last_acks.reject! { |block_ack| block_ack['R'].zero? }
|
|
203
|
+
|
|
204
|
+
# Sort by TX time
|
|
205
|
+
remote_last_acks.sort_by! { |block_ack| block_ack['U'] }
|
|
206
|
+
|
|
207
|
+
newest_remote_ack = remote_last_acks.last.to_h
|
|
208
|
+
|
|
209
|
+
# Calculate loss
|
|
210
|
+
tx_loss = 0
|
|
211
|
+
rx_loss = 0
|
|
212
|
+
exchanges = 0
|
|
213
|
+
|
|
214
|
+
if remote_last_acks.length == 32
|
|
215
|
+
# We have enough data
|
|
216
|
+
exchanges = 32
|
|
217
|
+
remote_acks = remote_last_acks.map { |ack| ack['R'] }
|
|
218
|
+
local_acks = @last_acks.acks.map { |ack| ack['R'] }
|
|
219
|
+
|
|
220
|
+
tip_id = (Time.now.to_i % 255) + 1
|
|
221
|
+
starting = tip_id - 32
|
|
222
|
+
|
|
223
|
+
last_ids = if tip_id > 32
|
|
224
|
+
(starting..tip_id).to_a
|
|
225
|
+
else
|
|
226
|
+
((255 + starting)..255).to_a + (1..tip_id).to_a
|
|
227
|
+
end
|
|
155
228
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
@udp_handshake_sender&.kill
|
|
229
|
+
last_ids[0...-1].each do |id|
|
|
230
|
+
tx_loss += 1 unless remote_acks.include? id
|
|
159
231
|
end
|
|
160
232
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
$logger.error "Package displays an error message: #{packet['E']} Processing aborted."
|
|
164
|
-
return
|
|
165
|
-
end
|
|
166
|
-
|
|
167
|
-
id = packet['I']
|
|
168
|
-
txtime = packet['T']
|
|
169
|
-
lastAcks = packet['A']
|
|
170
|
-
|
|
171
|
-
ack = {
|
|
172
|
-
'R' => id,
|
|
173
|
-
'U' => txtime,
|
|
174
|
-
'X' => rxtime
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
@last_acks.add_ack(ack)
|
|
178
|
-
@last_rx = rxtime
|
|
179
|
-
|
|
180
|
-
# This is the A
|
|
181
|
-
# The peer is B
|
|
182
|
-
# TX => Time from A to B
|
|
183
|
-
# RX => Time from B to A
|
|
184
|
-
|
|
185
|
-
# Remove zero acks
|
|
186
|
-
lastAcks.reject! { |ack| ack['R'] == 0 }
|
|
187
|
-
|
|
188
|
-
# Sort by TX time
|
|
189
|
-
lastAcks.sort_by! { |ack| ack['U'] }
|
|
190
|
-
|
|
191
|
-
newest_remote_ack = lastAcks.last.to_h
|
|
192
|
-
|
|
193
|
-
# Calculate loss
|
|
194
|
-
tx_loss = 0
|
|
195
|
-
rx_loss = 0
|
|
196
|
-
exchanges = 0
|
|
197
|
-
|
|
198
|
-
if lastAcks.length == 32
|
|
199
|
-
# We have enough data
|
|
200
|
-
exchanges = 32
|
|
201
|
-
remote_acks = lastAcks.map { |ack| ack['R'] }
|
|
202
|
-
local_acks = @last_acks.acks.map { |ack| ack['R'] }
|
|
203
|
-
|
|
204
|
-
tip_id = Time.now.to_i % 255 + 1
|
|
205
|
-
starting = tip_id - 32
|
|
206
|
-
|
|
207
|
-
last_ids = nil
|
|
208
|
-
if tip_id > 32
|
|
209
|
-
last_ids = (starting..tip_id).to_a
|
|
210
|
-
else
|
|
211
|
-
last_ids = ((255 + starting)..255).to_a + (1..tip_id).to_a
|
|
212
|
-
end
|
|
213
|
-
|
|
214
|
-
last_ids[0...-1].each do |id|
|
|
215
|
-
tx_loss += 1 unless remote_acks.include? id
|
|
216
|
-
end
|
|
217
|
-
|
|
218
|
-
last_ids[1..-1].each do |id|
|
|
219
|
-
rx_loss += 1 unless local_acks.include? id
|
|
220
|
-
end
|
|
221
|
-
end
|
|
222
|
-
|
|
223
|
-
tx_latency = ((newest_remote_ack['X'].to_f - newest_remote_ack['U'].to_f) * 1000.0).round(6)
|
|
224
|
-
rx_latency = ((rxtime - txtime) * 1000.0).round(6)
|
|
225
|
-
puts "[#{peeraddr}] RX: #{rx_latency}ms TX: #{tx_latency}ms [Loss RX: #{rx_loss}/#{exchanges} | Loss TX: #{tx_loss}/#{exchanges}]"
|
|
233
|
+
last_ids[1..].each do |id|
|
|
234
|
+
rx_loss += 1 unless local_acks.include? id
|
|
226
235
|
end
|
|
236
|
+
end
|
|
227
237
|
|
|
238
|
+
tx_latency = ((newest_remote_ack['X'].to_f - newest_remote_ack['U'].to_f) * 1000.0).round(6)
|
|
239
|
+
rx_latency = ((rxtime - txtime) * 1000.0).round(6)
|
|
240
|
+
puts "[#{peeraddr}] RX: #{rx_latency}ms TX: #{tx_latency}ms [Loss RX: #{rx_loss}/#{exchanges} | Loss TX: #{tx_loss}/#{exchanges}]"
|
|
228
241
|
end
|
|
242
|
+
end
|
|
229
243
|
end
|
data/lib/session_manager.rb
CHANGED
|
@@ -2,261 +2,285 @@
|
|
|
2
2
|
# sharable_constant_value: literal
|
|
3
3
|
|
|
4
4
|
module SPing
|
|
5
|
+
require_relative 'errors'
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
class SessionManager
|
|
8
|
+
require 'socket'
|
|
9
|
+
require 'timeout'
|
|
10
|
+
require_relative 'session'
|
|
8
11
|
|
|
9
|
-
|
|
12
|
+
def initialize(host = '::', port = 6924)
|
|
13
|
+
@host = host
|
|
14
|
+
@port = port
|
|
10
15
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
require_relative 'session'
|
|
16
|
+
@sessions = {}
|
|
17
|
+
@sessions_mutex = Mutex.new
|
|
14
18
|
|
|
15
|
-
|
|
16
|
-
|
|
19
|
+
@socket = UDPSocket.new(Socket::AF_INET6)
|
|
20
|
+
@socket.bind @host, @port
|
|
21
|
+
end
|
|
17
22
|
|
|
18
|
-
|
|
19
|
-
|
|
23
|
+
def new_session(host, port = 6924)
|
|
24
|
+
$logger.info "Add new session for host #{host} port #{port}."
|
|
25
|
+
session = SPing::Session.new host, port, @socket, true
|
|
20
26
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
27
|
+
counter = 0
|
|
28
|
+
loop do
|
|
29
|
+
session_id = request_session session
|
|
30
|
+
break if session_id
|
|
24
31
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
begin
|
|
30
|
-
counter = 0
|
|
31
|
-
loop do
|
|
32
|
-
begin
|
|
33
|
-
session.do_tcp_handshake2
|
|
34
|
-
rescue Errno::ECONNRESET, EOFError, IO::TimeoutError, TCPHandshakeError => e
|
|
35
|
-
$logger.warn "TCP handshake failed: #{e.message}"
|
|
36
|
-
return
|
|
37
|
-
end
|
|
38
|
-
if ! @sessions.key? session.session_id
|
|
39
|
-
break
|
|
40
|
-
end
|
|
41
|
-
if counter > 5
|
|
42
|
-
raise OutOfSessions, 'The peer could not name a session ID that had not yet been assigned.'
|
|
43
|
-
end
|
|
44
|
-
counter += 1
|
|
45
|
-
end
|
|
46
|
-
rescue OutOfSessions => e
|
|
47
|
-
$logger.error "Out of session: #{e.message}"
|
|
48
|
-
return
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
$logger.info "TCP handshake for host #{host} port #{port} successful. Session ID is #{session.session_id}."
|
|
52
|
-
|
|
53
|
-
$logger.info "Perform UDP handshake for session ID #{session.session_id}."
|
|
54
|
-
session.start_udp_handshake_sender
|
|
55
|
-
|
|
56
|
-
@sessions[session.session_id] = session
|
|
32
|
+
# rubocop:disable Style/IfUnlessModifier
|
|
33
|
+
if counter > 5
|
|
34
|
+
raise OutOfSessions, 'The peer could not name a session ID that had not yet been assigned.'
|
|
57
35
|
end
|
|
36
|
+
# rubocop:enable Style/IfUnlessModifier
|
|
58
37
|
|
|
59
|
-
|
|
60
|
-
|
|
38
|
+
counter += 1
|
|
39
|
+
end
|
|
61
40
|
|
|
62
|
-
|
|
63
|
-
@sessions.delete(session_id)
|
|
64
|
-
end
|
|
41
|
+
$logger.info "TCP handshake for host #{host} port #{port} successful. Session ID is #{session.session_id}."
|
|
65
42
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
43
|
+
$logger.info "Perform UDP handshake for session ID #{session.session_id}."
|
|
44
|
+
session.start_udp_handshake_sender
|
|
45
|
+
rescue Errno::ECONNRESET, EOFError, IO::TimeoutError, TCPHandshakeError => e
|
|
46
|
+
$logger.warn "TCP handshake failed: #{e.message}"
|
|
47
|
+
rescue OutOfSessions => e
|
|
48
|
+
$logger.error "Out of session: #{e.message}"
|
|
49
|
+
end
|
|
71
50
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
@gc_thread&.join
|
|
75
|
-
@server_thread&.join
|
|
76
|
-
end
|
|
51
|
+
def del_session(session_id)
|
|
52
|
+
$logger.debug "Delete session with session id #{session_id}"
|
|
77
53
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
loop do
|
|
84
|
-
Thread.new(@socket.recvfrom(10000)) do |buf|
|
|
85
|
-
handle_packet buf
|
|
86
|
-
end
|
|
87
|
-
end
|
|
88
|
-
end
|
|
89
|
-
@gc_thread = Thread.new do
|
|
90
|
-
loop do
|
|
91
|
-
do_gc
|
|
92
|
-
sleep 10
|
|
93
|
-
end
|
|
94
|
-
end
|
|
95
|
-
end
|
|
54
|
+
@sessions_mutex.synchronize do
|
|
55
|
+
@sessions[session_id].stop
|
|
56
|
+
@sessions.delete(session_id)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
96
59
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
60
|
+
def stop_sessions
|
|
61
|
+
@sessions.each do |_session_id, session|
|
|
62
|
+
session.stop
|
|
63
|
+
end
|
|
64
|
+
end
|
|
100
65
|
|
|
101
|
-
|
|
66
|
+
def join
|
|
67
|
+
@runner&.join
|
|
68
|
+
@gc_thread&.join
|
|
69
|
+
@server_thread&.join
|
|
70
|
+
end
|
|
102
71
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
handle_client client
|
|
107
|
-
end
|
|
108
|
-
end
|
|
109
|
-
end
|
|
72
|
+
def run
|
|
73
|
+
raise 'Client is already running.' if @runner
|
|
74
|
+
raise 'GC is already running.' if @gc
|
|
110
75
|
|
|
111
|
-
|
|
76
|
+
@runner = Thread.new do
|
|
77
|
+
loop do
|
|
78
|
+
Thread.new(@socket.recvfrom(10_000)) do |buf|
|
|
79
|
+
handle_packet buf
|
|
80
|
+
end
|
|
112
81
|
end
|
|
82
|
+
end
|
|
83
|
+
@gc_thread = Thread.new do
|
|
84
|
+
loop do
|
|
85
|
+
do_gc
|
|
86
|
+
sleep 10
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
113
90
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
@server_thread.kill
|
|
118
|
-
@server_thread = nil
|
|
91
|
+
def run_server
|
|
92
|
+
raise 'Server is already running.' if @server_thread
|
|
93
|
+
raise 'Server already exist.' if @server
|
|
119
94
|
|
|
120
|
-
|
|
95
|
+
@server = TCPServer.new @host, @port
|
|
121
96
|
|
|
122
|
-
|
|
123
|
-
|
|
97
|
+
@server_thread = Thread.new do
|
|
98
|
+
loop do
|
|
99
|
+
Thread.new(@server.accept) do |client|
|
|
100
|
+
handle_client client
|
|
101
|
+
end
|
|
124
102
|
end
|
|
103
|
+
end
|
|
125
104
|
|
|
126
|
-
|
|
127
|
-
|
|
105
|
+
return @server_thread
|
|
106
|
+
end
|
|
128
107
|
|
|
129
|
-
|
|
130
|
-
|
|
108
|
+
def stop_server
|
|
109
|
+
raise 'Server thread is not running.' unless @server_thread
|
|
131
110
|
|
|
132
|
-
|
|
111
|
+
@server_thread.kill
|
|
112
|
+
@server_thread = nil
|
|
133
113
|
|
|
134
|
-
|
|
135
|
-
@gc_thread = nil
|
|
136
|
-
end
|
|
114
|
+
raise 'Server is not running.' unless @server
|
|
137
115
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
while @sessions.key? session_id
|
|
142
|
-
session_id = rand(2 ** 32 - 1)
|
|
143
|
-
end
|
|
116
|
+
@server.close
|
|
117
|
+
@server = nil
|
|
118
|
+
end
|
|
144
119
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
end
|
|
120
|
+
def stop
|
|
121
|
+
raise 'Session manager is not running.' unless @runner
|
|
148
122
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
def do_gc
|
|
152
|
-
$logger.debug 'Remove outdated sessions.'
|
|
153
|
-
@sessions.keys.each do |session_id|
|
|
154
|
-
session = @sessions[session_id]
|
|
155
|
-
# It is possible that sessions no longer exist here, as we may have session IDs that have already been deleted.
|
|
156
|
-
# However, we can ignore this aspect here, as we are the only function that deletes sessions.
|
|
157
|
-
if ! (session.tcp_handshake_complete && session.udp_handshake_complete)
|
|
158
|
-
# Handshake incomplete
|
|
159
|
-
if (Time.now.to_i - session.created.to_i) > 60
|
|
160
|
-
# TCP and/or UDP take more than 60 seconds.
|
|
161
|
-
$logger.debug "UDP handshake for session id #{session_id} timed out."
|
|
162
|
-
del_session session_id
|
|
163
|
-
end
|
|
164
|
-
elsif (Time.now.to_i - session.last_rx.to_i) > 30
|
|
165
|
-
# 30 seconds have elapsed since the last ping from the peer was received. The peer is probably dead.
|
|
166
|
-
$logger.debug "Session id #{session_id} without activity for over thirty seconds."
|
|
167
|
-
del_session session_id
|
|
168
|
-
end
|
|
169
|
-
end
|
|
170
|
-
end
|
|
123
|
+
@runner.kill
|
|
124
|
+
@runner = nil
|
|
171
125
|
|
|
172
|
-
|
|
173
|
-
rxtime = Time.now
|
|
174
|
-
|
|
175
|
-
peeraddr = buf[1][2]
|
|
176
|
-
peerport = buf[1][1]
|
|
177
|
-
$logger.debug "Packet received from #{peeraddr} port #{peerport}."
|
|
178
|
-
|
|
179
|
-
packet = MessagePack.unpack(buf[0])
|
|
180
|
-
|
|
181
|
-
# Check whether the Magic Number is the correct one.
|
|
182
|
-
if packet['M'] != 11181
|
|
183
|
-
$logger.warn 'Package contains incorrect magic number. Processing is canceled.'
|
|
184
|
-
return
|
|
185
|
-
end
|
|
186
|
-
|
|
187
|
-
type = packet['Y'].chr
|
|
188
|
-
case type
|
|
189
|
-
when 'h'
|
|
190
|
-
handle_udp_handshake packet, peeraddr, peerport
|
|
191
|
-
when 't'
|
|
192
|
-
handle_ping packet, rxtime, peeraddr, peerport
|
|
193
|
-
else
|
|
194
|
-
$logger.warn "Package is of unknown type: #{type}"
|
|
195
|
-
end
|
|
196
|
-
end
|
|
126
|
+
raise 'Session GC is not running.' unless @gc_thread
|
|
197
127
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
session.set_endpoint peeraddr, peerport
|
|
208
|
-
|
|
209
|
-
if ! session.madebyme
|
|
210
|
-
# If the session was not started by me, the other peer expects a
|
|
211
|
-
# confirmation of the handshake in which the same is sent back.
|
|
212
|
-
$logger.debug "Send UDP Handshake back for session id #{session_id}."
|
|
213
|
-
session.send_udp_handshake
|
|
214
|
-
end
|
|
215
|
-
|
|
216
|
-
if session.udp_handshake_complete
|
|
217
|
-
$logger.warn 'UDP handshake is received, although a previous one was already successful.'
|
|
218
|
-
return
|
|
219
|
-
end
|
|
220
|
-
|
|
221
|
-
session.udp_handshake_recived
|
|
222
|
-
$logger.info "UDP handshake for session ID #{session_id} was successful."
|
|
223
|
-
|
|
224
|
-
session.start_pinger
|
|
225
|
-
else
|
|
226
|
-
$logger.warn 'UDP handshake received for uninitiated session.'
|
|
227
|
-
return
|
|
228
|
-
end
|
|
128
|
+
@gc_thread.kill
|
|
129
|
+
@gc_thread = nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def generate_session_id
|
|
133
|
+
Timeout.timeout(30, OutOfSessions, 'No session ID could be generated which has not yet been used.') do
|
|
134
|
+
loop do
|
|
135
|
+
session_id = rand (2**32) - 1
|
|
136
|
+
return session_id unless @sessions.key? session_id
|
|
229
137
|
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
230
140
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
141
|
+
private
|
|
142
|
+
|
|
143
|
+
def do_gc
|
|
144
|
+
$logger.debug 'Remove outdated sessions.'
|
|
145
|
+
# rubocop:disable Style/HashEachMethods
|
|
146
|
+
# You cannot run through a map and edit it at the same time. Since this is necessary due to the threading,
|
|
147
|
+
# a snapshot of the key variable is run through.
|
|
148
|
+
@sessions.keys.each do |session_id|
|
|
149
|
+
# rubocop:enable Style/HashEachMethods
|
|
150
|
+
|
|
151
|
+
session = @sessions[session_id]
|
|
152
|
+
# It is possible that sessions no longer exist here, as we may have session IDs that have already been deleted.
|
|
153
|
+
# However, we can ignore this aspect here, as we are the only function that deletes sessions.
|
|
154
|
+
if !(session.tcp_handshake_complete && session.udp_handshake_complete)
|
|
155
|
+
# Handshake incomplete
|
|
156
|
+
if (Time.now.to_i - session.created.to_i) > 60
|
|
157
|
+
# TCP and/or UDP take more than 60 seconds.
|
|
158
|
+
$logger.debug "UDP handshake for session id #{session_id} timed out."
|
|
159
|
+
del_session session_id
|
|
160
|
+
end
|
|
161
|
+
elsif (Time.now.to_i - session.last_rx.to_i) > 30
|
|
162
|
+
# 30 seconds have elapsed since the last ping from the peer was received. The peer is probably dead.
|
|
163
|
+
$logger.debug "Session id #{session_id} without activity for over thirty seconds."
|
|
164
|
+
del_session session_id
|
|
240
165
|
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def request_session(session)
|
|
170
|
+
session.do_tcp_handshake2
|
|
241
171
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
$logger.debug "Session request from host #{host} port #{port}."
|
|
248
|
-
|
|
249
|
-
begin
|
|
250
|
-
session.do_tcp_handshake1 client, self.generate_session_id
|
|
251
|
-
rescue Errno::ECONNRESET, EOFError, IO::TimeoutError, TCPHandshakeError => e
|
|
252
|
-
$logger.warn "Could not establish a new session with Peer: #{e.message}"
|
|
253
|
-
return
|
|
254
|
-
rescue OutOfSessions => e
|
|
255
|
-
$logger.error "Could not establish a new session with Peer: #{e.message}"
|
|
256
|
-
return
|
|
257
|
-
end
|
|
258
|
-
|
|
259
|
-
@sessions[session.session_id] = session
|
|
172
|
+
@sessions_mutex.synchronize do
|
|
173
|
+
unless @sessions.key? session.session_id
|
|
174
|
+
@sessions[session.session_id] = session
|
|
175
|
+
return session.session_id
|
|
260
176
|
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
return nil
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def handle_packet(buf)
|
|
183
|
+
rxtime = Time.now
|
|
184
|
+
|
|
185
|
+
peeraddr = buf[1][2]
|
|
186
|
+
peerport = buf[1][1]
|
|
187
|
+
$logger.debug "Packet received from #{peeraddr} port #{peerport}."
|
|
188
|
+
|
|
189
|
+
packet = MessagePack.unpack(buf[0]).to_h
|
|
190
|
+
|
|
191
|
+
if !packet.key?('M') ||
|
|
192
|
+
!packet.key?('Y') ||
|
|
193
|
+
!packet.key?('S') ||
|
|
194
|
+
!packet['M'].is_a?(Integer) ||
|
|
195
|
+
!packet['Y'].is_a?(Integer) ||
|
|
196
|
+
!packet['S'].is_a?(Integer)
|
|
197
|
+
raise InvalidMessageError, 'The peer has sent an invalid message. The packet is incorrectly coded.'
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Check whether the Magic Number is the correct one.
|
|
201
|
+
if packet['M'] != 11_181
|
|
202
|
+
raise UnknownPacketError, 'Package contains incorrect magic number. Processing is canceled.'
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
type = packet['Y'].chr
|
|
206
|
+
case type
|
|
207
|
+
when 'h'
|
|
208
|
+
handle_udp_handshake packet, peeraddr, peerport
|
|
209
|
+
when 't'
|
|
210
|
+
handle_ping packet, rxtime, peeraddr, peerport
|
|
211
|
+
else
|
|
212
|
+
raise UnknownPacketError, "Package is of unknown type: #{type}"
|
|
213
|
+
end
|
|
214
|
+
rescue MessagePack::MalformedFormatError => e
|
|
215
|
+
$logger.error "Packet cannot be decoded: #{e.message}"
|
|
216
|
+
rescue PeerError => e
|
|
217
|
+
$logger.error "Perr error: #{e.message}"
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def handle_udp_handshake(packet, peeraddr, peerport)
|
|
221
|
+
if !(packet.keys - %w[M Y V S]).empty? ||
|
|
222
|
+
# M, Y are already checked in handle_packet from session manager
|
|
223
|
+
!packet['V'].is_a?(Integer)
|
|
224
|
+
raise InvalidMessageError, 'The peer has sent an invalid message. The handshake packet is incorrectly coded.'
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
session_id = packet['S']
|
|
228
|
+
session = @sessions[session_id]
|
|
229
|
+
raise UnknownSessionError, 'UDP handshake received for uninitiated session.' unless session
|
|
230
|
+
raise UnknownVersionError, "UDP handshake uses an unsupported version: #{packet['V']}" if packet['V'] != 3
|
|
231
|
+
|
|
232
|
+
session.set_endpoint peeraddr, peerport
|
|
233
|
+
|
|
234
|
+
unless session.madebyme
|
|
235
|
+
# If the session was not started by me, the other peer expects a
|
|
236
|
+
# confirmation of the handshake in which the same is sent back.
|
|
237
|
+
$logger.debug "Send UDP Handshake back for session id #{session_id}."
|
|
238
|
+
session.send_udp_handshake
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
if session.udp_handshake_complete
|
|
242
|
+
$logger.warn 'UDP handshake is received, although a previous one was already successful.'
|
|
243
|
+
return
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
session.udp_handshake_recived
|
|
247
|
+
$logger.info "UDP handshake for session ID #{session_id} was successful."
|
|
248
|
+
|
|
249
|
+
session.start_pinger
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def handle_ping(packet, rxtime, peeraddr, peerport)
|
|
253
|
+
session_id = packet['S']
|
|
254
|
+
session = @sessions[session_id]
|
|
255
|
+
unless session&.tcp_handshake_complete && session&.udp_handshake_complete
|
|
256
|
+
raise UnknownSessionError, "Ping packet received for non-initiated session id #{session_id}."
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
session.set_endpoint peeraddr, peerport
|
|
260
|
+
session.handle_ping packet, rxtime, peeraddr
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def handle_client(client)
|
|
264
|
+
host = client.peeraddr[3]
|
|
265
|
+
port = client.peeraddr[1]
|
|
266
|
+
session = SPing::Session.new host, port, @socket, false
|
|
267
|
+
|
|
268
|
+
$logger.debug "Session request from host #{host} port #{port}."
|
|
269
|
+
|
|
270
|
+
session_id = nil
|
|
271
|
+
|
|
272
|
+
@sessions_mutex.synchronize do
|
|
273
|
+
session_id = generate_session_id
|
|
274
|
+
@sessions[session_id] = session
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
session.do_tcp_handshake1 client, session_id
|
|
278
|
+
rescue Errno::ECONNRESET, EOFError, IO::TimeoutError, TCPHandshakeError => e
|
|
279
|
+
$logger.warn "Could not establish a new session with Peer: #{e.message}"
|
|
280
|
+
del_session session_id
|
|
281
|
+
rescue OutOfSessions => e
|
|
282
|
+
$logger.error "Could not establish a new session with Peer: #{e.message}"
|
|
283
|
+
del_session session_id
|
|
261
284
|
end
|
|
285
|
+
end
|
|
262
286
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: sping
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.0.
|
|
4
|
+
version: 1.0.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Marek Küthe
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2023-11-
|
|
11
|
+
date: 2023-11-10 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: msgpack
|
|
@@ -43,6 +43,7 @@ files:
|
|
|
43
43
|
- LICENSE
|
|
44
44
|
- README.md
|
|
45
45
|
- bin/sping
|
|
46
|
+
- lib/errors.rb
|
|
46
47
|
- lib/last_acks.rb
|
|
47
48
|
- lib/session.rb
|
|
48
49
|
- lib/session_manager.rb
|
|
@@ -68,7 +69,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
68
69
|
- !ruby/object:Gem::Version
|
|
69
70
|
version: '0'
|
|
70
71
|
requirements: []
|
|
71
|
-
rubygems_version: 3.4.
|
|
72
|
+
rubygems_version: 3.4.22
|
|
72
73
|
signing_key:
|
|
73
74
|
specification_version: 4
|
|
74
75
|
summary: Reimplementation of sping in Ruby.
|