Skip to content

Commit 76e41d5

Browse files
authored
Fix a race condition that leads to path duplication (#114)
This adds a *pending* set of clients to the `Path` instance which prevents it from being removed while another client is currently in the handshake process.
1 parent 082a7d2 commit 76e41d5

File tree

4 files changed

+290
-17
lines changed

4 files changed

+290
-17
lines changed

saltyrtc/server/protocol.py

+106-10
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import struct
44
# noinspection PyUnresolvedReferences
55
from typing import Dict # noqa
6+
from typing import Set # noqa
67
from typing import (
78
Any,
89
Iterable,
@@ -79,6 +80,7 @@
7980

8081
class Path:
8182
__slots__ = (
83+
'_pending',
8284
'_initiator',
8385
'_responders',
8486
'log',
@@ -93,6 +95,7 @@ def __init__(
9395
number: int,
9496
attached: bool = True,
9597
) -> None:
98+
self._pending = set() # type: Set[PathClient]
9699
self._initiator = None # type: Optional[PathClient]
97100
self._responders = {} # type: Dict[ResponderAddress, PathClient]
98101
self.log = util.get_logger('path.{}'.format(number))
@@ -105,7 +108,24 @@ def empty(self) -> bool:
105108
"""
106109
Return whether the path is empty.
107110
"""
108-
return self._initiator is None and len(self._responders) == 0
111+
return (len(self._pending) == 0 and
112+
self._initiator is None and
113+
len(self._responders) == 0)
114+
115+
def add_pending(self, client: 'PathClient') -> None:
116+
"""
117+
Add a :class:`PathClient` as pending to the set of pending
118+
clients.
119+
120+
Arguments:
121+
- `client`: A :class:`PathClient` instance.
122+
123+
Raises :exc:`ValueError` in case of a state violation on the
124+
:class:`PathClient`.
125+
"""
126+
if not self.attached:
127+
raise ValueError('Patch has been detached!')
128+
self._pending.add(client)
109129

110130
def has_client(self, client: 'PathClient') -> bool:
111131
"""
@@ -114,7 +134,13 @@ def has_client(self, client: 'PathClient') -> bool:
114134
115135
Arguments:
116136
- `client`: The :class:`PathClient` instance to look for.
137+
138+
Raises :exc:`ValueError` in case of a state violation on the
139+
:class:`PathClient`.
117140
"""
141+
if not self.attached:
142+
raise ValueError('Patch has been detached!')
143+
118144
# Note: No need to check for an unassigned ID since the server's ID will never
119145
# be available in the slots.
120146
id_ = client.id
@@ -137,8 +163,13 @@ def get_initiator(self) -> 'PathClient':
137163
"""
138164
Return the initiator's :class:`PathClient` instance.
139165
140-
Raises :exc:`KeyError` if there is no initiator.
166+
Raises:
167+
- :exc:`KeyError` if there is no initiator.
168+
- :exc:`ValueError` in case of a state violation on the
169+
:class:`PathClient`.
141170
"""
171+
if not self.attached:
172+
raise ValueError('Patch has been detached!')
142173
if self._initiator is None:
143174
raise KeyError('No initiator present')
144175
return self._initiator
@@ -150,11 +181,21 @@ def set_initiator(self, initiator: 'PathClient') -> Optional['PathClient']:
150181
Arguments:
151182
- `initiator`: A :class:`PathClient` instance.
152183
153-
Raises :exc:`ValueError` in case of a state violation on the
154-
:class:`PathClient`.
184+
Raises:
185+
- :exc:`KeyError` in case the initiator was not in the set
186+
of pending clients.
187+
- :exc:`ValueError` in case of a state violation on the
188+
:class:`PathClient`.
155189
156190
Return the previously set initiator or `None`.
157191
"""
192+
if not self.attached:
193+
raise ValueError('Patch has been detached!')
194+
195+
# Remove initiator from 'pending' set
196+
self._pending.remove(initiator)
197+
198+
# Update initiator
158199
previous_initiator = self._initiator
159200
self._initiator = initiator
160201
self.log.debug('Set initiator {}', initiator)
@@ -173,15 +214,25 @@ def get_responder(self, id_: ResponderAddress) -> 'PathClient':
173214
Arguments:
174215
- `id_`: The identifier of the responder.
175216
176-
Raises :exc:`KeyError`: If `id_` cannot be associated to a
177-
:class:`PathClient` instance.
217+
Raises:
218+
- :exc:`KeyError`: If `id_` cannot be associated to a
219+
:class:`PathClient` instance.
220+
- :exc:`ValueError` in case of a state violation on the
221+
:class:`PathClient`.
178222
"""
223+
if not self.attached:
224+
raise ValueError('Patch has been detached!')
179225
return self._responders[id_]
180226

181227
def get_responder_ids(self) -> Iterable[ResponderAddress]:
182228
"""
183229
Return an iterable of responder identifiers (slots).
230+
231+
Raises :exc:`ValueError` in case of a state violation on the
232+
:class:`PathClient`.
184233
"""
234+
if not self.attached:
235+
raise ValueError('Patch has been detached!')
185236
return self._responders.keys()
186237

187238
def add_responder(self, responder: 'PathClient') -> ResponderAddress:
@@ -193,18 +244,27 @@ def add_responder(self, responder: 'PathClient') -> ResponderAddress:
193244
194245
Raises:
195246
- :exc:`SlotsFullError` if no free slot exists on the path.
247+
- :exc:`KeyError` in case the responder was not in the set
248+
of pending clients.
196249
- :exc:`ValueError` in case of a state violation on the
197-
:class:`PathClient`.
250+
:class:`PathClient` or in case the responder was not in
251+
the list of pending clients.
198252
199253
Return the assigned slot identifier.
200254
"""
255+
if not self.attached:
256+
raise ValueError('Patch has been detached!')
257+
201258
# Calculate slot id
202259
id_ = len(self._responders) + 0x02
203260
try:
204261
id_ = ResponderAddress(id_)
205262
except ValueError as exc:
206263
raise SlotsFullError('No free slot on path') from exc
207264

265+
# Remove responder from 'pending' set
266+
self._pending.remove(responder)
267+
208268
# Set responder
209269
self._responders[id_] = responder
210270
self.log.debug('Added responder {}', responder)
@@ -226,13 +286,31 @@ def remove_client(self, client: 'PathClient') -> None:
226286
Arguments:
227287
- `client`: The :class:`PathClient` instance.
228288
229-
Raises :exc:`KeyError` in case the client provided an
230-
invalid slot identifier.
289+
Raises:
290+
- :exc:`KeyError` in case the client provided an invalid
291+
slot identifier, or the client should have been but was
292+
not in the set of pending clients.
293+
- :exc:`ValueError` in case of a state violation on the
294+
:class:`PathClient`.
231295
"""
296+
if not self.attached:
297+
raise ValueError('Patch has been detached!')
298+
232299
if client.state == ClientState.restricted:
233-
# Client has never been authenticated. Nothing to do.
300+
# Client has never been authenticated, so it should be in
301+
# the 'pending' set
302+
self._pending.remove(client)
234303
return
235304

305+
# Client should not be in the 'pending' set!
306+
try:
307+
self._pending.remove(client)
308+
except KeyError:
309+
pass
310+
else:
311+
self.log.error(
312+
'Client {} in state {} was in the pending set!', client, client.state)
313+
236314
# Remove initiator or responder
237315
id_ = client.id
238316
is_initiator = id_ == INITIATOR_ADDRESS
@@ -250,6 +328,24 @@ def remove_client(self, client: 'PathClient') -> None:
250328
del self._responders[id_]
251329
self.log.debug('Removed {}', 'initiator' if is_initiator else 'responder')
252330

331+
def clear(self) -> None:
332+
"""
333+
Clear an empty path.
334+
"""
335+
if len(self._pending) != 0:
336+
self.log.error(
337+
'Clearing path that has pending clients: {}',
338+
self._pending)
339+
self._pending.clear()
340+
if self._initiator is not None:
341+
self.log.error(
342+
'Clearing path that has an attached initiator: {}', self._initiator)
343+
self._initiator = None
344+
if len(self._responders) != 0:
345+
self.log.error(
346+
'Clearing path that has attached responders: {}', self._responders)
347+
self._responders.clear()
348+
253349

254350
class PathClient:
255351
__slots__ = (

saltyrtc/server/server.py

+27-7
Original file line numberDiff line numberDiff line change
@@ -345,8 +345,16 @@ async def handler(self) -> None:
345345
else:
346346
client.log.debug('Job queue completed')
347347

348-
# Send disconnected message if client was authenticated
349-
if client.state == ClientState.authenticated:
348+
# Send disconnected message if the path is not empty and client was authenticated
349+
if path.empty:
350+
description = 'Skipping potential disconnected message as the path has ' \
351+
'already been detached'
352+
client.log.debug(description)
353+
elif client.state != ClientState.authenticated:
354+
client.log.debug(
355+
'Skipping potential disconnected message due to {} state',
356+
client.state.name)
357+
else:
350358
# Initiator: Send to all responders
351359
if client.type == AddressType.initiator:
352360
responder_ids = path.get_responder_ids()
@@ -385,10 +393,6 @@ async def handler(self) -> None:
385393
client.log.exception(description, exc)
386394
else:
387395
client.log.error('Invalid address type: {}', client.type)
388-
else:
389-
client.log.debug(
390-
'Skipping potential disconnected message due to {} state',
391-
client.state.name)
392396

393397
# Wait for the connection to be closed
394398
await close_awaitable
@@ -417,6 +421,10 @@ def close(self, code: CloseCode) -> None:
417421
# We can safely ignore this since clients will be removed immediately
418422
# from the path in case they are being dropped by another client.
419423
pass
424+
except ValueError:
425+
# We can also safely ignore this since a client may have already removed
426+
# itself from the path.
427+
pass
420428

421429
def get_path_client(
422430
self,
@@ -441,6 +449,9 @@ def get_path_client(
441449
# Create client instance
442450
client = PathClient(connection, path.number, initiator_key, loop=self._loop)
443451

452+
# Attach client to path as 'pending'
453+
path.add_pending(client)
454+
444455
# Return path and client
445456
return path, client
446457

@@ -888,6 +899,11 @@ def _drop_client(self, client: PathClient, code: CloseCode) -> None:
888899
Arguments:
889900
- `client`: The client to be dropped.
890901
- `close`: The close code.
902+
903+
Raises:
904+
- :exc:`KeyError` in case the client is not attached to the
905+
path.
906+
- :exc:`ValueError` in case the path has been detached.
891907
"""
892908
# Drop the client
893909
client.drop(code)
@@ -914,14 +930,18 @@ def get(self, initiator_key: InitiatorPublicPermanentKey) -> Path:
914930
return self.paths[initiator_key]
915931

916932
def clean(self, path: Path) -> None:
917-
if path.attached and path.empty:
933+
if not path.attached:
934+
self._log.error('Path {} has already been detached', path.number)
935+
return
936+
if path.empty:
918937
path.attached = False
919938
try:
920939
del self.paths[path.initiator_key]
921940
except KeyError:
922941
self._log.error('Path {} has already been removed', path.number)
923942
else:
924943
self._log.debug('Removed empty path: {}', path.number)
944+
path.clear()
925945

926946

927947
class Server:

tests/test_protocol.py

+2
Original file line numberDiff line numberDiff line change
@@ -1746,6 +1746,8 @@ async def test_path_full_lite(self, initiator_key, server, client_factory):
17461746

17471747
# Add fake clients to path
17481748
clients = [_FakePathClient() for _ in range(0x02, 0x100)]
1749+
for client in clients:
1750+
path.add_pending(client)
17491751
for client in clients:
17501752
path.add_responder(client)
17511753

0 commit comments

Comments
 (0)