anycable 0.6.3 → 1.0.0.rc1
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/CHANGELOG.md +23 -222
- data/MIT-LICENSE +1 -1
- data/README.md +21 -15
- data/lib/anycable.rb +3 -12
- data/lib/anycable/broadcast_adapters.rb +5 -3
- data/lib/anycable/broadcast_adapters/base.rb +29 -0
- data/lib/anycable/broadcast_adapters/http.rb +130 -0
- data/lib/anycable/broadcast_adapters/redis.rb +7 -6
- data/lib/anycable/cli.rb +24 -7
- data/lib/anycable/config.rb +44 -21
- data/lib/anycable/exceptions_handling.rb +5 -7
- data/lib/anycable/health_server.rb +6 -4
- data/lib/anycable/middleware.rb +9 -1
- data/lib/anycable/middleware_chain.rb +2 -2
- data/lib/anycable/middlewares/check_version.rb +24 -0
- data/lib/anycable/rpc.rb +84 -0
- data/lib/anycable/rpc/rpc_pb.rb +57 -39
- data/lib/anycable/rpc/rpc_services_pb.rb +4 -3
- data/lib/anycable/rpc_handler.rb +65 -25
- data/lib/anycable/rspec.rb +6 -0
- data/lib/anycable/rspec/rpc_command_context.rb +20 -0
- data/lib/anycable/rspec/rpc_stub_context.rb +13 -0
- data/lib/anycable/rspec/with_grpc_server.rb +15 -0
- data/lib/anycable/server.rb +7 -63
- data/lib/anycable/socket.rb +48 -6
- data/lib/anycable/version.rb +1 -1
- metadata +27 -42
data/lib/anycable/rpc.rb
ADDED
@@ -0,0 +1,84 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "anycable/rpc/rpc_pb"
|
4
|
+
require "anycable/rpc/rpc_services_pb"
|
5
|
+
|
6
|
+
# Extend some PB auto-generated classes
|
7
|
+
module AnyCable
|
8
|
+
# Current RPC proto version (used for compatibility checks)
|
9
|
+
PROTO_VERSION = "v1"
|
10
|
+
SESSION_KEY = "_s_"
|
11
|
+
|
12
|
+
# Add setters/getter for cstate field
|
13
|
+
module WithConnectionState
|
14
|
+
def initialize(session: nil, **other)
|
15
|
+
if session
|
16
|
+
other[:cstate] ||= {}
|
17
|
+
other[:cstate][SESSION_KEY] = session
|
18
|
+
end
|
19
|
+
super(**other)
|
20
|
+
end
|
21
|
+
|
22
|
+
def session=(val)
|
23
|
+
self.cstate = {} unless cstate
|
24
|
+
cstate[SESSION_KEY] = val
|
25
|
+
end
|
26
|
+
|
27
|
+
def session
|
28
|
+
cstate[SESSION_KEY]
|
29
|
+
end
|
30
|
+
|
31
|
+
def cstate
|
32
|
+
env.cstate
|
33
|
+
end
|
34
|
+
|
35
|
+
def cstate=(val)
|
36
|
+
env.cstate = val
|
37
|
+
end
|
38
|
+
|
39
|
+
def istate
|
40
|
+
env.istate
|
41
|
+
end
|
42
|
+
|
43
|
+
def istate=(val)
|
44
|
+
env.istate = val
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
# Status predicates
|
49
|
+
module StatusPredicates
|
50
|
+
def success?
|
51
|
+
status == :SUCCESS
|
52
|
+
end
|
53
|
+
|
54
|
+
def failure?
|
55
|
+
status == :FAILURE
|
56
|
+
end
|
57
|
+
|
58
|
+
def error?
|
59
|
+
status == :ERROR
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
class ConnectionResponse
|
64
|
+
prepend WithConnectionState
|
65
|
+
include StatusPredicates
|
66
|
+
end
|
67
|
+
|
68
|
+
class CommandMessage
|
69
|
+
prepend WithConnectionState
|
70
|
+
end
|
71
|
+
|
72
|
+
class CommandResponse
|
73
|
+
prepend WithConnectionState
|
74
|
+
include StatusPredicates
|
75
|
+
end
|
76
|
+
|
77
|
+
class DisconnectRequest
|
78
|
+
prepend WithConnectionState
|
79
|
+
end
|
80
|
+
|
81
|
+
class DisconnectResponse
|
82
|
+
include StatusPredicates
|
83
|
+
end
|
84
|
+
end
|
data/lib/anycable/rpc/rpc_pb.rb
CHANGED
@@ -1,51 +1,69 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
2
4
|
# source: rpc.proto
|
3
5
|
|
4
|
-
require
|
6
|
+
require "google/protobuf"
|
5
7
|
|
6
8
|
Google::Protobuf::DescriptorPool.generated_pool.build do
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
9
|
+
add_file("rpc.proto", syntax: :proto3) do
|
10
|
+
add_message "anycable.Env" do
|
11
|
+
optional :url, :string, 1
|
12
|
+
map :headers, :string, :string, 2
|
13
|
+
map :cstate, :string, :string, 3
|
14
|
+
map :istate, :string, :string, 4
|
15
|
+
end
|
16
|
+
add_message "anycable.EnvResponse" do
|
17
|
+
map :cstate, :string, :string, 1
|
18
|
+
map :istate, :string, :string, 2
|
19
|
+
end
|
20
|
+
add_message "anycable.ConnectionRequest" do
|
21
|
+
optional :env, :message, 3, "anycable.Env"
|
22
|
+
end
|
23
|
+
add_message "anycable.ConnectionResponse" do
|
24
|
+
optional :status, :enum, 1, "anycable.Status"
|
25
|
+
optional :identifiers, :string, 2
|
26
|
+
repeated :transmissions, :string, 3
|
27
|
+
optional :error_msg, :string, 4
|
28
|
+
optional :env, :message, 5, "anycable.EnvResponse"
|
29
|
+
end
|
30
|
+
add_message "anycable.CommandMessage" do
|
31
|
+
optional :command, :string, 1
|
32
|
+
optional :identifier, :string, 2
|
33
|
+
optional :connection_identifiers, :string, 3
|
34
|
+
optional :data, :string, 4
|
35
|
+
optional :env, :message, 5, "anycable.Env"
|
36
|
+
end
|
37
|
+
add_message "anycable.CommandResponse" do
|
38
|
+
optional :status, :enum, 1, "anycable.Status"
|
39
|
+
optional :disconnect, :bool, 2
|
40
|
+
optional :stop_streams, :bool, 3
|
41
|
+
repeated :streams, :string, 4
|
42
|
+
repeated :transmissions, :string, 5
|
43
|
+
optional :error_msg, :string, 6
|
44
|
+
optional :env, :message, 7, "anycable.EnvResponse"
|
45
|
+
repeated :stopped_streams, :string, 8
|
46
|
+
end
|
47
|
+
add_message "anycable.DisconnectRequest" do
|
48
|
+
optional :identifiers, :string, 1
|
49
|
+
repeated :subscriptions, :string, 2
|
50
|
+
optional :env, :message, 5, "anycable.Env"
|
51
|
+
end
|
52
|
+
add_message "anycable.DisconnectResponse" do
|
53
|
+
optional :status, :enum, 1, "anycable.Status"
|
54
|
+
optional :error_msg, :string, 2
|
55
|
+
end
|
56
|
+
add_enum "anycable.Status" do
|
57
|
+
value :ERROR, 0
|
58
|
+
value :SUCCESS, 1
|
59
|
+
value :FAILURE, 2
|
60
|
+
end
|
45
61
|
end
|
46
62
|
end
|
47
63
|
|
48
64
|
module AnyCable
|
65
|
+
Env = Google::Protobuf::DescriptorPool.generated_pool.lookup("anycable.Env").msgclass
|
66
|
+
EnvResponse = Google::Protobuf::DescriptorPool.generated_pool.lookup("anycable.EnvResponse").msgclass
|
49
67
|
ConnectionRequest = Google::Protobuf::DescriptorPool.generated_pool.lookup("anycable.ConnectionRequest").msgclass
|
50
68
|
ConnectionResponse = Google::Protobuf::DescriptorPool.generated_pool.lookup("anycable.ConnectionResponse").msgclass
|
51
69
|
CommandMessage = Google::Protobuf::DescriptorPool.generated_pool.lookup("anycable.CommandMessage").msgclass
|
@@ -1,17 +1,18 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
2
4
|
# Source: rpc.proto for package 'anycable'
|
3
5
|
|
4
|
-
require
|
6
|
+
require "grpc"
|
5
7
|
|
6
8
|
module AnyCable
|
7
9
|
module RPC
|
8
10
|
class Service
|
9
|
-
|
10
11
|
include GRPC::GenericService
|
11
12
|
|
12
13
|
self.marshal_class_method = :encode
|
13
14
|
self.unmarshal_class_method = :decode
|
14
|
-
self.service_name =
|
15
|
+
self.service_name = "anycable.RPC"
|
15
16
|
|
16
17
|
rpc :Connect, ConnectionRequest, ConnectionResponse
|
17
18
|
rpc :Command, CommandMessage, CommandResponse
|
data/lib/anycable/rpc_handler.rb
CHANGED
@@ -1,8 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require "anycable/socket"
|
4
|
-
require "anycable/rpc
|
5
|
-
require "anycable/rpc/rpc_services_pb"
|
4
|
+
require "anycable/rpc"
|
6
5
|
|
7
6
|
# rubocop:disable Metrics/AbcSize
|
8
7
|
# rubocop:disable Metrics/MethodLength
|
@@ -14,22 +13,26 @@ module AnyCable
|
|
14
13
|
def connect(request, _unused_call)
|
15
14
|
logger.debug("RPC Connect: #{request.inspect}")
|
16
15
|
|
17
|
-
socket = build_socket(env: rack_env(request))
|
16
|
+
socket = build_socket(env: rack_env(request.env))
|
18
17
|
|
19
18
|
connection = factory.call(socket)
|
20
19
|
|
21
20
|
connection.handle_open
|
22
21
|
|
23
22
|
if socket.closed?
|
24
|
-
AnyCable::ConnectionResponse.new(
|
23
|
+
AnyCable::ConnectionResponse.new(
|
24
|
+
status: AnyCable::Status::FAILURE,
|
25
|
+
transmissions: socket.transmissions
|
26
|
+
)
|
25
27
|
else
|
26
28
|
AnyCable::ConnectionResponse.new(
|
27
29
|
status: AnyCable::Status::SUCCESS,
|
28
30
|
identifiers: connection.identifiers_json,
|
29
|
-
transmissions: socket.transmissions
|
31
|
+
transmissions: socket.transmissions,
|
32
|
+
env: build_env_response(socket)
|
30
33
|
)
|
31
34
|
end
|
32
|
-
rescue
|
35
|
+
rescue => exp
|
33
36
|
notify_exception(exp, :connect, request)
|
34
37
|
|
35
38
|
AnyCable::ConnectionResponse.new(
|
@@ -41,7 +44,7 @@ module AnyCable
|
|
41
44
|
def disconnect(request, _unused_call)
|
42
45
|
logger.debug("RPC Disconnect: #{request.inspect}")
|
43
46
|
|
44
|
-
socket = build_socket(env: rack_env(request))
|
47
|
+
socket = build_socket(env: rack_env(request.env))
|
45
48
|
|
46
49
|
connection = factory.call(
|
47
50
|
socket,
|
@@ -54,7 +57,7 @@ module AnyCable
|
|
54
57
|
else
|
55
58
|
AnyCable::DisconnectResponse.new(status: AnyCable::Status::FAILURE)
|
56
59
|
end
|
57
|
-
rescue
|
60
|
+
rescue => exp
|
58
61
|
notify_exception(exp, :disconnect, request)
|
59
62
|
|
60
63
|
AnyCable::DisconnectResponse.new(
|
@@ -66,7 +69,7 @@ module AnyCable
|
|
66
69
|
def command(message, _unused_call)
|
67
70
|
logger.debug("RPC Command: #{message.inspect}")
|
68
71
|
|
69
|
-
socket = build_socket
|
72
|
+
socket = build_socket(env: rack_env(message.env))
|
70
73
|
|
71
74
|
connection = factory.call(
|
72
75
|
socket,
|
@@ -83,10 +86,12 @@ module AnyCable
|
|
83
86
|
status: result ? AnyCable::Status::SUCCESS : AnyCable::Status::FAILURE,
|
84
87
|
disconnect: socket.closed?,
|
85
88
|
stop_streams: socket.stop_streams?,
|
86
|
-
streams: socket.streams,
|
87
|
-
|
89
|
+
streams: socket.streams[:start],
|
90
|
+
stopped_streams: socket.streams[:stop],
|
91
|
+
transmissions: socket.transmissions,
|
92
|
+
env: build_env_response(socket)
|
88
93
|
)
|
89
|
-
rescue
|
94
|
+
rescue => exp
|
90
95
|
notify_exception(exp, :command, message)
|
91
96
|
|
92
97
|
AnyCable::CommandResponse.new(
|
@@ -97,24 +102,52 @@ module AnyCable
|
|
97
102
|
|
98
103
|
private
|
99
104
|
|
100
|
-
# Build env from
|
101
|
-
def rack_env(
|
102
|
-
uri = URI.parse(
|
103
|
-
|
104
|
-
|
105
|
-
|
105
|
+
# Build Rack env from request
|
106
|
+
def rack_env(request_env)
|
107
|
+
uri = URI.parse(request_env.url)
|
108
|
+
|
109
|
+
env = base_rack_env
|
110
|
+
env.merge!({
|
106
111
|
"PATH_INFO" => uri.path,
|
107
|
-
"
|
112
|
+
"QUERY_STRING" => uri.query,
|
113
|
+
"SERVER_NAME" => uri.host,
|
114
|
+
"SERVER_PORT" => uri.port,
|
108
115
|
"HTTP_HOST" => uri.host,
|
109
|
-
|
110
|
-
"rack.
|
111
|
-
|
112
|
-
"
|
113
|
-
|
116
|
+
"REMOTE_ADDR" => request_env.headers.delete("REMOTE_ADDR"),
|
117
|
+
"rack.url_scheme" => uri.scheme&.sub(/^ws/, "http"),
|
118
|
+
# AnyCable specific fields
|
119
|
+
"anycable.raw_cstate" => request_env.cstate&.to_h,
|
120
|
+
"anycable.raw_istate" => request_env.istate&.to_h
|
121
|
+
}.delete_if { |_k, v| v.nil? })
|
122
|
+
|
123
|
+
env.merge!(build_headers(request_env.headers))
|
124
|
+
end
|
125
|
+
|
126
|
+
def base_rack_env
|
127
|
+
# Minimum required variables according to Rack Spec
|
128
|
+
# (not all of them though, just those enough for Action Cable to work)
|
129
|
+
# See https://rubydoc.info/github/rack/rack/master/file/SPEC
|
130
|
+
# and https://github.com/rack/rack/blob/master/lib/rack/lint.rb
|
131
|
+
{
|
132
|
+
"REQUEST_METHOD" => "GET",
|
133
|
+
"SCRIPT_NAME" => "",
|
134
|
+
"PATH_INFO" => "/",
|
135
|
+
"QUERY_STRING" => "",
|
136
|
+
"SERVER_NAME" => "",
|
137
|
+
"SERVER_PORT" => "80",
|
138
|
+
"rack.url_scheme" => "http",
|
139
|
+
"rack.input" => StringIO.new("", "r").tap { |io| io.set_encoding(Encoding::ASCII_8BIT) },
|
140
|
+
"rack.version" => Rack::VERSION,
|
141
|
+
"rack.errors" => StringIO.new("").tap { |io| io.set_encoding(Encoding::ASCII_8BIT) },
|
142
|
+
"rack.multithread" => true,
|
143
|
+
"rack.multiprocess" => false,
|
144
|
+
"rack.run_once" => false,
|
145
|
+
"rack.hijack?" => false
|
146
|
+
}
|
114
147
|
end
|
115
148
|
|
116
149
|
def build_socket(**options)
|
117
|
-
AnyCable::Socket.new(options)
|
150
|
+
AnyCable::Socket.new(**options)
|
118
151
|
end
|
119
152
|
|
120
153
|
def build_headers(headers)
|
@@ -125,6 +158,13 @@ module AnyCable
|
|
125
158
|
end
|
126
159
|
end
|
127
160
|
|
161
|
+
def build_env_response(socket)
|
162
|
+
AnyCable::EnvResponse.new(
|
163
|
+
cstate: socket.cstate.changed_fields,
|
164
|
+
istate: socket.istate.changed_fields
|
165
|
+
)
|
166
|
+
end
|
167
|
+
|
128
168
|
def logger
|
129
169
|
AnyCable.logger
|
130
170
|
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
RSpec.shared_context "anycable:rpc:command" do
|
4
|
+
include_context "anycable:rpc:stub"
|
5
|
+
|
6
|
+
let(:command) { "" }
|
7
|
+
let(:channel_id) { "" }
|
8
|
+
let(:identifiers) { {} }
|
9
|
+
let(:data) { {} }
|
10
|
+
|
11
|
+
let(:request) do
|
12
|
+
AnyCable::CommandMessage.new(
|
13
|
+
command: command,
|
14
|
+
identifier: channel_id,
|
15
|
+
connection_identifiers: identifiers.to_json,
|
16
|
+
data: data.to_json,
|
17
|
+
env: env
|
18
|
+
)
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
RSpec.shared_context "anycable:rpc:stub" do
|
4
|
+
before(:all) do
|
5
|
+
@service = AnyCable::RPC::Stub.new(AnyCable.config.rpc_host, :this_channel_is_insecure)
|
6
|
+
end
|
7
|
+
|
8
|
+
let(:service) { @service }
|
9
|
+
|
10
|
+
let(:url) { "ws://example.anycable.com/cable" }
|
11
|
+
let(:headers) { {} }
|
12
|
+
let(:env) { AnyCable::Env.new(url: url, headers: headers) }
|
13
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
RSpec.shared_context "anycable:rpc:server" do
|
4
|
+
before(:all) do
|
5
|
+
@server = AnyCable::Server.new(
|
6
|
+
host: AnyCable.config.rpc_host,
|
7
|
+
**AnyCable.config.to_grpc_params,
|
8
|
+
interceptors: AnyCable.middleware.to_a
|
9
|
+
)
|
10
|
+
|
11
|
+
@server.start
|
12
|
+
end
|
13
|
+
|
14
|
+
after(:all) { @server.stop }
|
15
|
+
end
|
data/lib/anycable/server.rb
CHANGED
@@ -21,51 +21,9 @@ module AnyCable
|
|
21
21
|
# # stop server
|
22
22
|
# server.stop
|
23
23
|
class Server
|
24
|
-
class << self
|
25
|
-
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
26
|
-
def start(**options)
|
27
|
-
warn <<~DEPRECATION
|
28
|
-
DEPRECATION WARNING: Using AnyCable::Server.start is deprecated!
|
29
|
-
Please, use anycable CLI instead.
|
30
|
-
|
31
|
-
See https://docs.anycable.io/#upgrade_to_0_6_0
|
32
|
-
DEPRECATION
|
33
|
-
|
34
|
-
AnyCable.server_callbacks.each(&:call)
|
35
|
-
|
36
|
-
server = new(
|
37
|
-
host: AnyCable.config.rpc_host,
|
38
|
-
**AnyCable.config.to_grpc_params,
|
39
|
-
interceptors: AnyCable.middleware.to_a,
|
40
|
-
**options
|
41
|
-
)
|
42
|
-
|
43
|
-
AnyCable.middleware.freeze
|
44
|
-
|
45
|
-
if AnyCable.config.http_health_port_provided?
|
46
|
-
health_server = AnyCable::HealthServer.new(
|
47
|
-
server,
|
48
|
-
**AnyCable.config.to_http_health_params
|
49
|
-
)
|
50
|
-
health_server.start
|
51
|
-
end
|
52
|
-
|
53
|
-
at_exit do
|
54
|
-
server.stop
|
55
|
-
health_server&.stop
|
56
|
-
end
|
57
|
-
|
58
|
-
AnyCable.logger.info "Broadcasting Redis channel: #{AnyCable.config.redis_channel}"
|
59
|
-
|
60
|
-
server.start
|
61
|
-
server.wait_till_terminated
|
62
|
-
end
|
63
|
-
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
64
|
-
end
|
65
|
-
|
66
24
|
attr_reader :grpc_server, :host
|
67
25
|
|
68
|
-
def initialize(host:, logger:
|
26
|
+
def initialize(host:, logger: nil, **options)
|
69
27
|
@logger = logger
|
70
28
|
@host = host
|
71
29
|
@grpc_server = build_server(options)
|
@@ -78,8 +36,6 @@ module AnyCable
|
|
78
36
|
|
79
37
|
raise "Cannot re-start stopped server" if stopped?
|
80
38
|
|
81
|
-
check_default_host
|
82
|
-
|
83
39
|
logger.info "RPC server is starting..."
|
84
40
|
|
85
41
|
@start_thread = Thread.new { grpc_server.run }
|
@@ -114,10 +70,14 @@ module AnyCable
|
|
114
70
|
|
115
71
|
private
|
116
72
|
|
117
|
-
attr_reader :
|
73
|
+
attr_reader :start_thread
|
74
|
+
|
75
|
+
def logger
|
76
|
+
@logger ||= AnyCable.logger
|
77
|
+
end
|
118
78
|
|
119
79
|
def build_server(options)
|
120
|
-
GRPC::RpcServer.new(options).tap do |server|
|
80
|
+
GRPC::RpcServer.new(**options).tap do |server|
|
121
81
|
server.add_http2_port(host, :this_port_is_insecure)
|
122
82
|
server.handle(AnyCable::RPCHandler)
|
123
83
|
server.handle(build_health_checker)
|
@@ -132,21 +92,5 @@ module AnyCable
|
|
132
92
|
)
|
133
93
|
health_checker
|
134
94
|
end
|
135
|
-
|
136
|
-
def check_default_host
|
137
|
-
return unless host.is_a?(Anycable::Config::DefaultHostWrapper)
|
138
|
-
|
139
|
-
warn <<~DEPRECATION
|
140
|
-
DEPRECATION WARNING: You're using default rpc_host configuration which starts AnyCable RPC
|
141
|
-
server on all available interfaces including external IPv4 and IPv6.
|
142
|
-
This is about to be changed to loopback interface only in future versions.
|
143
|
-
|
144
|
-
Please, consider switching to the loopback interface or set "[::]:50051"
|
145
|
-
explicitly in your configuration, if you want to continue with the current
|
146
|
-
behavior and supress this message.
|
147
|
-
|
148
|
-
See https://docs.anycable.io/#/configuration
|
149
|
-
DEPRECATION
|
150
|
-
end
|
151
95
|
end
|
152
96
|
end
|