test and fix half of connector.py
still to do: * relay delays * connection race * cancellation of losing connections * shutdown of all connections when abandoned
This commit is contained in:
parent
e7cb1df785
commit
6ad6f8f40f
|
@ -34,8 +34,11 @@ def build_sided_relay_handshake(key, side):
|
|||
|
||||
PROLOGUE_LEADER = b"Magic-Wormhole Dilation Handshake v1 Leader\n\n"
|
||||
PROLOGUE_FOLLOWER = b"Magic-Wormhole Dilation Handshake v1 Follower\n\n"
|
||||
NOISEPROTO = "Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s"
|
||||
NOISEPROTO = b"Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s"
|
||||
|
||||
def build_noise():
|
||||
from noise.connection import NoiseConnection
|
||||
return NoiseConnection.from_name(NOISEPROTO)
|
||||
|
||||
@attrs
|
||||
@implementer(IDilationConnector)
|
||||
|
@ -53,7 +56,7 @@ class Connector(object):
|
|||
_role = attrib()
|
||||
|
||||
m = MethodicalMachine()
|
||||
set_trace = getattr(m, "_setTrace", lambda self, f: None)
|
||||
set_trace = getattr(m, "_setTrace", lambda self, f: None) # pragma: no cover
|
||||
|
||||
RELAY_DELAY = 2.0
|
||||
|
||||
|
@ -85,8 +88,7 @@ class Connector(object):
|
|||
# encryption: let's use Noise NNpsk0 (or maybe NNpsk2). That uses
|
||||
# ephemeral keys plus a pre-shared symmetric key (the Transit key), a
|
||||
# different one for each potential connection.
|
||||
from noise.connection import NoiseConnection
|
||||
noise = NoiseConnection.from_name(NOISEPROTO)
|
||||
noise = build_noise()
|
||||
noise.set_psks(self._dilation_key)
|
||||
if self._role is LEADER:
|
||||
noise.set_as_initiator()
|
||||
|
@ -144,6 +146,9 @@ class Connector(object):
|
|||
|
||||
@m.output()
|
||||
def publish_hints(self, hint_objs):
|
||||
self._publish_hints(hint_objs)
|
||||
|
||||
def _publish_hints(self, hint_objs):
|
||||
self._manager.send_hints([encode_hint(h) for h in hint_objs])
|
||||
|
||||
@m.output()
|
||||
|
@ -229,7 +234,7 @@ class Connector(object):
|
|||
def start(self):
|
||||
self._start_listener()
|
||||
if self._transit_relays:
|
||||
self.publish_hints(self._transit_relays)
|
||||
self._publish_hints(self._transit_relays)
|
||||
self._use_hints(self._transit_relays)
|
||||
|
||||
def _start_listener(self):
|
||||
|
@ -260,14 +265,15 @@ class Connector(object):
|
|||
|
||||
def _use_hints(self, hints):
|
||||
# first, pull out all the relays, we'll connect to them later
|
||||
relays = defaultdict(list)
|
||||
relays = []
|
||||
direct = defaultdict(list)
|
||||
for h in hints:
|
||||
if isinstance(h, RelayV1Hint):
|
||||
relays[h.priority].append(h)
|
||||
relays.append(h)
|
||||
else:
|
||||
direct[h.priority].append(h)
|
||||
delay = 0.0
|
||||
made_direct = False
|
||||
priorities = sorted(set(direct.keys()), reverse=True)
|
||||
for p in priorities:
|
||||
for h in direct[p]:
|
||||
|
@ -277,7 +283,9 @@ class Connector(object):
|
|||
desc = describe_hint_obj(h, False, self._tor)
|
||||
d = deferLater(self._reactor, delay,
|
||||
self._connect, ep, desc, is_relay=False)
|
||||
d.addErrback(log.err)
|
||||
self._pending_connectors.add(d)
|
||||
made_direct = True
|
||||
# Make all direct connections immediately. Later, we'll change
|
||||
# the add_candidate() function to look at the priority when
|
||||
# deciding whether to accept a successful connection or not,
|
||||
|
@ -286,34 +294,32 @@ class Connector(object):
|
|||
# putting an inter-direct-hint delay here to influence the
|
||||
# process.
|
||||
# delay += 1.0
|
||||
if delay > 0.0:
|
||||
# Start trying the relays a few seconds after we start to try the
|
||||
# direct hints. The idea is to prefer direct connections, but not
|
||||
# be afraid of using a relay when we have direct hints that don't
|
||||
# resolve quickly. Many direct hints will be to unused
|
||||
# local-network IP addresses, which won't answer, and would take
|
||||
# the full TCP timeout (30s or more) to fail. If there were no
|
||||
# direct hints, don't delay at all.
|
||||
delay += self.RELAY_DELAY
|
||||
|
||||
# prefer direct connections by stalling relay connections by a few
|
||||
# seconds, unless we're using --no-listen in which case we're probably
|
||||
# going to have to use the relay
|
||||
delay = self.RELAY_DELAY if self._no_listen else 0.0
|
||||
if made_direct and not self._no_listen:
|
||||
# Prefer direct connections by stalling relay connections by a
|
||||
# few seconds. We don't wait until direct connections have
|
||||
# failed, because many direct hints will be to unused
|
||||
# local-network IP address, which won't answer, and can take the
|
||||
# full 30s TCP timeout to fail.
|
||||
#
|
||||
# If we didn't make any direct connections, or we're using
|
||||
# --no-listen, then we're probably going to have to use the
|
||||
# relay, so don't delay it at all.
|
||||
delay += self.RELAY_DELAY
|
||||
|
||||
# It might be nice to wire this so that a failure in the direct hints
|
||||
# causes the relay hints to be used right away (fast failover). But
|
||||
# none of our current use cases would take advantage of that: if we
|
||||
# have any viable direct hints, then they're either going to succeed
|
||||
# quickly or hang for a long time.
|
||||
for p in priorities:
|
||||
for r in relays[p]:
|
||||
for h in r.hints:
|
||||
ep = endpoint_from_hint_obj(h, self._tor, self._reactor)
|
||||
desc = describe_hint_obj(h, True, self._tor)
|
||||
d = deferLater(self._reactor, delay,
|
||||
self._connect, ep, desc, is_relay=True)
|
||||
self._pending_connectors.add(d)
|
||||
for r in relays:
|
||||
for h in r.hints:
|
||||
ep = endpoint_from_hint_obj(h, self._tor, self._reactor)
|
||||
desc = describe_hint_obj(h, True, self._tor)
|
||||
d = deferLater(self._reactor, delay,
|
||||
self._connect, ep, desc, is_relay=True)
|
||||
d.addErrback(log.err)
|
||||
self._pending_connectors.add(d)
|
||||
# TODO:
|
||||
# if not contenders:
|
||||
# raise TransitError("No contenders for connection")
|
||||
|
@ -321,7 +327,7 @@ class Connector(object):
|
|||
# TODO: add 2*TIMEOUT deadline for first generation, don't wait forever for
|
||||
# the initial connection
|
||||
|
||||
def _connect(self, h, ep, description, is_relay=False):
|
||||
def _connect(self, ep, description, is_relay=False):
|
||||
relay_handshake = None
|
||||
if is_relay:
|
||||
relay_handshake = build_sided_relay_handshake(self._dilation_key,
|
||||
|
@ -369,7 +375,6 @@ class OutboundConnectionFactory(ClientFactory, object):
|
|||
@attrs
|
||||
class InboundConnectionFactory(ServerFactory, object):
|
||||
_connector = attrib(validator=provides(IDilationConnector))
|
||||
protocol = DilatedConnectionProtocol
|
||||
|
||||
def buildProtocol(self, addr):
|
||||
p = self._connector.build_protocol(addr)
|
||||
|
|
329
src/wormhole/test/dilate/test_connector.py
Normal file
329
src/wormhole/test/dilate/test_connector.py
Normal file
|
@ -0,0 +1,329 @@
|
|||
from __future__ import print_function, unicode_literals
|
||||
|
||||
import mock
|
||||
from zope.interface import alsoProvides
|
||||
from twisted.trial import unittest
|
||||
from twisted.internet.task import Clock
|
||||
from twisted.internet.defer import Deferred
|
||||
#from twisted.internet import endpoints
|
||||
from ...eventual import EventualQueue
|
||||
from ..._interfaces import IDilationManager, IDilationConnector
|
||||
from ..._dilation import roles
|
||||
from ..._hints import DirectTCPV1Hint, RelayV1Hint, TorTCPV1Hint
|
||||
from ..._dilation.connector import (#describe_hint_obj, parse_hint_argv,
|
||||
#parse_tcp_v1_hint, parse_hint, encode_hint,
|
||||
Connector,
|
||||
build_sided_relay_handshake,
|
||||
build_noise,
|
||||
OutboundConnectionFactory,
|
||||
InboundConnectionFactory,
|
||||
PROLOGUE_LEADER, PROLOGUE_FOLLOWER,
|
||||
)
|
||||
|
||||
class Handshake(unittest.TestCase):
|
||||
def test_build(self):
|
||||
key = b"k"*32
|
||||
side = "12345678abcdabcd"
|
||||
self.assertEqual(build_sided_relay_handshake(key, side),
|
||||
b"please relay 3f4147851dbd2589d25b654ee9fb35ed0d3e5f19c5c5403e8e6a195c70f0577a for side 12345678abcdabcd\n")
|
||||
|
||||
class Outbound(unittest.TestCase):
|
||||
def test_no_relay(self):
|
||||
c = mock.Mock()
|
||||
alsoProvides(c, IDilationConnector)
|
||||
p0 = mock.Mock()
|
||||
c.build_protocol = mock.Mock(return_value=p0)
|
||||
relay_handshake = None
|
||||
f = OutboundConnectionFactory(c, relay_handshake)
|
||||
addr = object()
|
||||
p = f.buildProtocol(addr)
|
||||
self.assertIdentical(p, p0)
|
||||
self.assertEqual(c.mock_calls, [mock.call.build_protocol(addr)])
|
||||
self.assertEqual(p.mock_calls, [])
|
||||
self.assertIdentical(p.factory, f)
|
||||
|
||||
def test_with_relay(self):
|
||||
c = mock.Mock()
|
||||
alsoProvides(c, IDilationConnector)
|
||||
p0 = mock.Mock()
|
||||
c.build_protocol = mock.Mock(return_value=p0)
|
||||
relay_handshake = b"relay handshake"
|
||||
f = OutboundConnectionFactory(c, relay_handshake)
|
||||
addr = object()
|
||||
p = f.buildProtocol(addr)
|
||||
self.assertIdentical(p, p0)
|
||||
self.assertEqual(c.mock_calls, [mock.call.build_protocol(addr)])
|
||||
self.assertEqual(p.mock_calls, [mock.call.use_relay(relay_handshake)])
|
||||
self.assertIdentical(p.factory, f)
|
||||
|
||||
class Inbound(unittest.TestCase):
|
||||
def test_build(self):
|
||||
c = mock.Mock()
|
||||
alsoProvides(c, IDilationConnector)
|
||||
p0 = mock.Mock()
|
||||
c.build_protocol = mock.Mock(return_value=p0)
|
||||
f = InboundConnectionFactory(c)
|
||||
addr = object()
|
||||
p = f.buildProtocol(addr)
|
||||
self.assertIdentical(p, p0)
|
||||
self.assertEqual(c.mock_calls, [mock.call.build_protocol(addr)])
|
||||
self.assertIdentical(p.factory, f)
|
||||
|
||||
def make_connector(listen=True, tor=False, relay=None, role=roles.LEADER):
|
||||
class Holder:
|
||||
pass
|
||||
h = Holder()
|
||||
h.dilation_key = b"key"
|
||||
h.relay = relay
|
||||
h.manager = mock.Mock()
|
||||
alsoProvides(h.manager, IDilationManager)
|
||||
h.clock = Clock()
|
||||
h.reactor = h.clock
|
||||
h.eq = EventualQueue(h.clock)
|
||||
h.tor = None
|
||||
if tor:
|
||||
h.tor = mock.Mock()
|
||||
timing = None
|
||||
h.side = u"abcd1234abcd5678"
|
||||
h.role = role
|
||||
c = Connector(h.dilation_key, h.relay, h.manager, h.reactor, h.eq,
|
||||
not listen, h.tor, timing, h.side, h.role)
|
||||
return c, h
|
||||
|
||||
class TestConnector(unittest.TestCase):
|
||||
def test_build(self):
|
||||
c, h = make_connector()
|
||||
c, h = make_connector(relay="tcp:host:1234")
|
||||
|
||||
def test_connection_abilities(self):
|
||||
self.assertEqual(Connector.get_connection_abilities(),
|
||||
[{"type": "direct-tcp-v1"},
|
||||
{"type": "relay-v1"},
|
||||
])
|
||||
|
||||
def test_build_noise(self):
|
||||
build_noise()
|
||||
|
||||
def test_build_protocol_leader(self):
|
||||
c, h = make_connector(role=roles.LEADER)
|
||||
n0 = mock.Mock()
|
||||
p0 = mock.Mock()
|
||||
addr = object()
|
||||
with mock.patch("wormhole._dilation.connector.build_noise",
|
||||
return_value=n0) as bn:
|
||||
with mock.patch("wormhole._dilation.connector.DilatedConnectionProtocol",
|
||||
return_value=p0) as dcp:
|
||||
p = c.build_protocol(addr)
|
||||
self.assertEqual(bn.mock_calls, [mock.call()])
|
||||
self.assertEqual(n0.mock_calls, [mock.call.set_psks(h.dilation_key),
|
||||
mock.call.set_as_initiator()])
|
||||
self.assertIdentical(p, p0)
|
||||
self.assertEqual(dcp.mock_calls,
|
||||
[mock.call(h.eq, h.role, c, n0,
|
||||
PROLOGUE_LEADER, PROLOGUE_FOLLOWER)])
|
||||
|
||||
def test_build_protocol_follower(self):
|
||||
c, h = make_connector(role=roles.FOLLOWER)
|
||||
n0 = mock.Mock()
|
||||
p0 = mock.Mock()
|
||||
addr = object()
|
||||
with mock.patch("wormhole._dilation.connector.build_noise",
|
||||
return_value=n0) as bn:
|
||||
with mock.patch("wormhole._dilation.connector.DilatedConnectionProtocol",
|
||||
return_value=p0) as dcp:
|
||||
p = c.build_protocol(addr)
|
||||
self.assertEqual(bn.mock_calls, [mock.call()])
|
||||
self.assertEqual(n0.mock_calls, [mock.call.set_psks(h.dilation_key),
|
||||
mock.call.set_as_responder()])
|
||||
self.assertIdentical(p, p0)
|
||||
self.assertEqual(dcp.mock_calls,
|
||||
[mock.call(h.eq, h.role, c, n0,
|
||||
PROLOGUE_FOLLOWER, PROLOGUE_LEADER)])
|
||||
|
||||
def test_start_stop(self):
|
||||
c, h = make_connector(listen=False, relay=None, role=roles.LEADER)
|
||||
c.start()
|
||||
# no relays, so it publishes no hints
|
||||
self.assertEqual(h.manager.mock_calls, [])
|
||||
# and no listener, so nothing happens until we provide a hint
|
||||
c.stop()
|
||||
# we stop while we're connecting, so no connections must be stopped
|
||||
|
||||
def test_basic(self):
|
||||
c, h = make_connector(listen=False, relay=None, role=roles.LEADER)
|
||||
c.start()
|
||||
# no relays, so it publishes no hints
|
||||
self.assertEqual(h.manager.mock_calls, [])
|
||||
# and no listener, so nothing happens until we provide a hint
|
||||
|
||||
ep0 = mock.Mock()
|
||||
ep0_connect_d = Deferred()
|
||||
ep0.connect = mock.Mock(return_value=ep0_connect_d)
|
||||
efho = mock.Mock(side_effect=[ep0])
|
||||
hint0 = DirectTCPV1Hint("foo", 55, 0.0)
|
||||
dho = mock.Mock(side_effect=["desc0"])
|
||||
with mock.patch("wormhole._dilation.connector.endpoint_from_hint_obj",
|
||||
efho):
|
||||
with mock.patch("wormhole._dilation.connector.describe_hint_obj", dho):
|
||||
c.got_hints([hint0])
|
||||
self.assertEqual(efho.mock_calls, [mock.call(hint0, h.tor, h.reactor)])
|
||||
self.assertEqual(dho.mock_calls, [mock.call(hint0, False, h.tor)])
|
||||
f0 = mock.Mock()
|
||||
with mock.patch("wormhole._dilation.connector.OutboundConnectionFactory",
|
||||
return_value=f0) as ocf:
|
||||
h.clock.advance(c.RELAY_DELAY / 2 + 0.01)
|
||||
self.assertEqual(ocf.mock_calls, [mock.call(c, None)])
|
||||
self.assertEqual(ep0.connect.mock_calls, [mock.call(f0)])
|
||||
|
||||
p = mock.Mock()
|
||||
ep0_connect_d.callback(p)
|
||||
self.assertEqual(p.mock_calls,
|
||||
[mock.call.when_disconnected(),
|
||||
mock.call.when_disconnected().addCallback(c._pending_connections.discard)])
|
||||
|
||||
def test_listen(self):
|
||||
c, h = make_connector(listen=True, role=roles.LEADER)
|
||||
d = Deferred()
|
||||
ep = mock.Mock()
|
||||
ep.listen = mock.Mock(return_value=d)
|
||||
f = mock.Mock()
|
||||
with mock.patch("wormhole.ipaddrs.find_addresses",
|
||||
return_value=["127.0.0.1", "1.2.3.4", "5.6.7.8"]):
|
||||
with mock.patch("wormhole._dilation.connector.serverFromString",
|
||||
side_effect=[ep]):
|
||||
with mock.patch("wormhole._dilation.connector.InboundConnectionFactory",
|
||||
return_value=f):
|
||||
c.start()
|
||||
# no relays and the listener isn't ready yet, so no hints yet
|
||||
self.assertEqual(h.manager.mock_calls, [])
|
||||
# but a listener was started
|
||||
self.assertEqual(ep.mock_calls, [mock.call.listen(f)])
|
||||
lp = mock.Mock()
|
||||
host = mock.Mock()
|
||||
host.port = 2345
|
||||
lp.getHost = mock.Mock(return_value=host)
|
||||
d.callback(lp)
|
||||
self.assertEqual(h.manager.mock_calls,
|
||||
[mock.call.send_hints(
|
||||
[{"type": "direct-tcp-v1", "hostname": "1.2.3.4",
|
||||
"port": 2345, "priority": 0.0},
|
||||
{"type": "direct-tcp-v1", "hostname": "5.6.7.8",
|
||||
"port": 2345, "priority": 0.0},
|
||||
])])
|
||||
|
||||
def test_listen_but_tor(self):
|
||||
c, h = make_connector(listen=True, tor=True, role=roles.LEADER)
|
||||
with mock.patch("wormhole.ipaddrs.find_addresses",
|
||||
return_value=["127.0.0.1", "1.2.3.4", "5.6.7.8"]) as fa:
|
||||
c.start()
|
||||
# don't even look up addresses
|
||||
self.assertEqual(fa.mock_calls, [])
|
||||
# no relays and the listener isn't ready yet, so no hints yet
|
||||
self.assertEqual(h.manager.mock_calls, [])
|
||||
|
||||
def test_listen_only_loopback(self):
|
||||
# some test hosts, including the appveyor VMs, *only* have
|
||||
# 127.0.0.1, and the tests will hang badly if we remove it.
|
||||
c, h = make_connector(listen=True, role=roles.LEADER)
|
||||
d = Deferred()
|
||||
ep = mock.Mock()
|
||||
ep.listen = mock.Mock(return_value=d)
|
||||
f = mock.Mock()
|
||||
with mock.patch("wormhole.ipaddrs.find_addresses", return_value=["127.0.0.1"]):
|
||||
with mock.patch("wormhole._dilation.connector.serverFromString",
|
||||
side_effect=[ep]):
|
||||
with mock.patch("wormhole._dilation.connector.InboundConnectionFactory",
|
||||
return_value=f):
|
||||
c.start()
|
||||
# no relays and the listener isn't ready yet, so no hints yet
|
||||
self.assertEqual(h.manager.mock_calls, [])
|
||||
# but a listener was started
|
||||
self.assertEqual(ep.mock_calls, [mock.call.listen(f)])
|
||||
lp = mock.Mock()
|
||||
host = mock.Mock()
|
||||
host.port = 2345
|
||||
lp.getHost = mock.Mock(return_value=host)
|
||||
d.callback(lp)
|
||||
self.assertEqual(h.manager.mock_calls,
|
||||
[mock.call.send_hints(
|
||||
[{"type": "direct-tcp-v1", "hostname": "127.0.0.1",
|
||||
"port": 2345, "priority": 0.0},
|
||||
])])
|
||||
|
||||
def OFFtest_relay_delay(self):
|
||||
# given a direct connection and a relay, we should see the direct
|
||||
# connection initiated at T+0 seconds, and the relay at T+RELAY_DELAY
|
||||
c, h = make_connector(listen=False, relay="tcp:foo:55", role=roles.LEADER)
|
||||
c.start()
|
||||
hint1 = DirectTCPV1Hint("foo", 55, 0.0)
|
||||
hint2 = DirectTCPV1Hint("bar", 55, 0.0)
|
||||
hint3 = RelayV1Hint([DirectTCPV1Hint("relay", 55, 0.0)])
|
||||
ep1, ep2, ep3 = mock.Mock(), mock.Mock(), mock.Mock()
|
||||
with mock.patch("wormhole._dilation.connector.endpoint_from_hint_obj",
|
||||
side_effect=[ep1, ep2, ep3]):
|
||||
c.got_hints([hint1, hint2, hint3])
|
||||
self.assertEqual(ep1.mock_calls, [])
|
||||
self.assertEqual(ep2.mock_calls, [])
|
||||
self.assertEqual(ep3.mock_calls, [])
|
||||
|
||||
h.clock.advance(c.RELAY_DELAY / 2 + 0.01)
|
||||
self.assertEqual(len(ep1.mock_calls), 2)
|
||||
self.assertEqual(len(ep2.mock_calls), 2)
|
||||
self.assertEqual(ep3.mock_calls, [])
|
||||
|
||||
h.clock.advance(c.RELAY_DELAY)
|
||||
self.assertEqual(len(ep1.mock_calls), 2)
|
||||
self.assertEqual(len(ep2.mock_calls), 2)
|
||||
self.assertEqual(len(ep3.mock_calls), 2)
|
||||
|
||||
def test_initial_relay(self):
|
||||
c, h = make_connector(listen=False, relay="tcp:foo:55", role=roles.LEADER)
|
||||
ep = mock.Mock()
|
||||
with mock.patch("wormhole._dilation.connector.endpoint_from_hint_obj",
|
||||
side_effect=[ep]) as efho:
|
||||
c.start()
|
||||
self.assertEqual(h.manager.mock_calls,
|
||||
[mock.call.send_hints([{"type": "relay-v1",
|
||||
"hints": [
|
||||
{"type": "direct-tcp-v1",
|
||||
"hostname": "foo",
|
||||
"port": 55,
|
||||
"priority": 0.0
|
||||
},
|
||||
],
|
||||
}])])
|
||||
self.assertEqual(len(efho.mock_calls), 1)
|
||||
|
||||
def test_tor_no_manager(self):
|
||||
# tor hints should be ignored if we don't have a Tor manager to use them
|
||||
c, h = make_connector(listen=False, role=roles.LEADER)
|
||||
c.start()
|
||||
hint = TorTCPV1Hint("foo", 55, 0.0)
|
||||
ep = mock.Mock()
|
||||
with mock.patch("wormhole._dilation.connector.endpoint_from_hint_obj",
|
||||
side_effect=[ep]):
|
||||
c.got_hints([hint])
|
||||
self.assertEqual(ep.mock_calls, [])
|
||||
|
||||
h.clock.advance(c.RELAY_DELAY * 2)
|
||||
self.assertEqual(ep.mock_calls, [])
|
||||
|
||||
def test_tor_with_manager(self):
|
||||
# tor hints should be processed if we do have a Tor manager
|
||||
c, h = make_connector(listen=False, tor=True, role=roles.LEADER)
|
||||
c.start()
|
||||
hint = TorTCPV1Hint("foo", 55, 0.0)
|
||||
ep = mock.Mock()
|
||||
with mock.patch("wormhole._dilation.connector.endpoint_from_hint_obj",
|
||||
side_effect=[ep]):
|
||||
c.got_hints([hint])
|
||||
self.assertEqual(ep.mock_calls, [])
|
||||
|
||||
h.clock.advance(c.RELAY_DELAY * 2)
|
||||
self.assertEqual(len(ep.mock_calls), 2)
|
||||
|
||||
|
||||
def test_priorities(self):
|
||||
# given two hints with different priorities, we should somehow prefer
|
||||
# one. This is a placeholder to fill in once we implement priorities.
|
||||
pass
|
Loading…
Reference in New Issue
Block a user