iomultiplex 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/lib/iomultiplex.rb +26 -0
- data/lib/iomultiplex/iomultiplex.rb +126 -0
- data/lib/iomultiplex/ioreactor.rb +118 -0
- data/lib/iomultiplex/ioreactor/buffered.rb +55 -0
- data/lib/iomultiplex/ioreactor/openssl.rb +91 -0
- data/lib/iomultiplex/mixins/callback.rb +44 -0
- data/lib/iomultiplex/mixins/ioreactor/read.rb +180 -0
- data/lib/iomultiplex/mixins/ioreactor/write.rb +109 -0
- data/lib/iomultiplex/mixins/logger.rb +64 -0
- data/lib/iomultiplex/mixins/logslow.rb +40 -0
- data/lib/iomultiplex/mixins/openssl.rb +148 -0
- data/lib/iomultiplex/mixins/post.rb +89 -0
- data/lib/iomultiplex/mixins/select.rb +145 -0
- data/lib/iomultiplex/mixins/state.rb +79 -0
- data/lib/iomultiplex/mixins/timer.rb +87 -0
- data/lib/iomultiplex/pool.rb +124 -0
- data/lib/iomultiplex/stringbuffer.rb +87 -0
- data/lib/iomultiplex/tcplistener.rb +56 -0
- data/lib/iomultiplex/version.rb +4 -0
- metadata +98 -0
@@ -0,0 +1,180 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
# Copyright 2014-2016 Jason Woods
|
4
|
+
#
|
5
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
6
|
+
# you may not use this file except in compliance with the License.
|
7
|
+
# You may obtain a copy of the License at
|
8
|
+
#
|
9
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
10
|
+
#
|
11
|
+
# Unless required by applicable law or agreed to in writing, software
|
12
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
13
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
14
|
+
# See the License for the specific language governing permissions and
|
15
|
+
# limitations under the License.
|
16
|
+
|
17
|
+
module IOMultiplex
|
18
|
+
class NotEnoughData < StandardError; end
|
19
|
+
|
20
|
+
module Mixins
|
21
|
+
module IOReactor
|
22
|
+
# Read mixin for IOReactor
|
23
|
+
module Read
|
24
|
+
# TODO: Make these customisable?
|
25
|
+
READ_BUFFER_MAX = 16_384
|
26
|
+
READ_SIZE = 16_384
|
27
|
+
|
28
|
+
def handle_read
|
29
|
+
begin
|
30
|
+
do_read
|
31
|
+
rescue EOFError, IOError, Errno::ECONNRESET => e
|
32
|
+
read_exception e
|
33
|
+
end
|
34
|
+
|
35
|
+
handle_data
|
36
|
+
nil
|
37
|
+
end
|
38
|
+
|
39
|
+
def handle_data
|
40
|
+
@was_read_full = read_full?
|
41
|
+
process unless @read_buffer.empty?
|
42
|
+
nil
|
43
|
+
rescue NotEnoughData
|
44
|
+
return send_eof if @eof_scheduled
|
45
|
+
|
46
|
+
# Allow overfilling of the read buffer in the event
|
47
|
+
# read(>=READ_BUFFER_MAX) was called
|
48
|
+
reschedule_read true
|
49
|
+
else
|
50
|
+
return send_eof if @eof_scheduled && @read_buffer.empty?
|
51
|
+
|
52
|
+
reschedule_read
|
53
|
+
end
|
54
|
+
|
55
|
+
def read(n)
|
56
|
+
raise 'Socket is not attached' unless @attached
|
57
|
+
raise IOError, 'Socket is closed' if @io.closed?
|
58
|
+
raise NotEnoughData, 'Not enough data', nil if @read_buffer.length < n
|
59
|
+
|
60
|
+
@read_buffer.read n
|
61
|
+
end
|
62
|
+
|
63
|
+
def discard
|
64
|
+
@read_buffer.reset
|
65
|
+
nil
|
66
|
+
end
|
67
|
+
|
68
|
+
# Pause read processing
|
69
|
+
# Takes effect on the next reschedule, which occurs after each read
|
70
|
+
# processing takes place
|
71
|
+
def pause
|
72
|
+
return if @pause
|
73
|
+
log_debug 'pause read'
|
74
|
+
@pause = true
|
75
|
+
nil
|
76
|
+
end
|
77
|
+
|
78
|
+
# Resume read processing
|
79
|
+
def resume
|
80
|
+
return unless @pause
|
81
|
+
log_debug 'resume read'
|
82
|
+
@pause = false
|
83
|
+
reschedule_read
|
84
|
+
nil
|
85
|
+
end
|
86
|
+
|
87
|
+
def read_full?
|
88
|
+
@read_buffer.length >= READ_BUFFER_MAX
|
89
|
+
end
|
90
|
+
|
91
|
+
protected
|
92
|
+
|
93
|
+
def do_read
|
94
|
+
read_action
|
95
|
+
rescue IO::WaitReadable, Errno::EINTR, Errno::EAGAIN
|
96
|
+
return
|
97
|
+
end
|
98
|
+
|
99
|
+
def send_eof
|
100
|
+
unless @exception.nil?
|
101
|
+
exception @exception if respond_to?(:exception)
|
102
|
+
force_close
|
103
|
+
return
|
104
|
+
end
|
105
|
+
|
106
|
+
eof if respond_to?(:eof)
|
107
|
+
close
|
108
|
+
nil
|
109
|
+
end
|
110
|
+
|
111
|
+
# To balance threads, process is allowed to return without processing
|
112
|
+
# all data, and will get called again after one round even if read not
|
113
|
+
# ready again. This allows us to spread processing more evenly if the
|
114
|
+
# processor is smart
|
115
|
+
# If the read buffer is >=4096 we can also skip read polling otherwise
|
116
|
+
# we will add another 4096 bytes and not process it as fast as we are
|
117
|
+
# adding the data
|
118
|
+
# Also, allow the processor to pause read which has the same effect -
|
119
|
+
# it is expected a timer or something will then resume read - this can
|
120
|
+
# be if the client is waiting on a background thread
|
121
|
+
# NOTE: Processor should be careful, if it processes nothing this can
|
122
|
+
# cause a busy loop
|
123
|
+
def reschedule_read(overfill = false)
|
124
|
+
if @pause
|
125
|
+
@multiplexer.stop_read self
|
126
|
+
return
|
127
|
+
end
|
128
|
+
|
129
|
+
if read_full? && !overfill
|
130
|
+
# Stop reading, the buffer is too full, let the processor catch up
|
131
|
+
log_info 'Holding read due to full read buffer'
|
132
|
+
@multiplexer.stop_read self
|
133
|
+
@multiplexer.defer self
|
134
|
+
return
|
135
|
+
end
|
136
|
+
|
137
|
+
# Only schedule read if write isn't full - this allows us to drain
|
138
|
+
# write buffer before reading again and prevents a client from sending
|
139
|
+
# large amounts of data without receiving responses
|
140
|
+
if @w && write_full?
|
141
|
+
log_info 'Holding read due to full write buffer'
|
142
|
+
@multiplexer.stop_read self
|
143
|
+
return
|
144
|
+
end
|
145
|
+
|
146
|
+
schedule_read
|
147
|
+
end
|
148
|
+
|
149
|
+
# Schedules the next read action, can be overrided if necessary to
|
150
|
+
# change how the next read should be scheduled
|
151
|
+
def schedule_read
|
152
|
+
@multiplexer.defer self unless @read_buffer.empty?
|
153
|
+
|
154
|
+
# Resume read signal if we had paused due to full buffer
|
155
|
+
@multiplexer.wait_read self if @was_read_full
|
156
|
+
|
157
|
+
nil
|
158
|
+
end
|
159
|
+
|
160
|
+
# Can be overridden for other IO objects
|
161
|
+
def read_nonblock(n)
|
162
|
+
log_debug 'read_nonblock', count: n
|
163
|
+
@io.read_nonblock(n)
|
164
|
+
end
|
165
|
+
|
166
|
+
# Can be overriden for other IO objects
|
167
|
+
def read_action
|
168
|
+
@read_buffer << read_nonblock(READ_SIZE)
|
169
|
+
nil
|
170
|
+
end
|
171
|
+
|
172
|
+
def read_exception(e)
|
173
|
+
@eof_scheduled = true
|
174
|
+
@exception = e unless e.is_a?(EOFError)
|
175
|
+
@multiplexer.stop_read self
|
176
|
+
end
|
177
|
+
end # ::Read
|
178
|
+
end # ::IOReactor
|
179
|
+
end # ::Mixins
|
180
|
+
end # ::IOMultiplex
|
@@ -0,0 +1,109 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
# Copyright 2014-2016 Jason Woods
|
4
|
+
#
|
5
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
6
|
+
# you may not use this file except in compliance with the License.
|
7
|
+
# You may obtain a copy of the License at
|
8
|
+
#
|
9
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
10
|
+
#
|
11
|
+
# Unless required by applicable law or agreed to in writing, software
|
12
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
13
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
14
|
+
# See the License for the specific language governing permissions and
|
15
|
+
# limitations under the License.
|
16
|
+
|
17
|
+
module IOMultiplex
|
18
|
+
module Mixins
|
19
|
+
module IOReactor
|
20
|
+
# Write mixin for IOReactor
|
21
|
+
module Write
|
22
|
+
# TODO: Make these customisable?
|
23
|
+
WRITE_BUFFER_MAX = 16_384
|
24
|
+
WRITE_SIZE = 4_096
|
25
|
+
|
26
|
+
def handle_write
|
27
|
+
begin
|
28
|
+
do_write
|
29
|
+
rescue IOError, Errno::ECONNRESET => e
|
30
|
+
write_exception e
|
31
|
+
end
|
32
|
+
|
33
|
+
nil
|
34
|
+
end
|
35
|
+
|
36
|
+
def write(data)
|
37
|
+
raise 'Socket is not attached' unless @attached
|
38
|
+
raise IOError, 'Socket is closed' if @io.closed?
|
39
|
+
|
40
|
+
@write_buffer.push data
|
41
|
+
handle_write if @write_immediately
|
42
|
+
|
43
|
+
# Write buffer too large - pause read polling
|
44
|
+
if @r && write_full?
|
45
|
+
log_debug 'write buffer full, pausing read',
|
46
|
+
count: @write_buffer.length
|
47
|
+
@multiplexer.stop_read self
|
48
|
+
@multiplexer.remove_post self
|
49
|
+
end
|
50
|
+
nil
|
51
|
+
end
|
52
|
+
|
53
|
+
def write_full?
|
54
|
+
@write_buffer.length >= WRITE_BUFFER_MAX
|
55
|
+
end
|
56
|
+
|
57
|
+
protected
|
58
|
+
|
59
|
+
def reading?
|
60
|
+
@r && !@pause
|
61
|
+
end
|
62
|
+
|
63
|
+
def do_write
|
64
|
+
@was_read_held = reading? && write_full?
|
65
|
+
@write_buffer.shift write_action
|
66
|
+
|
67
|
+
if @write_buffer.empty?
|
68
|
+
force_close if @close_scheduled
|
69
|
+
return
|
70
|
+
end
|
71
|
+
|
72
|
+
check_read_throttle
|
73
|
+
rescue IO::WaitWritable, Errno::EINTR, Errno::EAGAIN
|
74
|
+
# Wait for write
|
75
|
+
@write_immediately = false
|
76
|
+
@multiplexer.wait_write self
|
77
|
+
else
|
78
|
+
@write_immediately = true
|
79
|
+
@multiplexer.stop_write self
|
80
|
+
end
|
81
|
+
|
82
|
+
def check_read_throttle
|
83
|
+
return unless @was_read_held && !write_full?
|
84
|
+
|
85
|
+
log_debug 'write buffer no longer full, resuming read',
|
86
|
+
count: @write_buffer.length
|
87
|
+
@multiplexer.wait_read self
|
88
|
+
reschedule_read
|
89
|
+
end
|
90
|
+
|
91
|
+
# Can be overridden for other IO objects
|
92
|
+
def write_nonblock(data)
|
93
|
+
log_debug 'write_nonblock', count: data.length
|
94
|
+
@io.write_nonblock(data)
|
95
|
+
end
|
96
|
+
|
97
|
+
# Can be overriden for other IO objects
|
98
|
+
def write_action
|
99
|
+
write_nonblock @write_buffer.peek(WRITE_SIZE)
|
100
|
+
end
|
101
|
+
|
102
|
+
def write_exception(e)
|
103
|
+
exception e if respond_to?(:exception)
|
104
|
+
force_close
|
105
|
+
end
|
106
|
+
end # ::Write
|
107
|
+
end # ::IOReactor
|
108
|
+
end # ::Mixins
|
109
|
+
end # ::IOMultiplex
|
@@ -0,0 +1,64 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
# Copyright 2014-2016 Jason Woods
|
4
|
+
#
|
5
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
6
|
+
# you may not use this file except in compliance with the License.
|
7
|
+
# You may obtain a copy of the License at
|
8
|
+
#
|
9
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
10
|
+
#
|
11
|
+
# Unless required by applicable law or agreed to in writing, software
|
12
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
13
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
14
|
+
# See the License for the specific language governing permissions and
|
15
|
+
# limitations under the License.
|
16
|
+
|
17
|
+
module IOMultiplex
|
18
|
+
module Mixins
|
19
|
+
# Logger provides ability for object specific context in logs
|
20
|
+
module Logger
|
21
|
+
attr_reader :logger
|
22
|
+
attr_reader :logger_context
|
23
|
+
|
24
|
+
protected
|
25
|
+
|
26
|
+
def initialize_logger(logger = nil, logger_context = nil)
|
27
|
+
@logger = logger || Cabin::Channel.get(IOMultiplex)
|
28
|
+
@logger_context = logger_context.nil? ? {} : logger_context
|
29
|
+
end
|
30
|
+
|
31
|
+
def add_logger_context(key, value)
|
32
|
+
@logger_context[key] = value
|
33
|
+
end
|
34
|
+
|
35
|
+
def clear_logger_context
|
36
|
+
@logger_context = nil
|
37
|
+
end
|
38
|
+
|
39
|
+
%w(fatal error warn info debug).each do |level|
|
40
|
+
method = ('log_' + level).to_sym
|
41
|
+
pmethod = ('log_' + level + '?').to_sym
|
42
|
+
logger_method = level.to_sym
|
43
|
+
logger_pmethod = (level + '?').to_sym
|
44
|
+
|
45
|
+
define_method(method) do |*args|
|
46
|
+
return unless @logger.send(logger_pmethod)
|
47
|
+
|
48
|
+
args[1] ||= {}
|
49
|
+
|
50
|
+
unless args[1].is_a?(Hash)
|
51
|
+
raise ArgumentError 'Second argument must be a hash'
|
52
|
+
end
|
53
|
+
|
54
|
+
args[1].merge! @logger_context unless @logger_context.nil?
|
55
|
+
@logger.send logger_method, *args
|
56
|
+
end
|
57
|
+
|
58
|
+
define_method(pmethod) do
|
59
|
+
@logger.send logger_pmethod
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end # ::Logger
|
63
|
+
end # ::Mixins
|
64
|
+
end # ::IOMultiplex
|
@@ -0,0 +1,40 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
# Copyright 2014-2016 Jason Woods
|
4
|
+
#
|
5
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
6
|
+
# you may not use this file except in compliance with the License.
|
7
|
+
# You may obtain a copy of the License at
|
8
|
+
#
|
9
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
10
|
+
#
|
11
|
+
# Unless required by applicable law or agreed to in writing, software
|
12
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
13
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
14
|
+
# See the License for the specific language governing permissions and
|
15
|
+
# limitations under the License.
|
16
|
+
|
17
|
+
module IOMultiplex
|
18
|
+
module Mixins
|
19
|
+
# LogSlow mixin to log slow function calls
|
20
|
+
module LogSlow
|
21
|
+
protected
|
22
|
+
|
23
|
+
# Wrap a method and report to the logger if it runs slowly
|
24
|
+
def log_slow(func, args = [], max_duration = 100, diagnostics = nil)
|
25
|
+
sub_start_time = Time.now
|
26
|
+
func.call(*args)
|
27
|
+
duration = ((Time.now - sub_start_time) * 1000).to_i
|
28
|
+
return unless duration > max_duration
|
29
|
+
extra = {
|
30
|
+
:duration_ms => duration,
|
31
|
+
:client => monitor.value.id
|
32
|
+
}
|
33
|
+
extra = diagnostics.call unless diagnostics.nil?
|
34
|
+
log_warn \
|
35
|
+
'Slow ' + func.to_s,
|
36
|
+
extra
|
37
|
+
end
|
38
|
+
end # ::LogSlow
|
39
|
+
end # ::Mixins
|
40
|
+
end # ::IOMultiplex
|
@@ -0,0 +1,148 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
|
3
|
+
# Copyright 2014-2016 Jason Woods
|
4
|
+
#
|
5
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
6
|
+
# you may not use this file except in compliance with the License.
|
7
|
+
# You may obtain a copy of the License at
|
8
|
+
#
|
9
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
10
|
+
#
|
11
|
+
# Unless required by applicable law or agreed to in writing, software
|
12
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
13
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
14
|
+
# See the License for the specific language governing permissions and
|
15
|
+
# limitations under the License.
|
16
|
+
|
17
|
+
require 'openssl'
|
18
|
+
|
19
|
+
module IOMultiplex
|
20
|
+
module Mixins
|
21
|
+
# OpenSSL mixin, shared code amongst the OpenSSL IOReactors
|
22
|
+
module OpenSSL
|
23
|
+
def handle_read
|
24
|
+
if @write_on_read
|
25
|
+
handle_write
|
26
|
+
|
27
|
+
# Still need more reads to complete a write?
|
28
|
+
return if @write_on_read
|
29
|
+
end
|
30
|
+
|
31
|
+
super
|
32
|
+
|
33
|
+
# If we were waiting for a write signal so we could complete a read
|
34
|
+
# call, clear it since we now completed it
|
35
|
+
reset_read_on_write if @read_on_write
|
36
|
+
rescue IO::WaitWritable
|
37
|
+
# TODO: handle_data should really be triggered
|
38
|
+
# This captures an OpenSSL read wanting a write
|
39
|
+
@multiplexer.stop_read self
|
40
|
+
@multiplexer.wait_write self
|
41
|
+
@read_on_write = true
|
42
|
+
|
43
|
+
log_debug 'OpenSSL wants read on write'
|
44
|
+
end
|
45
|
+
|
46
|
+
def handle_write
|
47
|
+
if @read_on_write
|
48
|
+
handle_read
|
49
|
+
|
50
|
+
# Still need more writes to complete a read?
|
51
|
+
return if @read_on_write
|
52
|
+
end
|
53
|
+
|
54
|
+
super
|
55
|
+
|
56
|
+
# If we were waiting for a read signal so we could complete a write
|
57
|
+
# call, clear it since we now completed it
|
58
|
+
reset_write_on_read if @write_on_read
|
59
|
+
rescue IO::WaitReadable
|
60
|
+
# Write needs a read
|
61
|
+
@multiplexer.stop_write self
|
62
|
+
@multiplexer.wait_read self
|
63
|
+
@write_on_read = true
|
64
|
+
|
65
|
+
log_debug 'OpenSSL wants write on read'
|
66
|
+
end
|
67
|
+
|
68
|
+
def peer_cert
|
69
|
+
@ssl.peer_cert
|
70
|
+
end
|
71
|
+
|
72
|
+
def peer_cert_cn
|
73
|
+
return nil unless peer_cert
|
74
|
+
return @peer_cert_cn unless @peer_cert_cn.nil?
|
75
|
+
@peer_cert_cn = peer_cert.subject.to_a.find do |oid, value|
|
76
|
+
return value if oid == 'CN'
|
77
|
+
nil
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
def handshake_completed?
|
82
|
+
@handshake_completed
|
83
|
+
end
|
84
|
+
|
85
|
+
protected
|
86
|
+
|
87
|
+
def initialize_ssl(ssl_ctx)
|
88
|
+
@ssl = ::OpenSSL::SSL::SSLSocket.new(@io, ssl_ctx)
|
89
|
+
@ssl_ctx = ssl_ctx
|
90
|
+
@handshake_completed = false
|
91
|
+
@read_on_write = false
|
92
|
+
nil
|
93
|
+
end
|
94
|
+
|
95
|
+
def ssl_read_nonblock(n)
|
96
|
+
read = @ssl.read_nonblock n
|
97
|
+
rescue IO::WaitReadable
|
98
|
+
# OpenSSL wraps these, keep it flowing throw
|
99
|
+
raise
|
100
|
+
rescue ::OpenSSL::SSL::SSLError => e
|
101
|
+
# Throw back OpenSSL errors as IOErrors
|
102
|
+
raise IOError, "#{e.class.name}: #{e}"
|
103
|
+
ensure
|
104
|
+
log_debug 'SSL read_nonblock',
|
105
|
+
count: n, read: read.nil? ? nil : read.length
|
106
|
+
end
|
107
|
+
|
108
|
+
def ssl_write_nonblock(data)
|
109
|
+
written = @ssl.write_nonblock data
|
110
|
+
rescue IO::WaitWritable
|
111
|
+
# OpenSSL wraps these, keep it flowing throw
|
112
|
+
raise
|
113
|
+
rescue ::OpenSSL::SSL::SSLError => e
|
114
|
+
# Throw back OpenSSL errors as IOErrors
|
115
|
+
raise IOError, "#{e.class.name}: #{e}"
|
116
|
+
ensure
|
117
|
+
log_debug 'SSL write_nonblock',
|
118
|
+
count: data.length, written: written
|
119
|
+
end
|
120
|
+
|
121
|
+
def reset_read_on_write
|
122
|
+
@read_on_write = false
|
123
|
+
@multiplexer.wait_read self unless write_full?
|
124
|
+
end
|
125
|
+
|
126
|
+
def reset_write_on_read
|
127
|
+
@write_on_read = false
|
128
|
+
@write_immediately = true
|
129
|
+
end
|
130
|
+
|
131
|
+
def process_handshake
|
132
|
+
@ssl.accept_nonblock
|
133
|
+
@handshake_completed = true
|
134
|
+
add_logger_context 'peer_cert_cn', peer_cert_cn
|
135
|
+
|
136
|
+
handshake_completed if respond_to?(:handshake_completed)
|
137
|
+
|
138
|
+
log_debug 'Handshake completed'
|
139
|
+
rescue IO::WaitReadable, IO::WaitWritable
|
140
|
+
# OpenSSL wraps these, keep it flowing throw
|
141
|
+
raise
|
142
|
+
rescue ::OpenSSL::SSL::SSLError => e
|
143
|
+
# Throw back OpenSSL errors as IOErrors
|
144
|
+
raise IOError, "#{e.class.name}: #{e}"
|
145
|
+
end
|
146
|
+
end # ::OpenSSL
|
147
|
+
end # ::Mixins
|
148
|
+
end # ::IOMultiplex
|