Funktionierender Prototyp des Serious Games zur Vermittlung von Wissen zu Software-Engineering-Arbeitsmodellen.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

component.py 37KB

1 year ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976
  1. ###############################################################################
  2. #
  3. # The MIT License (MIT)
  4. #
  5. # Copyright (c) typedef int GmbH
  6. #
  7. # Permission is hereby granted, free of charge, to any person obtaining a copy
  8. # of this software and associated documentation files (the "Software"), to deal
  9. # in the Software without restriction, including without limitation the rights
  10. # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  11. # copies of the Software, and to permit persons to whom the Software is
  12. # furnished to do so, subject to the following conditions:
  13. #
  14. # The above copyright notice and this permission notice shall be included in
  15. # all copies or substantial portions of the Software.
  16. #
  17. # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  18. # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  19. # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  20. # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  21. # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  22. # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
  23. # THE SOFTWARE.
  24. #
  25. ###############################################################################
  26. import itertools
  27. import random
  28. from functools import partial
  29. import txaio
  30. from autobahn.util import ObservableMixin
  31. from autobahn.websocket.util import parse_url as parse_ws_url
  32. from autobahn.rawsocket.util import parse_url as parse_rs_url
  33. from autobahn.wamp.types import ComponentConfig, SubscribeOptions, RegisterOptions
  34. from autobahn.wamp.exception import SessionNotReady, ApplicationError
  35. from autobahn.wamp.auth import create_authenticator, IAuthenticator
  36. from autobahn.wamp.serializer import SERID_TO_SER
  37. __all__ = (
  38. 'Component'
  39. )
  40. def _validate_endpoint(endpoint, check_native_endpoint=None):
  41. """
  42. Check a WAMP connecting endpoint configuration.
  43. """
  44. if check_native_endpoint:
  45. check_native_endpoint(endpoint)
  46. elif not isinstance(endpoint, dict):
  47. raise ValueError(
  48. "'endpoint' must be a dict"
  49. )
  50. # note, we're falling through here -- check_native_endpoint can
  51. # disallow or allow dict-based config as it likes, but if it
  52. # *does* allow a dict through, we want to check "base options"
  53. # here so that both Twisted and asyncio don't have to check these
  54. # things as well.
  55. if isinstance(endpoint, dict):
  56. # XXX what about filling in anything missing from the URL? Or
  57. # is that only for when *nothing* is provided for endpoint?
  58. if 'type' not in endpoint:
  59. # could maybe just make tcp the default?
  60. raise ValueError("'type' required in endpoint configuration")
  61. if endpoint['type'] not in ['tcp', 'unix']:
  62. raise ValueError('invalid type "{}" in endpoint'.format(endpoint['type']))
  63. for k in endpoint.keys():
  64. if k not in ['type', 'host', 'port', 'path', 'tls', 'timeout', 'version']:
  65. raise ValueError(
  66. "Invalid key '{}' in endpoint configuration".format(k)
  67. )
  68. if endpoint['type'] == 'tcp':
  69. for k in ['host', 'port']:
  70. if k not in endpoint:
  71. raise ValueError(
  72. "'{}' required in 'tcp' endpoint config".format(k)
  73. )
  74. for k in ['path']:
  75. if k in endpoint:
  76. raise ValueError(
  77. "'{}' not valid in 'tcp' endpoint config".format(k)
  78. )
  79. elif endpoint['type'] == 'unix':
  80. for k in ['path']:
  81. if k not in endpoint:
  82. raise ValueError(
  83. "'{}' required for 'unix' endpoint config".format(k)
  84. )
  85. for k in ['host', 'port', 'tls']:
  86. if k in endpoint:
  87. raise ValueError(
  88. "'{}' not valid in 'unix' endpoint config".format(k)
  89. )
  90. else:
  91. assert False, 'should not arrive here'
  92. def _create_transport(index, transport, check_native_endpoint=None):
  93. """
  94. Internal helper to insert defaults and create _Transport instances.
  95. :param transport: a (possibly valid) transport configuration
  96. :type transport: dict
  97. :returns: a _Transport instance
  98. :raises: ValueError on invalid configuration
  99. """
  100. if type(transport) != dict:
  101. raise ValueError('invalid type {} for transport configuration - must be a dict'.format(type(transport)))
  102. valid_transport_keys = [
  103. 'type', 'url', 'endpoint', 'serializer', 'serializers', 'options',
  104. 'max_retries', 'max_retry_delay', 'initial_retry_delay',
  105. 'retry_delay_growth', 'retry_delay_jitter', 'proxy',
  106. ]
  107. for k in transport.keys():
  108. if k not in valid_transport_keys:
  109. raise ValueError(
  110. "'{}' is not a valid configuration item".format(k)
  111. )
  112. kind = 'websocket'
  113. if 'type' in transport:
  114. if transport['type'] not in ['websocket', 'rawsocket']:
  115. raise ValueError('Invalid transport type {}'.format(transport['type']))
  116. kind = transport['type']
  117. else:
  118. transport['type'] = 'websocket'
  119. if 'proxy' in transport and kind != 'websocket':
  120. raise ValueError(
  121. "proxy= only supported for type=websocket transports"
  122. )
  123. proxy = transport.get("proxy", None)
  124. if proxy is not None:
  125. for k in proxy.keys():
  126. if k not in ['host', 'port']:
  127. raise ValueError(
  128. "Unknown key '{}' in proxy config".format(k)
  129. )
  130. for k in ['host', 'port']:
  131. if k not in proxy:
  132. raise ValueError(
  133. "Proxy config requires '{}'".formaT(k)
  134. )
  135. options = dict()
  136. if 'options' in transport:
  137. options = transport['options']
  138. if not isinstance(options, dict):
  139. raise ValueError(
  140. 'options must be a dict, not {}'.format(type(options))
  141. )
  142. if kind == 'websocket':
  143. for key in ['url']:
  144. if key not in transport:
  145. raise ValueError("Transport requires '{}' key".format(key))
  146. # endpoint not required; we will deduce from URL if it's not provided
  147. # XXX not in the branch I rebased; can this go away? (is it redundant??)
  148. if 'endpoint' not in transport:
  149. is_secure, host, port, resource, path, params = parse_ws_url(transport['url'])
  150. endpoint_config = {
  151. 'type': 'tcp',
  152. 'host': host,
  153. 'port': port,
  154. 'tls': is_secure,
  155. }
  156. else:
  157. # note: we're avoiding mutating the incoming "configuration"
  158. # dict, so this should avoid that too...
  159. endpoint_config = transport['endpoint']
  160. _validate_endpoint(endpoint_config, check_native_endpoint)
  161. if 'serializer' in transport:
  162. raise ValueError("'serializer' is only for rawsocket; use 'serializers'")
  163. if 'serializers' in transport:
  164. if not isinstance(transport['serializers'], (list, tuple)):
  165. raise ValueError("'serializers' must be a list of strings")
  166. if not all([
  167. isinstance(s, (str, str))
  168. for s in transport['serializers']]):
  169. raise ValueError("'serializers' must be a list of strings")
  170. valid_serializers = SERID_TO_SER.keys()
  171. for serial in transport['serializers']:
  172. if serial not in valid_serializers:
  173. raise ValueError(
  174. "Invalid serializer '{}' (expected one of: {})".format(
  175. serial,
  176. ', '.join([repr(s) for s in valid_serializers]),
  177. )
  178. )
  179. serializer_config = transport.get('serializers', ['cbor', 'json'])
  180. elif kind == 'rawsocket':
  181. if 'endpoint' not in transport:
  182. if transport['url'].startswith('rs'):
  183. # # try to parse RawSocket URL ..
  184. isSecure, host, port = parse_rs_url(transport['url'])
  185. elif transport['url'].startswith('ws'):
  186. # try to parse WebSocket URL ..
  187. isSecure, host, port, resource, path, params = parse_ws_url(transport['url'])
  188. else:
  189. raise RuntimeError()
  190. if host == 'unix':
  191. # here, "port" is actually holding the path on the host, eg "/tmp/file.sock"
  192. endpoint_config = {
  193. 'type': 'unix',
  194. 'path': port,
  195. }
  196. else:
  197. endpoint_config = {
  198. 'type': 'tcp',
  199. 'host': host,
  200. 'port': port,
  201. }
  202. else:
  203. endpoint_config = transport['endpoint']
  204. if 'serializers' in transport:
  205. raise ValueError("'serializers' is only for websocket; use 'serializer'")
  206. # always a list; len == 1 for rawsocket
  207. if 'serializer' in transport:
  208. if not isinstance(transport['serializer'], (str, str)):
  209. raise ValueError("'serializer' must be a string")
  210. serializer_config = [transport['serializer']]
  211. else:
  212. serializer_config = ['cbor']
  213. else:
  214. assert False, 'should not arrive here'
  215. kw = {}
  216. for key in ['max_retries', 'max_retry_delay', 'initial_retry_delay',
  217. 'retry_delay_growth', 'retry_delay_jitter']:
  218. if key in transport:
  219. kw[key] = transport[key]
  220. return _Transport(
  221. index,
  222. kind=kind,
  223. url=transport.get('url', None),
  224. endpoint=endpoint_config,
  225. serializers=serializer_config,
  226. proxy=proxy,
  227. options=options,
  228. **kw
  229. )
  230. class _Transport(object):
  231. """
  232. Thin-wrapper for WAMP transports used by a Connection.
  233. """
  234. def __init__(self, idx, kind, url, endpoint, serializers,
  235. max_retries=-1,
  236. max_retry_delay=300,
  237. initial_retry_delay=1.5,
  238. retry_delay_growth=1.5,
  239. retry_delay_jitter=0.1,
  240. proxy=None,
  241. options=None):
  242. """
  243. """
  244. if options is None:
  245. options = dict()
  246. self.idx = idx
  247. self.type = kind
  248. self.url = url
  249. self.endpoint = endpoint
  250. self.options = options
  251. self.serializers = serializers
  252. if self.type == 'rawsocket' and len(serializers) != 1:
  253. raise ValueError(
  254. "'rawsocket' transport requires exactly one serializer"
  255. )
  256. self.max_retries = max_retries
  257. self.max_retry_delay = max_retry_delay
  258. self.initial_retry_delay = initial_retry_delay
  259. self.retry_delay_growth = retry_delay_growth
  260. self.retry_delay_jitter = retry_delay_jitter
  261. self.proxy = proxy # this is a dict of proxy config
  262. # used via can_reconnect() and failed() to record this
  263. # transport is never going to work
  264. self._permanent_failure = False
  265. self.reset()
  266. def reset(self):
  267. """
  268. set connection failure rates and retry-delay to initial values
  269. """
  270. self.connect_attempts = 0
  271. self.connect_sucesses = 0
  272. self.connect_failures = 0
  273. self.retry_delay = self.initial_retry_delay
  274. def failed(self):
  275. """
  276. Mark this transport as failed, meaning we won't try to connect to
  277. it any longer (that is: can_reconnect() will always return
  278. False afer calling this).
  279. """
  280. self._permanent_failure = True
  281. def can_reconnect(self):
  282. if self._permanent_failure:
  283. return False
  284. if self.max_retries == -1:
  285. return True
  286. return self.connect_attempts < self.max_retries + 1
  287. def next_delay(self):
  288. if self.connect_attempts == 0:
  289. # if we never tried before, try immediately
  290. return 0
  291. elif self.max_retries != -1 and self.connect_attempts >= self.max_retries + 1:
  292. raise RuntimeError('max reconnects reached')
  293. else:
  294. self.retry_delay = self.retry_delay * self.retry_delay_growth
  295. self.retry_delay = random.normalvariate(self.retry_delay, self.retry_delay * self.retry_delay_jitter)
  296. if self.retry_delay > self.max_retry_delay:
  297. self.retry_delay = self.max_retry_delay
  298. return self.retry_delay
  299. def describe_endpoint(self):
  300. """
  301. returns a human-readable description of the endpoint
  302. """
  303. if isinstance(self.endpoint, dict):
  304. return self.endpoint['type']
  305. return repr(self.endpoint)
  306. # this could probably implement twisted.application.service.IService
  307. # if we wanted; or via an adapter...which just adds a startService()
  308. # and stopService() [latter can be async]
  309. class Component(ObservableMixin):
  310. """
  311. A WAMP application component. A component holds configuration for
  312. (and knows how to create) transports and sessions.
  313. """
  314. session_factory = None
  315. """
  316. The factory of the session we will instantiate.
  317. """
  318. def subscribe(self, topic, options=None, check_types=False):
  319. """
  320. A decorator as a shortcut for subscribing during on-join
  321. For example::
  322. @component.subscribe(
  323. "some.topic",
  324. options=SubscribeOptions(match='prefix'),
  325. )
  326. def topic(*args, **kw):
  327. print("some.topic({}, {}): event received".format(args, kw))
  328. """
  329. assert options is None or isinstance(options, SubscribeOptions)
  330. def decorator(fn):
  331. def do_subscription(session, details):
  332. return session.subscribe(fn, topic=topic, options=options, check_types=check_types)
  333. self.on('join', do_subscription)
  334. return fn
  335. return decorator
  336. def register(self, uri, options=None, check_types=False):
  337. """
  338. A decorator as a shortcut for registering during on-join
  339. For example::
  340. @component.register(
  341. "com.example.add",
  342. options=RegisterOptions(invoke='roundrobin'),
  343. )
  344. def add(*args, **kw):
  345. print("add({}, {}): event received".format(args, kw))
  346. """
  347. assert options is None or isinstance(options, RegisterOptions)
  348. def decorator(fn):
  349. def do_registration(session, details):
  350. return session.register(fn, procedure=uri, options=options, check_types=check_types)
  351. self.on('join', do_registration)
  352. return fn
  353. return decorator
  354. def __init__(self, main=None, transports=None, config=None, realm='realm1', extra=None,
  355. authentication=None, session_factory=None, is_fatal=None):
  356. """
  357. :param main: After a transport has been connected and a session
  358. has been established and joined to a realm, this (async)
  359. procedure will be run until it finishes -- which signals that
  360. the component has run to completion. In this case, it usually
  361. doesn't make sense to use the ``on_*`` kwargs. If you do not
  362. pass a main() procedure, the session will not be closed
  363. (unless you arrange for .leave() to be called).
  364. :type main: callable taking two args ``reactor`` and ``ISession``
  365. :param transports: Transport configurations for creating
  366. transports. Each transport can be a WAMP URL, or a dict
  367. containing the following configuration keys:
  368. - ``type`` (optional): ``websocket`` (default) or ``rawsocket``
  369. - ``url``: the router URL
  370. - ``endpoint`` (optional, derived from URL if not provided):
  371. - ``type``: "tcp" or "unix"
  372. - ``host``, ``port``: only for TCP
  373. - ``path``: only for unix
  374. - ``timeout``: in seconds
  375. - ``tls``: ``True`` or (under Twisted) an
  376. ``twisted.internet.ssl.IOpenSSLClientComponentCreator``
  377. instance (such as returned from
  378. ``twisted.internet.ssl.optionsForClientTLS``) or
  379. ``CertificateOptions`` instance.
  380. - ``max_retries``: Maximum number of reconnection attempts. Unlimited if set to -1.
  381. - ``initial_retry_delay``: Initial delay for reconnection attempt in seconds (Default: 1.0s).
  382. - ``max_retry_delay``: Maximum delay for reconnection attempts in seconds (Default: 60s).
  383. - ``retry_delay_growth``: The growth factor applied to the retry delay between reconnection attempts (Default 1.5).
  384. - ``retry_delay_jitter``: A 0-argument callable that introduces nose into the delay. (Default random.random)
  385. - ``serializer`` (only for raw socket): Specify an accepted serializer (e.g. 'json', 'msgpack', 'cbor', 'ubjson', 'flatbuffers')
  386. - ``serializers``: Specify list of accepted serializers
  387. - ``options``: tbd
  388. - ``proxy``: tbd
  389. :type transports: None or str or list
  390. :param realm: the realm to join
  391. :type realm: str
  392. :param authentication: configuration of authenticators
  393. :type authentication: dict
  394. :param session_factory: if None, ``ApplicationSession`` is
  395. used, otherwise a callable taking a single ``config`` argument
  396. that is used to create a new `ApplicationSession` instance.
  397. :param is_fatal: a callable taking a single argument, an
  398. ``Exception`` instance. The callable should return ``True`` if
  399. this error is "fatal", meaning we should not try connecting to
  400. the current transport again. The default behavior (on None) is
  401. to always return ``False``
  402. """
  403. self.set_valid_events(
  404. [
  405. 'start', # fired by base class
  406. 'connect', # fired by ApplicationSession
  407. 'join', # fired by ApplicationSession
  408. 'ready', # fired by ApplicationSession
  409. 'leave', # fired by ApplicationSession
  410. 'disconnect', # fired by ApplicationSession
  411. 'connectfailure', # fired by base class
  412. ]
  413. )
  414. if is_fatal is not None and not callable(is_fatal):
  415. raise ValueError('"is_fatal" must be a callable or None')
  416. self._is_fatal = is_fatal
  417. if main is not None and not callable(main):
  418. raise ValueError('"main" must be a callable if given')
  419. self._entry = main
  420. # use WAMP-over-WebSocket to localhost when no transport is specified at all
  421. if transports is None:
  422. transports = 'ws://127.0.0.1:8080/ws'
  423. # allows to provide a URL instead of a list of transports
  424. if isinstance(transports, (str, str)):
  425. url = transports
  426. # 'endpoint' will get filled in by parsing the 'url'
  427. transport = {
  428. 'type': 'websocket',
  429. 'url': url,
  430. }
  431. transports = [transport]
  432. # allows single transport instead of a list (convenience)
  433. elif isinstance(transports, dict):
  434. transports = [transports]
  435. # XXX do we want to be able to provide an infinite iterable of
  436. # transports here? e.g. a generator that makes new transport
  437. # to try?
  438. # now check and save list of transports
  439. self._transports = []
  440. for idx, transport in enumerate(transports):
  441. # allows to provide a URL instead of transport dict
  442. if type(transport) == str:
  443. _transport = {
  444. 'type': 'websocket',
  445. 'url': transport,
  446. }
  447. else:
  448. _transport = transport
  449. self._transports.append(
  450. _create_transport(idx, _transport, self._check_native_endpoint)
  451. )
  452. # XXX should have some checkconfig support
  453. self._authentication = authentication or {}
  454. if session_factory:
  455. self.session_factory = session_factory
  456. self._realm = realm
  457. self._extra = extra
  458. self._delay_f = None
  459. self._done_f = None
  460. self._session = None
  461. self._stopping = False
  462. def _can_reconnect(self):
  463. # check if any of our transport has any reconnect attempt left
  464. for transport in self._transports:
  465. if transport.can_reconnect():
  466. return True
  467. return False
  468. def _start(self, loop=None):
  469. """
  470. This starts the Component, which means it will start connecting
  471. (and re-connecting) to its configured transports. A Component
  472. runs until it is "done", which means one of:
  473. - There was a "main" function defined, and it completed successfully;
  474. - Something called ``.leave()`` on our session, and we left successfully;
  475. - ``.stop()`` was called, and completed successfully;
  476. - none of our transports were able to connect successfully (failure);
  477. :returns: a Future/Deferred which will resolve (to ``None``) when we are
  478. "done" or with an error if something went wrong.
  479. """
  480. # we can only be "start()ed" once before we stop .. but that
  481. # doesn't have to be an error we can give back another future
  482. # that fires when our "real" _done_f is completed.
  483. if self._done_f is not None:
  484. d = txaio.create_future()
  485. def _cb(arg):
  486. txaio.resolve(d, arg)
  487. txaio.add_callbacks(self._done_f, _cb, _cb)
  488. return d
  489. # this future will be returned, and thus has the semantics
  490. # specified in the docstring.
  491. self._done_f = txaio.create_future()
  492. def _reset(arg):
  493. """
  494. if the _done_f future is resolved (good or bad), we want to set it
  495. to None in our class
  496. """
  497. self._done_f = None
  498. return arg
  499. txaio.add_callbacks(self._done_f, _reset, _reset)
  500. # Create a generator of transports that .can_reconnect()
  501. transport_gen = itertools.cycle(self._transports)
  502. # this is a 1-element list so we can set it from closures in
  503. # this function
  504. transport_candidate = [0]
  505. def error(fail):
  506. self._delay_f = None
  507. if self._stopping:
  508. # might be better to add framework-specific checks in
  509. # subclasses to see if this is CancelledError (for
  510. # Twisted) and whatever asyncio does .. but tracking
  511. # if we're in the shutdown path is fine too
  512. txaio.resolve(self._done_f, None)
  513. else:
  514. self.log.info("Internal error {msg}", msg=txaio.failure_message(fail))
  515. self.log.debug("{tb}", tb=txaio.failure_format_traceback(fail))
  516. txaio.reject(self._done_f, fail)
  517. def attempt_connect(_):
  518. self._delay_f = None
  519. def handle_connect_error(fail):
  520. # FIXME - make txaio friendly
  521. # Can connect_f ever be in a cancelled state?
  522. # if txaio.using_asyncio and isinstance(fail.value, asyncio.CancelledError):
  523. # unrecoverable_error = True
  524. self.log.debug('component failed: {error}', error=txaio.failure_message(fail))
  525. self.log.debug('{tb}', tb=txaio.failure_format_traceback(fail))
  526. # If this is a "fatal error" that will never work,
  527. # we bail out now
  528. if isinstance(fail.value, ApplicationError):
  529. self.log.error("{msg}", msg=fail.value.error_message())
  530. elif isinstance(fail.value, OSError):
  531. # failed to connect entirely, like nobody
  532. # listening etc.
  533. self.log.info("Connection failed with OS error: {msg}", msg=txaio.failure_message(fail))
  534. elif self._is_ssl_error(fail.value):
  535. # Quoting pyOpenSSL docs: "Whenever
  536. # [SSL.Error] is raised directly, it has a
  537. # list of error messages from the OpenSSL
  538. # error queue, where each item is a tuple
  539. # (lib, function, reason). Here lib, function
  540. # and reason are all strings, describing where
  541. # and what the problem is. See err(3) for more
  542. # information."
  543. # (and 'args' is a 1-tuple containing the above
  544. # 3-tuple...)
  545. ssl_lib, ssl_func, ssl_reason = fail.value.args[0][0]
  546. self.log.error("TLS failure: {reason}", reason=ssl_reason)
  547. else:
  548. self.log.error(
  549. 'Connection failed: {error}',
  550. error=txaio.failure_message(fail),
  551. )
  552. if self._is_fatal is None:
  553. is_fatal = False
  554. else:
  555. is_fatal = self._is_fatal(fail.value)
  556. if is_fatal:
  557. self.log.info("Error was fatal; failing transport")
  558. transport_candidate[0].failed()
  559. txaio.call_later(0, transport_check, None)
  560. return
  561. def notify_connect_error(fail):
  562. chain_f = txaio.create_future()
  563. # hmm, if connectfailure took a _Transport instead of
  564. # (or in addition to?) self it could .failed() the
  565. # transport and we could do away with the is_fatal
  566. # listener?
  567. handler_f = self.fire('connectfailure', self, fail.value)
  568. txaio.add_callbacks(
  569. handler_f,
  570. lambda _: txaio.reject(chain_f, fail),
  571. lambda _: txaio.reject(chain_f, fail)
  572. )
  573. return chain_f
  574. def connect_error(fail):
  575. notify_f = notify_connect_error(fail)
  576. txaio.add_callbacks(notify_f, None, handle_connect_error)
  577. def session_done(x):
  578. txaio.resolve(self._done_f, None)
  579. connect_f = txaio.as_future(
  580. self._connect_once,
  581. loop, transport_candidate[0],
  582. )
  583. txaio.add_callbacks(connect_f, session_done, connect_error)
  584. def transport_check(_):
  585. self.log.debug('Entering re-connect loop')
  586. if not self._can_reconnect():
  587. err_msg = "Component failed: Exhausted all transport connect attempts"
  588. self.log.info(err_msg)
  589. try:
  590. raise RuntimeError(err_msg)
  591. except RuntimeError as e:
  592. txaio.reject(self._done_f, e)
  593. return
  594. while True:
  595. transport = next(transport_gen)
  596. if transport.can_reconnect():
  597. transport_candidate[0] = transport
  598. break
  599. delay = transport.next_delay()
  600. self.log.warn(
  601. 'trying transport {transport_idx} ("{transport_url}") using connect delay {transport_delay}',
  602. transport_idx=transport.idx,
  603. transport_url=transport.url,
  604. transport_delay=delay,
  605. )
  606. self._delay_f = txaio.sleep(delay)
  607. txaio.add_callbacks(self._delay_f, attempt_connect, error)
  608. # issue our first event, then start reconnect loop
  609. start_f = self.fire('start', loop, self)
  610. txaio.add_callbacks(start_f, transport_check, error)
  611. return self._done_f
  612. def stop(self):
  613. self._stopping = True
  614. if self._session and self._session.is_attached():
  615. return self._session.leave()
  616. elif self._delay_f:
  617. # This cancel request will actually call the "error" callback of
  618. # the _delay_f future. Nothing to worry about.
  619. return txaio.as_future(txaio.cancel, self._delay_f)
  620. # if (for some reason -- should we log warning here to figure
  621. # out if this can evern happen?) we've not fired _done_f, we
  622. # do that now (causing our "main" to exit, and thus react() to
  623. # quit)
  624. if not txaio.is_called(self._done_f):
  625. txaio.resolve(self._done_f, None)
  626. return txaio.create_future_success(None)
  627. def _connect_once(self, reactor, transport):
  628. self.log.info(
  629. 'connecting once using transport type "{transport_type}" '
  630. 'over endpoint "{endpoint_desc}"',
  631. transport_type=transport.type,
  632. endpoint_desc=transport.describe_endpoint(),
  633. )
  634. done = txaio.create_future()
  635. # factory for ISession objects
  636. def create_session():
  637. cfg = ComponentConfig(self._realm, self._extra)
  638. try:
  639. self._session = session = self.session_factory(cfg)
  640. for auth_name, auth_config in self._authentication.items():
  641. if isinstance(auth_config, IAuthenticator):
  642. session.add_authenticator(auth_config)
  643. else:
  644. authenticator = create_authenticator(auth_name, **auth_config)
  645. session.add_authenticator(authenticator)
  646. except Exception as e:
  647. # couldn't instantiate session calls, which is fatal.
  648. # let the reconnection logic deal with that
  649. f = txaio.create_failure(e)
  650. txaio.reject(done, f)
  651. raise
  652. else:
  653. # hook up the listener to the parent so we can bubble
  654. # up events happning on the session onto the
  655. # connection. This lets you do component.on('join',
  656. # cb) which will work just as if you called
  657. # session.on('join', cb) for every session created.
  658. session._parent = self
  659. # listen on leave events; if we get errors
  660. # (e.g. no_such_realm), an on_leave can happen without
  661. # an on_join before
  662. def on_leave(session, details):
  663. self.log.info(
  664. "session leaving '{details.reason}'",
  665. details=details,
  666. )
  667. if not txaio.is_called(done):
  668. if details.reason in ["wamp.close.normal", "wamp.close.goodbye_and_out"]:
  669. txaio.resolve(done, None)
  670. else:
  671. f = txaio.create_failure(
  672. ApplicationError(details.reason, details.message)
  673. )
  674. txaio.reject(done, f)
  675. session.on('leave', on_leave)
  676. # if we were given a "main" procedure, we run through
  677. # it completely (i.e. until its Deferred fires) and
  678. # then disconnect this session
  679. def on_join(session, details):
  680. transport.reset()
  681. transport.connect_sucesses += 1
  682. self.log.debug("session on_join: {details}", details=details)
  683. d = txaio.as_future(self._entry, reactor, session)
  684. def main_success(_):
  685. self.log.debug("main_success")
  686. def leave():
  687. try:
  688. session.leave()
  689. except SessionNotReady:
  690. # someone may have already called
  691. # leave()
  692. pass
  693. txaio.call_later(0, leave)
  694. def main_error(err):
  695. self.log.debug("main_error: {err}", err=err)
  696. txaio.reject(done, err)
  697. session.disconnect()
  698. txaio.add_callbacks(d, main_success, main_error)
  699. if self._entry is not None:
  700. session.on('join', on_join)
  701. # listen on disconnect events. Note that in case we
  702. # had a "main" procedure, we could have already
  703. # resolve()'d our "done" future
  704. def on_disconnect(session, was_clean):
  705. self.log.debug(
  706. "session on_disconnect: was_clean={was_clean}",
  707. was_clean=was_clean,
  708. )
  709. if not txaio.is_called(done):
  710. if not was_clean:
  711. self.log.warn(
  712. "Session disconnected uncleanly"
  713. )
  714. else:
  715. # eg the session has left the realm, and the transport was properly
  716. # shut down. successfully finish the connection
  717. txaio.resolve(done, None)
  718. session.on('disconnect', on_disconnect)
  719. # return the fresh session object
  720. return session
  721. transport.connect_attempts += 1
  722. d = txaio.as_future(
  723. self._connect_transport,
  724. reactor, transport, create_session, done,
  725. )
  726. def on_error(err):
  727. """
  728. this may seem redundant after looking at _connect_transport, but
  729. it will handle a case where something goes wrong in
  730. _connect_transport itself -- as the only connect our
  731. caller has is the 'done' future
  732. """
  733. transport.connect_failures += 1
  734. # something bad has happened, and maybe didn't get caught
  735. # upstream yet
  736. if not txaio.is_called(done):
  737. txaio.reject(done, err)
  738. txaio.add_callbacks(d, None, on_error)
  739. return done
  740. def on_join(self, fn):
  741. """
  742. A decorator as a shortcut for listening for 'join' events.
  743. For example::
  744. @component.on_join
  745. def joined(session, details):
  746. print("Session {} joined: {}".format(session, details))
  747. """
  748. self.on('join', fn)
  749. def on_leave(self, fn):
  750. """
  751. A decorator as a shortcut for listening for 'leave' events.
  752. """
  753. self.on('leave', fn)
  754. def on_connect(self, fn):
  755. """
  756. A decorator as a shortcut for listening for 'connect' events.
  757. """
  758. self.on('connect', fn)
  759. def on_disconnect(self, fn):
  760. """
  761. A decorator as a shortcut for listening for 'disconnect' events.
  762. """
  763. self.on('disconnect', fn)
  764. def on_ready(self, fn):
  765. """
  766. A decorator as a shortcut for listening for 'ready' events.
  767. """
  768. self.on('ready', fn)
  769. def on_connectfailure(self, fn):
  770. """
  771. A decorator as a shortcut for listening for 'connectfailure' events.
  772. """
  773. self.on('connectfailure', fn)
  774. def _run(reactor, components, done_callback=None):
  775. """
  776. Internal helper. Use "run" method from autobahn.twisted.wamp or
  777. autobahn.asyncio.wamp
  778. This is the generic parts of the run() method so that there's very
  779. little code in the twisted/asyncio specific run() methods.
  780. This is called by react() (or run_until_complete() so any errors
  781. coming out of this should be handled properly. Logging will
  782. already be started.
  783. """
  784. # let user pass a single component to run, too
  785. # XXX probably want IComponent? only demand it, here and below?
  786. if isinstance(components, Component):
  787. components = [components]
  788. if type(components) != list:
  789. raise ValueError(
  790. '"components" must be a list of Component objects - encountered'
  791. ' {0}'.format(type(components))
  792. )
  793. for c in components:
  794. if not isinstance(c, Component):
  795. raise ValueError(
  796. '"components" must be a list of Component objects - encountered'
  797. 'item of type {0}'.format(type(c))
  798. )
  799. # validation complete; proceed with startup
  800. log = txaio.make_logger()
  801. def component_success(comp, arg):
  802. log.debug("Component '{c}' successfully completed: {arg}", c=comp, arg=arg)
  803. return arg
  804. def component_failure(comp, f):
  805. log.error("Component '{c}' error: {msg}", c=comp, msg=txaio.failure_message(f))
  806. log.debug("Component error: {tb}", tb=txaio.failure_format_traceback(f))
  807. # double-check: is a component-failure still fatal to the
  808. # startup process (because we passed consume_exception=False
  809. # to gather() below?)
  810. return None
  811. def component_start(comp):
  812. # the future from start() errbacks if we fail, or callbacks
  813. # when the component is considered "done" (so maybe never)
  814. d = txaio.as_future(comp.start, reactor)
  815. txaio.add_callbacks(
  816. d,
  817. partial(component_success, comp),
  818. partial(component_failure, comp),
  819. )
  820. return d
  821. # note that these are started in parallel -- maybe we want to add
  822. # a "connected" signal to components so we could start them in the
  823. # order they're given to run() as "a" solution to dependencies.
  824. dl = []
  825. for comp in components:
  826. d = component_start(comp)
  827. dl.append(d)
  828. done_d = txaio.gather(dl, consume_exceptions=False)
  829. if done_callback:
  830. def all_done(arg):
  831. log.debug("All components ended; stopping reactor")
  832. done_callback(reactor, arg)
  833. txaio.add_callbacks(done_d, all_done, all_done)
  834. return done_d