2022-01-14 15:07:14 +00:00
|
|
|
# Nimbus
|
2024-02-28 17:31:45 +00:00
|
|
|
# Copyright (c) 2022-2024 Status Research & Development GmbH
|
2022-01-14 15:07:14 +00:00
|
|
|
# Licensed and distributed under either of
|
|
|
|
# * MIT license (license terms in the root directory or at https://opensource.org/licenses/MIT).
|
|
|
|
# * Apache v2 license (license terms in the root directory or at https://www.apache.org/licenses/LICENSE-2.0).
|
|
|
|
# at your option. This file may not be copied, modified, or distributed except according to those terms.
|
|
|
|
|
2023-01-31 12:38:08 +00:00
|
|
|
{.push raises: [].}
|
2022-01-20 14:04:23 +00:00
|
|
|
|
2022-01-14 15:07:14 +00:00
|
|
|
import
|
|
|
|
std/sequtils,
|
2024-02-28 17:31:45 +00:00
|
|
|
chronos,
|
|
|
|
stew/[byteutils, leb128, endians2],
|
|
|
|
chronicles,
|
2022-01-14 15:07:14 +00:00
|
|
|
eth/utp/utp_discv5_protocol,
|
|
|
|
# even though utp_discv5_protocol exports this, import is still needed,
|
|
|
|
# perhaps protocol.Protocol type of usage?
|
|
|
|
eth/p2p/discoveryv5/protocol,
|
|
|
|
./messages
|
|
|
|
|
|
|
|
export utp_discv5_protocol
|
|
|
|
|
2022-04-13 05:56:01 +00:00
|
|
|
logScope:
|
|
|
|
topics = "portal_stream"
|
|
|
|
|
2022-01-14 15:07:14 +00:00
|
|
|
const
|
2022-08-17 07:32:06 +00:00
|
|
|
utpProtocolId = "utp".toBytes()
|
2022-08-01 19:00:21 +00:00
|
|
|
defaultConnectionTimeout = 15.seconds
|
|
|
|
defaultContentReadTimeout = 60.seconds
|
2022-05-03 07:18:33 +00:00
|
|
|
|
|
|
|
# TalkReq message is used as transport for uTP. It is assumed here that Portal
|
|
|
|
# protocol messages were exchanged before sending uTP over discv5 data. This
|
|
|
|
# means that a session is established and that the discv5 messages send are
|
|
|
|
# discv5 ordinary message packets, for which below calculation applies.
|
2022-08-09 12:32:41 +00:00
|
|
|
talkReqOverhead = getTalkReqOverhead(utpProtocolId)
|
2022-05-03 07:18:33 +00:00
|
|
|
utpHeaderOverhead = 20
|
2022-08-17 07:32:06 +00:00
|
|
|
maxUtpPayloadSize = maxDiscv5PacketSize - talkReqOverhead - utpHeaderOverhead
|
2022-01-14 15:07:14 +00:00
|
|
|
|
|
|
|
type
|
|
|
|
ContentRequest = object
|
|
|
|
connectionId: uint16
|
|
|
|
nodeId: NodeId
|
2022-01-20 20:21:20 +00:00
|
|
|
content: seq[byte]
|
2022-01-14 15:07:14 +00:00
|
|
|
timeout: Moment
|
|
|
|
|
|
|
|
ContentOffer = object
|
|
|
|
connectionId: uint16
|
|
|
|
nodeId: NodeId
|
|
|
|
contentKeys: ContentKeysList
|
|
|
|
timeout: Moment
|
|
|
|
|
|
|
|
PortalStream* = ref object
|
2022-03-30 05:12:39 +00:00
|
|
|
transport: UtpDiscv5Protocol
|
2022-01-14 15:07:14 +00:00
|
|
|
# TODO:
|
|
|
|
# Decide on what's the better collection to use and set some limits in them
|
|
|
|
# on how many uTP transfers allowed to happen concurrently.
|
|
|
|
# Either set some limit, and drop whatever comes next. Unsure how to
|
|
|
|
# communicate that with the peer however. Or have some more async waiting
|
|
|
|
# until a spot becomes free, like with an AsyncQueue. Although the latter
|
|
|
|
# probably can not be used here directly. This system however does needs
|
|
|
|
# some agreement on timeout values of how long a uTP socket may be
|
|
|
|
# "listening" before it times out because of inactivity.
|
|
|
|
# Or, depending on the direction, it might also depend on the time out
|
|
|
|
# values of the discovery v5 talkresp message.
|
|
|
|
# TODO: Should the content key also be stored to be able to validate the
|
|
|
|
# received data?
|
|
|
|
contentRequests: seq[ContentRequest]
|
|
|
|
contentOffers: seq[ContentOffer]
|
2022-01-18 08:01:22 +00:00
|
|
|
connectionTimeout: Duration
|
2022-06-24 13:35:31 +00:00
|
|
|
contentReadTimeout*: Duration
|
2022-07-04 07:38:02 +00:00
|
|
|
rng: ref HmacDrbgContext
|
2023-09-04 10:21:01 +00:00
|
|
|
contentQueue*: AsyncQueue[(Opt[NodeId], ContentKeysList, seq[seq[byte]])]
|
2022-02-11 13:43:10 +00:00
|
|
|
|
2022-08-17 07:32:06 +00:00
|
|
|
StreamManager* = ref object
|
|
|
|
transport: UtpDiscv5Protocol
|
|
|
|
streams: seq[PortalStream]
|
|
|
|
rng: ref HmacDrbgContext
|
|
|
|
|
2022-04-11 17:42:38 +00:00
|
|
|
proc pruneAllowedConnections(stream: PortalStream) =
|
|
|
|
# Prune requests and offers that didn't receive a connection request
|
|
|
|
# before `connectionTimeout`.
|
|
|
|
let now = Moment.now()
|
2024-02-28 17:31:45 +00:00
|
|
|
stream.contentRequests.keepIf(
|
|
|
|
proc(x: ContentRequest): bool =
|
|
|
|
x.timeout > now
|
|
|
|
)
|
|
|
|
stream.contentOffers.keepIf(
|
|
|
|
proc(x: ContentOffer): bool =
|
|
|
|
x.timeout > now
|
|
|
|
)
|
2022-04-11 17:42:38 +00:00
|
|
|
|
2022-01-14 15:07:14 +00:00
|
|
|
proc addContentOffer*(
|
2024-02-28 17:31:45 +00:00
|
|
|
stream: PortalStream, nodeId: NodeId, contentKeys: ContentKeysList
|
|
|
|
): Bytes2 =
|
2022-04-11 17:42:38 +00:00
|
|
|
stream.pruneAllowedConnections()
|
|
|
|
|
|
|
|
# TODO: Should we check if `NodeId` & `connectionId` combo already exists?
|
|
|
|
# What happens if we get duplicates?
|
2022-01-14 15:07:14 +00:00
|
|
|
var connectionId: Bytes2
|
2022-07-04 07:38:02 +00:00
|
|
|
stream.rng[].generate(connectionId)
|
2022-01-14 15:07:14 +00:00
|
|
|
|
|
|
|
# uTP protocol uses BE for all values in the header, incl. connection id.
|
|
|
|
let id = uint16.fromBytesBE(connectionId)
|
2022-07-29 12:24:07 +00:00
|
|
|
|
|
|
|
debug "Register new incoming offer", contentKeys
|
|
|
|
|
2022-01-14 15:07:14 +00:00
|
|
|
let contentOffer = ContentOffer(
|
|
|
|
connectionId: id,
|
|
|
|
nodeId: nodeId,
|
|
|
|
contentKeys: contentKeys,
|
2024-02-28 17:31:45 +00:00
|
|
|
timeout: Moment.now() + stream.connectionTimeout,
|
|
|
|
)
|
2022-01-14 15:07:14 +00:00
|
|
|
stream.contentOffers.add(contentOffer)
|
|
|
|
|
|
|
|
return connectionId
|
|
|
|
|
|
|
|
proc addContentRequest*(
|
2024-02-28 17:31:45 +00:00
|
|
|
stream: PortalStream, nodeId: NodeId, content: seq[byte]
|
|
|
|
): Bytes2 =
|
2022-04-11 17:42:38 +00:00
|
|
|
stream.pruneAllowedConnections()
|
|
|
|
|
|
|
|
# TODO: Should we check if `NodeId` & `connectionId` combo already exists?
|
|
|
|
# What happens if we get duplicates?
|
2022-01-14 15:07:14 +00:00
|
|
|
var connectionId: Bytes2
|
2022-07-04 07:38:02 +00:00
|
|
|
stream.rng[].generate(connectionId)
|
2022-01-14 15:07:14 +00:00
|
|
|
|
|
|
|
# uTP protocol uses BE for all values in the header, incl. connection id.
|
|
|
|
let id = uint16.fromBytesBE(connectionId)
|
|
|
|
let contentRequest = ContentRequest(
|
|
|
|
connectionId: id,
|
|
|
|
nodeId: nodeId,
|
|
|
|
content: content,
|
2024-02-28 17:31:45 +00:00
|
|
|
timeout: Moment.now() + stream.connectionTimeout,
|
|
|
|
)
|
2022-01-14 15:07:14 +00:00
|
|
|
stream.contentRequests.add(contentRequest)
|
|
|
|
|
|
|
|
return connectionId
|
|
|
|
|
2022-03-30 05:12:39 +00:00
|
|
|
proc connectTo*(
|
2024-02-28 17:31:45 +00:00
|
|
|
stream: PortalStream, nodeAddress: NodeAddress, connectionId: uint16
|
2024-06-14 12:21:30 +00:00
|
|
|
): Future[Result[UtpSocket[NodeAddress], string]] {.async: (raises: [CancelledError]).} =
|
2023-06-15 12:32:33 +00:00
|
|
|
let connectRes = await stream.transport.connectTo(nodeAddress, connectionId)
|
|
|
|
if connectRes.isErr():
|
2024-06-14 07:31:08 +00:00
|
|
|
case connectRes.error
|
2022-03-30 05:12:39 +00:00
|
|
|
of SocketAlreadyExists:
|
2022-04-13 05:56:01 +00:00
|
|
|
# This means that there is already a socket to this nodeAddress with given
|
2023-06-15 12:32:33 +00:00
|
|
|
# connection id. This means that a peer sent us a connection id which is
|
|
|
|
# already in use. The connection is failed and an error returned.
|
2024-02-28 17:31:45 +00:00
|
|
|
let msg =
|
|
|
|
"Socket to " & $nodeAddress & "with connection id: " & $connectionId &
|
|
|
|
" already exists"
|
2022-03-30 05:12:39 +00:00
|
|
|
return err(msg)
|
|
|
|
of ConnectionTimedOut:
|
2023-06-15 12:32:33 +00:00
|
|
|
# A time-out here means that a uTP SYN packet was re-sent 3 times and
|
|
|
|
# failed to be acked. This should be enough of indication that the
|
|
|
|
# remote host is not reachable and no new connections are attempted.
|
2022-03-30 05:12:39 +00:00
|
|
|
let msg = "uTP timeout while trying to connect to " & $nodeAddress
|
|
|
|
return err(msg)
|
2023-06-15 12:32:33 +00:00
|
|
|
else:
|
|
|
|
return ok(connectRes.get())
|
2022-03-30 05:12:39 +00:00
|
|
|
|
2022-06-24 13:35:31 +00:00
|
|
|
proc writeContentRequest(
|
2024-02-28 17:31:45 +00:00
|
|
|
socket: UtpSocket[NodeAddress], stream: PortalStream, request: ContentRequest
|
2024-06-14 07:31:08 +00:00
|
|
|
) {.async: (raises: [CancelledError]).} =
|
2024-02-28 17:31:45 +00:00
|
|
|
let dataWritten = await socket.write(request.content)
|
2022-01-14 15:07:14 +00:00
|
|
|
if dataWritten.isErr():
|
|
|
|
debug "Error writing requested data", error = dataWritten.error
|
|
|
|
|
|
|
|
await socket.closeWait()
|
|
|
|
|
2024-06-14 07:31:08 +00:00
|
|
|
proc readVarint(
|
|
|
|
socket: UtpSocket[NodeAddress]
|
|
|
|
): Future[Opt[uint32]] {.async: (raises: [CancelledError]).} =
|
2024-02-28 17:31:45 +00:00
|
|
|
var buffer: array[5, byte]
|
2022-06-24 13:35:31 +00:00
|
|
|
|
2024-02-28 17:31:45 +00:00
|
|
|
for i in 0 ..< len(buffer):
|
2022-06-24 13:35:31 +00:00
|
|
|
let dataRead = await socket.read(1)
|
|
|
|
if dataRead.len() == 0:
|
|
|
|
return err()
|
|
|
|
|
|
|
|
buffer[i] = dataRead[0]
|
|
|
|
|
|
|
|
let (lenU32, bytesRead) = fromBytes(uint32, buffer.toOpenArray(0, i), Leb128)
|
|
|
|
if bytesRead > 0:
|
|
|
|
return ok(lenU32)
|
|
|
|
elif bytesRead == 0:
|
|
|
|
continue
|
|
|
|
else:
|
|
|
|
return err()
|
|
|
|
|
2024-06-14 07:31:08 +00:00
|
|
|
proc readContentItem(
|
|
|
|
socket: UtpSocket[NodeAddress]
|
|
|
|
): Future[Opt[seq[byte]]] {.async: (raises: [CancelledError]).} =
|
2022-06-24 13:35:31 +00:00
|
|
|
let len = await socket.readVarint()
|
|
|
|
|
|
|
|
if len.isOk():
|
|
|
|
let contentItem = await socket.read(len.get())
|
|
|
|
if contentItem.len() == len.get().int:
|
|
|
|
return ok(contentItem)
|
|
|
|
else:
|
|
|
|
return err()
|
|
|
|
else:
|
|
|
|
return err()
|
|
|
|
|
|
|
|
proc readContentOffer(
|
2024-02-28 17:31:45 +00:00
|
|
|
socket: UtpSocket[NodeAddress], stream: PortalStream, offer: ContentOffer
|
2024-06-14 07:31:08 +00:00
|
|
|
) {.async: (raises: [CancelledError]).} =
|
2022-06-24 13:35:31 +00:00
|
|
|
# Read number of content items according to amount of ContentKeys accepted.
|
|
|
|
# This will either end with a FIN, or because the read action times out or
|
|
|
|
# because the number of expected items was read (if this happens and no FIN
|
|
|
|
# was received yet, a FIN will be send from this side).
|
|
|
|
# None of this means that the contentItems are valid, further validation is
|
|
|
|
# required.
|
|
|
|
# Socket will be closed when this call ends.
|
|
|
|
|
|
|
|
# TODO: Currently reading from the socket 1 item at a time, and validating
|
|
|
|
# items at later time. Uncertain what is best approach here (mostly from a
|
|
|
|
# security PoV), e.g. other options such as reading all content from socket at
|
|
|
|
# once, then processing the individual content items. Or reading and
|
|
|
|
# validating one per time.
|
|
|
|
let amount = offer.contentKeys.len()
|
|
|
|
|
|
|
|
var contentItems: seq[seq[byte]]
|
2024-02-28 17:31:45 +00:00
|
|
|
for i in 0 ..< amount:
|
2022-06-24 13:35:31 +00:00
|
|
|
let contentItemFut = socket.readContentItem()
|
|
|
|
if await contentItemFut.withTimeout(stream.contentReadTimeout):
|
2024-06-14 07:31:08 +00:00
|
|
|
let contentItem = await contentItemFut
|
2022-06-24 13:35:31 +00:00
|
|
|
|
|
|
|
if contentItem.isOk():
|
|
|
|
contentItems.add(contentItem.get())
|
|
|
|
else:
|
|
|
|
# Invalid data, stop reading content, but still process data received
|
|
|
|
# so far.
|
2022-08-22 10:23:26 +00:00
|
|
|
debug "Reading content item failed, content offer failed",
|
|
|
|
contentKeys = offer.contentKeys
|
2022-06-24 13:35:31 +00:00
|
|
|
break
|
|
|
|
else:
|
|
|
|
# Read timed out, stop further reading, but still process data received
|
|
|
|
# so far.
|
2022-08-22 10:23:26 +00:00
|
|
|
debug "Reading data from socket timed out, content offer failed",
|
|
|
|
contentKeys = offer.contentKeys
|
2022-06-24 13:35:31 +00:00
|
|
|
break
|
|
|
|
|
|
|
|
if socket.atEof():
|
2022-04-13 05:56:01 +00:00
|
|
|
# Destroy socket and not closing as we already received FIN. Closing would
|
|
|
|
# send also a FIN from our side, see also:
|
2022-01-20 14:04:23 +00:00
|
|
|
# https://github.com/status-im/nim-eth/blob/b2dab4be0839c95ca2564df9eacf81995bf57802/eth/utp/utp_socket.nim#L1223
|
|
|
|
await socket.destroyWait()
|
2022-01-14 15:07:14 +00:00
|
|
|
else:
|
2022-06-24 13:35:31 +00:00
|
|
|
# This means FIN didn't arrive yet, perhaps it got dropped but it might also
|
|
|
|
# be still in flight. Closing the socket (= sending FIN) ourselves.
|
2023-04-04 15:36:57 +00:00
|
|
|
# Not waiting here for its ACK however, so no `closeWait`. Underneath the
|
|
|
|
# socket will still wait for the FIN-ACK (or timeout) before it destroys the
|
|
|
|
# socket.
|
2022-01-20 14:04:23 +00:00
|
|
|
socket.close()
|
2022-01-14 15:07:14 +00:00
|
|
|
|
2022-07-11 14:29:16 +00:00
|
|
|
# TODO: This could currently create a backlog of content items to be validated
|
|
|
|
# as `AcceptConnectionCallback` is `asyncSpawn`'ed and there are no limits
|
|
|
|
# on the `contentOffers`. Might move the queue to before the reading of the
|
|
|
|
# socket, and let the specific networks handle that.
|
2024-02-28 17:31:45 +00:00
|
|
|
await stream.contentQueue.put(
|
|
|
|
(Opt.some(offer.nodeId), offer.contentKeys, contentItems)
|
|
|
|
)
|
2022-06-24 13:35:31 +00:00
|
|
|
|
2022-08-17 07:32:06 +00:00
|
|
|
proc new(
|
2022-02-11 13:43:10 +00:00
|
|
|
T: type PortalStream,
|
2022-08-17 07:32:06 +00:00
|
|
|
transport: UtpDiscv5Protocol,
|
2023-09-04 10:21:01 +00:00
|
|
|
contentQueue: AsyncQueue[(Opt[NodeId], ContentKeysList, seq[seq[byte]])],
|
2022-08-17 07:32:06 +00:00
|
|
|
connectionTimeout: Duration,
|
|
|
|
contentReadTimeout: Duration,
|
2024-02-28 17:31:45 +00:00
|
|
|
rng: ref HmacDrbgContext,
|
|
|
|
): T =
|
2022-08-17 07:32:06 +00:00
|
|
|
let stream = PortalStream(
|
|
|
|
transport: transport,
|
|
|
|
connectionTimeout: connectionTimeout,
|
|
|
|
contentReadTimeout: contentReadTimeout,
|
|
|
|
contentQueue: contentQueue,
|
2024-02-28 17:31:45 +00:00
|
|
|
rng: rng,
|
2022-08-17 07:32:06 +00:00
|
|
|
)
|
2022-02-11 13:43:10 +00:00
|
|
|
|
|
|
|
stream
|
|
|
|
|
|
|
|
proc allowedConnection(
|
2024-02-28 17:31:45 +00:00
|
|
|
stream: PortalStream, address: NodeAddress, connectionId: uint16
|
|
|
|
): bool =
|
2022-02-11 13:43:10 +00:00
|
|
|
return
|
|
|
|
stream.contentRequests.any(
|
2024-02-28 17:31:45 +00:00
|
|
|
proc(x: ContentRequest): bool =
|
|
|
|
x.connectionId == connectionId and x.nodeId == address.nodeId
|
|
|
|
) or
|
2022-02-11 13:43:10 +00:00
|
|
|
stream.contentOffers.any(
|
2024-02-28 17:31:45 +00:00
|
|
|
proc(x: ContentOffer): bool =
|
|
|
|
x.connectionId == connectionId and x.nodeId == address.nodeId
|
|
|
|
)
|
2022-02-11 13:43:10 +00:00
|
|
|
|
2022-08-22 10:23:26 +00:00
|
|
|
proc handleIncomingConnection(
|
2024-02-28 17:31:45 +00:00
|
|
|
server: UtpRouter[NodeAddress], socket: UtpSocket[NodeAddress]
|
2024-06-14 07:31:08 +00:00
|
|
|
): Future[void] {.async: (raw: true, raises: []).} =
|
2022-08-17 07:32:06 +00:00
|
|
|
let manager = getUserData[NodeAddress, StreamManager](server)
|
|
|
|
|
|
|
|
for stream in manager.streams:
|
|
|
|
# Note: Connection id of uTP SYN is different from other packets, it is
|
|
|
|
# actually the peers `send_conn_id`, opposed to `receive_conn_id` for all
|
|
|
|
# other packets.
|
|
|
|
for i, request in stream.contentRequests:
|
|
|
|
if request.connectionId == socket.connectionId and
|
|
|
|
request.nodeId == socket.remoteAddress.nodeId:
|
|
|
|
let fut = socket.writeContentRequest(stream, request)
|
|
|
|
stream.contentRequests.del(i)
|
2024-06-14 07:31:08 +00:00
|
|
|
return noCancel(fut)
|
2022-08-17 07:32:06 +00:00
|
|
|
|
|
|
|
for i, offer in stream.contentOffers:
|
|
|
|
if offer.connectionId == socket.connectionId and
|
|
|
|
offer.nodeId == socket.remoteAddress.nodeId:
|
|
|
|
let fut = socket.readContentOffer(stream, offer)
|
|
|
|
stream.contentOffers.del(i)
|
2024-06-14 07:31:08 +00:00
|
|
|
return noCancel(fut)
|
2022-08-17 07:32:06 +00:00
|
|
|
|
|
|
|
# TODO: Is there a scenario where this can happen,
|
|
|
|
# considering `allowRegisteredIdCallback`? If not, doAssert?
|
|
|
|
var fut = newFuture[void]("fluffy.AcceptConnectionCallback")
|
|
|
|
fut.complete()
|
|
|
|
return fut
|
|
|
|
|
2022-08-22 10:23:26 +00:00
|
|
|
proc allowIncomingConnection(
|
2024-02-28 17:31:45 +00:00
|
|
|
r: UtpRouter[NodeAddress], remoteAddress: NodeAddress, connectionId: uint16
|
|
|
|
): bool =
|
2022-08-17 07:32:06 +00:00
|
|
|
let manager = getUserData[NodeAddress, StreamManager](r)
|
|
|
|
for stream in manager.streams:
|
|
|
|
# stream.pruneAllowedConnections()
|
|
|
|
if allowedConnection(stream, remoteAddress, connectionId):
|
|
|
|
return true
|
|
|
|
|
|
|
|
proc new*(T: type StreamManager, d: protocol.Protocol): T =
|
2022-08-22 10:23:26 +00:00
|
|
|
let
|
|
|
|
socketConfig = SocketConfig.init(
|
|
|
|
# Setting to none means that incoming sockets are in Connected state, which
|
|
|
|
# means they can send and receive data.
|
|
|
|
incomingSocketReceiveTimeout = none(Duration),
|
2024-02-28 17:31:45 +00:00
|
|
|
payloadSize = uint32(maxUtpPayloadSize),
|
2022-08-22 10:23:26 +00:00
|
|
|
)
|
|
|
|
manager = StreamManager(streams: @[], rng: d.rng)
|
|
|
|
utpOverDiscV5Protocol = UtpDiscv5Protocol.new(
|
2024-02-28 17:31:45 +00:00
|
|
|
d, utpProtocolId, handleIncomingConnection, manager, allowIncomingConnection,
|
|
|
|
socketConfig,
|
2022-08-22 10:23:26 +00:00
|
|
|
)
|
2022-08-17 07:32:06 +00:00
|
|
|
|
|
|
|
manager.transport = utpOverDiscV5Protocol
|
|
|
|
|
|
|
|
return manager
|
|
|
|
|
|
|
|
proc registerNewStream*(
|
2024-02-28 17:31:45 +00:00
|
|
|
m: StreamManager,
|
2023-09-04 10:21:01 +00:00
|
|
|
contentQueue: AsyncQueue[(Opt[NodeId], ContentKeysList, seq[seq[byte]])],
|
2022-08-17 07:32:06 +00:00
|
|
|
connectionTimeout = defaultConnectionTimeout,
|
2024-02-28 17:31:45 +00:00
|
|
|
contentReadTimeout = defaultContentReadTimeout,
|
|
|
|
): PortalStream =
|
2022-08-17 07:32:06 +00:00
|
|
|
let s = PortalStream.new(
|
2024-02-28 17:31:45 +00:00
|
|
|
m.transport, contentQueue, connectionTimeout, contentReadTimeout, m.rng
|
2022-08-17 07:32:06 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
m.streams.add(s)
|
|
|
|
|
|
|
|
return s
|