mirror of
https://github.com/xtaci/smux.git
synced 2026-10-20 16:31:38 +00:00
commit c8a401fdb4
Author: xtaci <imap@live.com>
Date: Sat Jan 24 22:29:03 2026 +0800
fix(session): reject cmdUPD in protocol version 1
cmdUPD is a v2-only command used for flow control window updates.
Previously, recvLoop would accept and process cmdUPD even when
the session was configured for protocol version 1, which violates
the protocol specification.
This change adds a version check before processing cmdUPD. If the
session is not running v2, it now correctly returns ErrInvalidProtocol
and terminates the receive loop.
675 lines
16 KiB
Go
675 lines
16 KiB
Go
// MIT License
|
|
//
|
|
// Copyright (c) 2016-2017 xtaci
|
|
//
|
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
// of this software and associated documentation files (the "Software"), to deal
|
|
// in the Software without restriction, including without limitation the rights
|
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
// copies of the Software, and to permit persons to whom the Software is
|
|
// furnished to do so, subject to the following conditions:
|
|
//
|
|
// The above copyright notice and this permission notice shall be included in all
|
|
// copies or substantial portions of the Software.
|
|
//
|
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
// SOFTWARE.
|
|
|
|
package smux
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"errors"
|
|
"io"
|
|
"net"
|
|
"runtime"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
defaultAcceptBacklog = 1024
|
|
minShaperNotifySize = 16
|
|
maxShaperSize = 1024
|
|
openCloseTimeout = 30 * time.Second // Timeout for opening/closing streams
|
|
)
|
|
|
|
// CLASSID represents the class of a frame
|
|
type CLASSID int
|
|
|
|
const (
|
|
CLSCTRL CLASSID = iota // prioritized control signal
|
|
CLSDATA
|
|
)
|
|
|
|
// timeoutError representing timeouts for operations such as accept, read and write
|
|
//
|
|
// To better cooperate with the standard library, timeoutError should implement the standard library's `net.Error`.
|
|
//
|
|
// For example, using smux to implement net.Listener and work with http.Server, the keep-alive connection (*smux.Stream) will be unexpectedly closed.
|
|
// For more details, see https://github.com/xtaci/smux/pull/99.
|
|
type timeoutError struct{}
|
|
|
|
func (timeoutError) Error() string { return "timeout" }
|
|
func (timeoutError) Temporary() bool { return true }
|
|
func (timeoutError) Timeout() bool { return true }
|
|
|
|
var (
|
|
ErrInvalidProtocol = errors.New("invalid protocol")
|
|
ErrConsumed = errors.New("peer consumed more than sent")
|
|
ErrGoAway = errors.New("stream id overflows, should start a new connection")
|
|
ErrTimeout net.Error = &timeoutError{}
|
|
ErrWouldBlock = errors.New("operation would block on IO")
|
|
)
|
|
|
|
// writeRequest represents a request to write a frame
|
|
type writeRequest struct {
|
|
class CLASSID
|
|
frame Frame
|
|
seq uint32
|
|
result chan writeResult
|
|
}
|
|
|
|
// writeResult represents the result of a write request
|
|
type writeResult struct {
|
|
n int
|
|
err error
|
|
}
|
|
|
|
// Session defines a multiplexed connection for streams
|
|
type Session struct {
|
|
conn io.ReadWriteCloser
|
|
|
|
config *Config
|
|
goAway int32 // flag id exhausted
|
|
nextStreamID uint32 // next stream identifier
|
|
nextStreamIDLock sync.Mutex
|
|
|
|
bucket int32 // token bucket
|
|
bucketNotify chan struct{} // used for waiting for tokens
|
|
|
|
streams map[uint32]*stream // all streams in this session
|
|
streamLock sync.Mutex // locks streams
|
|
|
|
die chan struct{} // flag session has died
|
|
dieOnce sync.Once
|
|
|
|
// socket error handling
|
|
socketReadError atomic.Value
|
|
socketWriteError atomic.Value
|
|
chSocketReadError chan struct{}
|
|
chSocketWriteError chan struct{}
|
|
socketReadErrorOnce sync.Once
|
|
socketWriteErrorOnce sync.Once
|
|
|
|
// smux protocol errors
|
|
protoError atomic.Value
|
|
chProtoError chan struct{}
|
|
protoErrorOnce sync.Once
|
|
|
|
chAccepts chan *stream
|
|
|
|
sessionIsActive int32 // flag session is active
|
|
acceptDeadline atomic.Value // deadline for Accept()
|
|
|
|
requestID uint32 // Monotonic increasing write request ID
|
|
shaper chan writeRequest // a shaper for writing
|
|
sq *shaperQueue
|
|
chShaperPending chan struct{}
|
|
chShaperConsumed chan struct{}
|
|
}
|
|
|
|
func newSession(config *Config, conn io.ReadWriteCloser, client bool) *Session {
|
|
s := new(Session)
|
|
s.die = make(chan struct{})
|
|
s.conn = conn
|
|
s.config = config
|
|
s.streams = make(map[uint32]*stream)
|
|
s.chAccepts = make(chan *stream, defaultAcceptBacklog)
|
|
s.bucket = int32(config.MaxReceiveBuffer)
|
|
s.bucketNotify = make(chan struct{}, 1)
|
|
s.shaper = make(chan writeRequest, maxShaperSize)
|
|
s.chSocketReadError = make(chan struct{})
|
|
s.chSocketWriteError = make(chan struct{})
|
|
s.chProtoError = make(chan struct{})
|
|
s.chShaperPending = make(chan struct{}, 1)
|
|
s.chShaperConsumed = make(chan struct{}, 1)
|
|
s.sq = NewShaperQueue()
|
|
|
|
if client {
|
|
s.nextStreamID = 1
|
|
} else {
|
|
s.nextStreamID = 0
|
|
}
|
|
|
|
go s.shaperLoop()
|
|
go s.recvLoop()
|
|
go s.sendLoop()
|
|
if !config.KeepAliveDisabled {
|
|
go s.keepalive()
|
|
}
|
|
return s
|
|
}
|
|
|
|
// OpenStream is used to create a new stream
|
|
func (s *Session) OpenStream() (*Stream, error) {
|
|
if s.IsClosed() {
|
|
return nil, io.ErrClosedPipe
|
|
}
|
|
|
|
// generate stream id
|
|
s.nextStreamIDLock.Lock()
|
|
if s.goAway > 0 {
|
|
s.nextStreamIDLock.Unlock()
|
|
return nil, ErrGoAway
|
|
}
|
|
|
|
// check for stream id overflow
|
|
if s.nextStreamID+2 < s.nextStreamID {
|
|
s.goAway = 1
|
|
s.nextStreamIDLock.Unlock()
|
|
return nil, ErrGoAway
|
|
}
|
|
|
|
// allocate next stream id
|
|
s.nextStreamID += 2
|
|
sid := s.nextStreamID
|
|
s.nextStreamIDLock.Unlock()
|
|
|
|
stream := newStream(sid, s.config.MaxFrameSize, s)
|
|
|
|
if _, err := s.writeControlFrame(newFrame(byte(s.config.Version), cmdSYN, sid)); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
s.streamLock.Lock()
|
|
defer s.streamLock.Unlock()
|
|
select {
|
|
case <-s.chSocketReadError:
|
|
return nil, s.socketReadError.Load().(error)
|
|
case <-s.chSocketWriteError:
|
|
return nil, s.socketWriteError.Load().(error)
|
|
case <-s.die:
|
|
return nil, io.ErrClosedPipe
|
|
default:
|
|
s.streams[sid] = stream
|
|
wrapper := &Stream{stream: stream}
|
|
// NOTE(x): disabled finalizer for issue #997
|
|
/*
|
|
runtime.SetFinalizer(wrapper, func(s *Stream) {
|
|
s.Close()
|
|
})
|
|
*/
|
|
return wrapper, nil
|
|
}
|
|
}
|
|
|
|
// Open returns a generic ReadWriteCloser
|
|
func (s *Session) Open() (io.ReadWriteCloser, error) {
|
|
return s.OpenStream()
|
|
}
|
|
|
|
// AcceptStream is used to block until the next available stream
|
|
// is ready to be accepted.
|
|
func (s *Session) AcceptStream() (*Stream, error) {
|
|
var deadline <-chan time.Time
|
|
if d, ok := s.acceptDeadline.Load().(time.Time); ok && !d.IsZero() {
|
|
timer := time.NewTimer(time.Until(d))
|
|
defer timer.Stop()
|
|
deadline = timer.C
|
|
}
|
|
|
|
select {
|
|
case stream := <-s.chAccepts:
|
|
wrapper := &Stream{stream: stream}
|
|
runtime.SetFinalizer(wrapper, func(s *Stream) {
|
|
s.Close()
|
|
})
|
|
return wrapper, nil
|
|
case <-deadline:
|
|
return nil, ErrTimeout
|
|
case <-s.chSocketReadError:
|
|
return nil, s.socketReadError.Load().(error)
|
|
case <-s.chProtoError:
|
|
return nil, s.protoError.Load().(error)
|
|
case <-s.die:
|
|
return nil, io.ErrClosedPipe
|
|
}
|
|
}
|
|
|
|
// Accept Returns a generic ReadWriteCloser instead of smux.Stream
|
|
func (s *Session) Accept() (io.ReadWriteCloser, error) {
|
|
return s.AcceptStream()
|
|
}
|
|
|
|
// Close is used to close the session and all streams.
|
|
func (s *Session) Close() error {
|
|
var once bool
|
|
s.dieOnce.Do(func() {
|
|
close(s.die)
|
|
once = true
|
|
})
|
|
|
|
if !once {
|
|
return io.ErrClosedPipe
|
|
}
|
|
|
|
s.streamLock.Lock()
|
|
for k := range s.streams {
|
|
s.streams[k].sessionClose()
|
|
}
|
|
s.streamLock.Unlock()
|
|
return s.conn.Close()
|
|
}
|
|
|
|
// CloseChan can be used by someone who wants to be notified immediately when this
|
|
// session is closed
|
|
func (s *Session) CloseChan() <-chan struct{} {
|
|
return s.die
|
|
}
|
|
|
|
// notifyBucket notifies recvLoop that bucket is available
|
|
func (s *Session) notifyBucket() {
|
|
select {
|
|
case s.bucketNotify <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
|
|
func (s *Session) notifyReadError(err error) {
|
|
s.socketReadErrorOnce.Do(func() {
|
|
s.socketReadError.Store(err)
|
|
close(s.chSocketReadError)
|
|
})
|
|
}
|
|
|
|
func (s *Session) notifyWriteError(err error) {
|
|
s.socketWriteErrorOnce.Do(func() {
|
|
s.socketWriteError.Store(err)
|
|
close(s.chSocketWriteError)
|
|
})
|
|
}
|
|
|
|
func (s *Session) notifyProtoError(err error) {
|
|
s.protoErrorOnce.Do(func() {
|
|
s.protoError.Store(err)
|
|
close(s.chProtoError)
|
|
})
|
|
}
|
|
|
|
// IsClosed does a safe check to see if we have shutdown
|
|
func (s *Session) IsClosed() bool {
|
|
select {
|
|
case <-s.die:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// NumStreams returns the number of currently open streams
|
|
func (s *Session) NumStreams() int {
|
|
if s.IsClosed() {
|
|
return 0
|
|
}
|
|
s.streamLock.Lock()
|
|
defer s.streamLock.Unlock()
|
|
return len(s.streams)
|
|
}
|
|
|
|
// SetDeadline sets a deadline used by Accept* calls.
|
|
// A zero time value disables the deadline.
|
|
func (s *Session) SetDeadline(t time.Time) error {
|
|
s.acceptDeadline.Store(t)
|
|
return nil
|
|
}
|
|
|
|
// LocalAddr satisfies net.Conn interface
|
|
func (s *Session) LocalAddr() net.Addr {
|
|
if ts, ok := s.conn.(interface {
|
|
LocalAddr() net.Addr
|
|
}); ok {
|
|
return ts.LocalAddr()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RemoteAddr satisfies net.Conn interface
|
|
func (s *Session) RemoteAddr() net.Addr {
|
|
if ts, ok := s.conn.(interface {
|
|
RemoteAddr() net.Addr
|
|
}); ok {
|
|
return ts.RemoteAddr()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// notify the session that a stream has closed
|
|
func (s *Session) streamClosed(sid uint32) {
|
|
s.streamLock.Lock()
|
|
defer s.streamLock.Unlock()
|
|
|
|
stream, ok := s.streams[sid]
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
if n := stream.recycleTokens(); n > 0 {
|
|
// return remaining tokens to the bucket
|
|
if atomic.AddInt32(&s.bucket, int32(n)) > 0 {
|
|
s.notifyBucket()
|
|
}
|
|
}
|
|
delete(s.streams, sid)
|
|
}
|
|
|
|
// returnTokens is called by stream to return token after read
|
|
func (s *Session) returnTokens(n int) {
|
|
if atomic.AddInt32(&s.bucket, int32(n)) > 0 {
|
|
s.notifyBucket()
|
|
}
|
|
}
|
|
|
|
// recvLoop keeps on reading from underlying connection if tokens are available
|
|
func (s *Session) recvLoop() {
|
|
var hdr rawHeader
|
|
var updHdr updHeader
|
|
|
|
for {
|
|
// Wait until we have tokens or session is closed.
|
|
for atomic.LoadInt32(&s.bucket) <= 0 && !s.IsClosed() {
|
|
select {
|
|
case <-s.bucketNotify:
|
|
case <-s.die:
|
|
// If it returns here, Accept() and OpenStream() are unblocked with io.ErrClosedPipe,
|
|
// causing recvLoop to exit gracefully. If recvLoop is blocked in io.ReadFull, however,
|
|
// it will be unblocked by a socket read error instead.
|
|
return
|
|
}
|
|
}
|
|
|
|
// As long as we have tokens, try to read frames.
|
|
// read header first
|
|
_, err := io.ReadFull(s.conn, hdr[:])
|
|
if err != nil {
|
|
s.notifyReadError(err)
|
|
return
|
|
}
|
|
|
|
// Mark the session as active
|
|
atomic.StoreInt32(&s.sessionIsActive, 1)
|
|
|
|
// validate protocol version
|
|
if hdr.Version() != byte(s.config.Version) {
|
|
s.notifyProtoError(ErrInvalidProtocol)
|
|
return
|
|
}
|
|
|
|
// handle different command types
|
|
sid := hdr.StreamID()
|
|
switch hdr.Cmd() {
|
|
case cmdNOP:
|
|
case cmdSYN: // stream opening
|
|
var accepted *stream
|
|
s.streamLock.Lock()
|
|
if _, ok := s.streams[sid]; !ok {
|
|
stream := newStream(sid, s.config.MaxFrameSize, s)
|
|
s.streams[sid] = stream
|
|
accepted = stream
|
|
}
|
|
s.streamLock.Unlock()
|
|
|
|
if accepted != nil {
|
|
select {
|
|
case s.chAccepts <- accepted:
|
|
case <-s.die:
|
|
}
|
|
}
|
|
|
|
case cmdFIN: // stream closing
|
|
s.streamLock.Lock()
|
|
if stream, ok := s.streams[sid]; ok {
|
|
stream.fin() // fin unblocks the readers and writers
|
|
}
|
|
s.streamLock.Unlock()
|
|
|
|
case cmdPSH: // data frame
|
|
if hdr.Length() == 0 {
|
|
continue
|
|
}
|
|
|
|
// read payload from the underlying connection
|
|
pNewbuf := defaultAllocator.Get(int(hdr.Length()))
|
|
written, err := io.ReadFull(s.conn, *pNewbuf)
|
|
if err != nil {
|
|
s.notifyReadError(err)
|
|
|
|
// recycle the buffer immediately.
|
|
defaultAllocator.Put(pNewbuf)
|
|
return
|
|
}
|
|
|
|
// push data to the corresponding stream
|
|
s.streamLock.Lock()
|
|
if stream, ok := s.streams[sid]; ok {
|
|
stream.pushBytes(pNewbuf)
|
|
// deduct tokens from the bucket
|
|
atomic.AddInt32(&s.bucket, -int32(written))
|
|
stream.wakeupReader()
|
|
} else {
|
|
// data directed to a missing/closed stream, recycle the buffer immediately.
|
|
defaultAllocator.Put(pNewbuf)
|
|
}
|
|
s.streamLock.Unlock()
|
|
|
|
case cmdUPD: // a window update signal (v2 only)
|
|
if s.config.Version != 2 {
|
|
s.notifyProtoError(ErrInvalidProtocol)
|
|
return
|
|
}
|
|
|
|
_, err := io.ReadFull(s.conn, updHdr[:])
|
|
if err != nil {
|
|
s.notifyReadError(err)
|
|
return
|
|
}
|
|
|
|
// update the window size for the corresponding stream
|
|
s.streamLock.Lock()
|
|
if stream, ok := s.streams[sid]; ok {
|
|
stream.update(updHdr.Consumed(), updHdr.Window())
|
|
}
|
|
s.streamLock.Unlock()
|
|
|
|
default:
|
|
s.notifyProtoError(ErrInvalidProtocol)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// keepalive sends NOP frames periodically to keep the connection alive
|
|
func (s *Session) keepalive() {
|
|
tickerPing := time.NewTicker(s.config.KeepAliveInterval)
|
|
tickerTimeout := time.NewTicker(s.config.KeepAliveTimeout)
|
|
defer tickerPing.Stop()
|
|
defer tickerTimeout.Stop()
|
|
for {
|
|
select {
|
|
case <-tickerPing.C:
|
|
s.writeFrameInternal(newFrame(byte(s.config.Version), cmdNOP, 0), tickerPing.C, CLSCTRL)
|
|
s.notifyBucket() // force a wakeup signal to the recvLoop
|
|
case <-tickerTimeout.C:
|
|
if !atomic.CompareAndSwapInt32(&s.sessionIsActive, 1, 0) {
|
|
// recvLoop may block while bucket is 0, in this case,
|
|
// session should not be closed.
|
|
if atomic.LoadInt32(&s.bucket) > 0 {
|
|
s.Close()
|
|
return
|
|
}
|
|
}
|
|
case <-s.die:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// shaperLoop implements a priority queue and bandwidth shaping for write requests.
|
|
// Eg: Control messages are prioritized over data messages, and shaper tries
|
|
// it's best to keep fair bandwidth among streams.
|
|
func (s *Session) shaperLoop() {
|
|
chShaper := s.shaper
|
|
|
|
for {
|
|
select {
|
|
case <-s.die:
|
|
return
|
|
case r := <-chShaper:
|
|
s.sq.Push(r)
|
|
// notify sendLoop there are pending requests
|
|
if len(chShaper) == 0 || s.sq.Len() > minShaperNotifySize {
|
|
s.notifyShaperPending()
|
|
}
|
|
|
|
if s.sq.Len() >= maxShaperSize {
|
|
// stop accepting new requests temporarily if shaper queue is full
|
|
chShaper = nil
|
|
}
|
|
case <-s.chShaperConsumed:
|
|
// re-enable shaper channel
|
|
chShaper = s.shaper
|
|
}
|
|
}
|
|
}
|
|
|
|
// notifyShaperPending notifies sendLoop that there are pending requests
|
|
func (s *Session) notifyShaperPending() {
|
|
select {
|
|
case s.chShaperPending <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
|
|
// notifyShaperConsumed notifies when shaper queue is being consumed
|
|
func (s *Session) notifyShaperConsumed() {
|
|
select {
|
|
case s.chShaperConsumed <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
|
|
// sendLoop sends frames over the underlying connection
|
|
func (s *Session) sendLoop() {
|
|
var buf []byte
|
|
var n int
|
|
var err error
|
|
var vec [][]byte // vector for writeBuffers
|
|
|
|
bw, ok := s.conn.(interface {
|
|
WriteBuffers(v [][]byte) (n int, err error)
|
|
})
|
|
|
|
if ok {
|
|
buf = make([]byte, headerSize)
|
|
vec = make([][]byte, 2)
|
|
} else {
|
|
buf = make([]byte, (1<<16)+headerSize)
|
|
}
|
|
|
|
EVENT_LOOP:
|
|
for {
|
|
select {
|
|
case <-s.die:
|
|
return
|
|
case <-s.chShaperPending:
|
|
for {
|
|
request, ok := s.sq.Pop()
|
|
if !ok {
|
|
// notify shaperLoop to accept new requests
|
|
s.notifyShaperConsumed()
|
|
goto EVENT_LOOP
|
|
}
|
|
|
|
buf[0] = request.frame.ver
|
|
buf[1] = request.frame.cmd
|
|
binary.LittleEndian.PutUint16(buf[2:], uint16(len(request.frame.data)))
|
|
binary.LittleEndian.PutUint32(buf[4:], request.frame.sid)
|
|
|
|
// support for scatter-gather I/O
|
|
if len(vec) > 0 {
|
|
vec[0] = buf[:headerSize]
|
|
vec[1] = request.frame.data
|
|
n, err = bw.WriteBuffers(vec)
|
|
} else {
|
|
copy(buf[headerSize:], request.frame.data)
|
|
n, err = s.conn.Write(buf[:headerSize+len(request.frame.data)])
|
|
}
|
|
|
|
n -= headerSize
|
|
if n < 0 {
|
|
n = 0
|
|
}
|
|
|
|
result := writeResult{
|
|
n: n,
|
|
err: err,
|
|
}
|
|
|
|
request.result <- result
|
|
close(request.result)
|
|
|
|
// store conn error
|
|
if err != nil {
|
|
s.notifyWriteError(err)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// writeControlFrame writes the control frame to the underlying connection
|
|
// and returns the number of bytes written if successful
|
|
func (s *Session) writeControlFrame(f Frame) (n int, err error) {
|
|
timer := time.NewTimer(openCloseTimeout)
|
|
defer timer.Stop()
|
|
|
|
return s.writeFrameInternal(f, timer.C, CLSCTRL)
|
|
}
|
|
|
|
// internal writeFrame version to support deadline used in keepalive
|
|
func (s *Session) writeFrameInternal(f Frame, deadline <-chan time.Time, class CLASSID) (int, error) {
|
|
req := writeRequest{
|
|
class: class,
|
|
frame: f,
|
|
seq: atomic.AddUint32(&s.requestID, 1),
|
|
result: make(chan writeResult, 1),
|
|
}
|
|
select {
|
|
case s.shaper <- req:
|
|
case <-s.die:
|
|
return 0, io.ErrClosedPipe
|
|
case <-s.chSocketWriteError:
|
|
return 0, s.socketWriteError.Load().(error)
|
|
case <-deadline:
|
|
return 0, ErrTimeout
|
|
}
|
|
|
|
select {
|
|
case result := <-req.result:
|
|
return result.n, result.err
|
|
case <-s.die:
|
|
return 0, io.ErrClosedPipe
|
|
case <-s.chSocketWriteError:
|
|
return 0, s.socketWriteError.Load().(error)
|
|
case <-deadline:
|
|
return 0, ErrTimeout
|
|
}
|
|
}
|