From 4f5a92a28a2ed3ce464acbd072efa7a3c1005559 Mon Sep 17 00:00:00 2001 From: Brad Cowie Date: Fri, 7 Mar 2025 14:01:06 +1300 Subject: [PATCH] Import os-ken 3.0.1 source --- os_ken/cmd/osken_base.py | 4 +- os_ken/lib/hub.py | 379 ++++++++++++------ os_ken/tests/unit/lib/test_hub.py | 351 ++++++++++++++++ ...d-support-for-native-92012287cc366890.yaml | 6 + 4 files changed, 622 insertions(+), 118 deletions(-) create mode 100644 os_ken/tests/unit/lib/test_hub.py create mode 100644 releasenotes/notes/add-support-for-native-92012287cc366890.yaml diff --git a/os_ken/cmd/osken_base.py b/os_ken/cmd/osken_base.py index 774d68c..fefdc2d 100644 --- a/os_ken/cmd/osken_base.py +++ b/os_ken/cmd/osken_base.py @@ -20,7 +20,7 @@ from os_ken import cfg from os_ken import utils -from os_ken import version +from os_ken import __version__ subcommands = { @@ -59,7 +59,7 @@ def run(self, args): def main(): try: - base_conf(project='os_ken', version='os_ken %s' % version) + base_conf(project='os_ken', version='os_ken %s' % __version__) except cfg.RequiredOptError as e: base_conf.print_help() raise SystemExit(1) diff --git a/os_ken/lib/hub.py b/os_ken/lib/hub.py index dd49df0..69d9e60 100644 --- a/os_ken/lib/hub.py +++ b/os_ken/lib/hub.py @@ -16,6 +16,11 @@ import logging import os +import ssl +import socket +import sys +import traceback + from os_ken.lib import ip @@ -26,6 +31,131 @@ LOG = logging.getLogger('os_ken.lib.hub') + +class StreamServer(object): + def __init__(self, listen_info, handle=None, backlog=None, + spawn='default', **ssl_args): + assert backlog is None + assert spawn == 'default' + + if ip.valid_ipv6(listen_info[0]): + self.server = listen(listen_info, family=socket.AF_INET6) + elif os.path.isdir(os.path.dirname(listen_info[0])): + # Case for Unix domain socket + self.server = listen(listen_info[0], family=socket.AF_UNIX) + else: + self.server = listen(listen_info) + + if ssl_args: + ssl_args.setdefault('server_side', True) + if 'ssl_ctx' not in ssl_args: + raise RuntimeError("no SSLContext ssl_ctx in ssl_args") + ctx = ssl_args.pop('ssl_ctx') + ctx.load_cert_chain(ssl_args.pop('certfile'), + ssl_args.pop('keyfile')) + if 'cert_reqs' in ssl_args: + ctx.verify_mode = ssl_args.pop('cert_reqs') + if 'ca_certs' in ssl_args: + ctx.load_verify_locations(ssl_args.pop('ca_certs')) + + def wrap_and_handle_ctx(sock, addr): + handle(ctx.wrap_socket(sock, **ssl_args), addr) + + self.handle = wrap_and_handle_ctx + else: + self.handle = handle + + def serve_forever(self): + while True: + sock, addr = self.server.accept() + spawn(self.handle, sock, addr) + + +class StreamClient(object): + def __init__(self, addr, timeout=None, **ssl_args): + assert ip.valid_ipv4(addr[0]) or ip.valid_ipv6(addr[0]) + self.addr = addr + self.timeout = timeout + self.ssl_args = ssl_args + self._is_active = True + + def connect(self): + try: + if self.timeout is not None: + client = socket.create_connection(self.addr, + timeout=self.timeout) + else: + client = socket.create_connection(self.addr) + except socket.error: + return None + + if self.ssl_args: + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ctx.load_cert_chain(self.ssl_args.pop('certfile'), + self.ssl_args.pop('keyfile')) + if 'cert_reqs' in self.ssl_args: + ctx.verify_mode = self.ssl_args.pop('cert_reqs') + if 'ca_certs' in self.ssl_args: + ctx.load_verify_location(self.ssl_args.pop('ca_certs')) + client = ctx.wrap_socket(client, **self.ssl_args) + + return client + + def connect_loop(self, handle, interval): + while self._is_active: + sock = self.connect() + if sock: + handle(sock, self.addr) + sleep(interval) + + def stop(self): + self._is_active = False + + +class LoggingWrapper(object): + def write(self, message): + LOG.info(message.rstrip('\n')) + + +class Event(object): + def __init__(self): + self._ev = HubEvent() + self._cond = False + + def _wait(self, timeout=None): + while not self._cond: + self._ev.wait() + + def _broadcast(self): + self._ev.send() + # Since eventlet Event doesn't allow multiple send() operations + # on an event, re-create the underlying event. + # Note: _ev.reset() is obsolete. + self._ev = HubEvent() + + def is_set(self): + return self._cond + + def set(self): + self._cond = True + self._broadcast() + + def clear(self): + self._cond = False + + def wait(self, timeout=None): + if timeout is None: + self._wait() + else: + try: + with Timeout(timeout): + self._wait() + except Timeout: + pass + + return self._cond + + if HUB_TYPE == 'eventlet': import eventlet # HACK: @@ -39,10 +169,6 @@ import eventlet.wsgi from eventlet import websocket import greenlet - import ssl - import socket - import traceback - import sys getcurrent = eventlet.getcurrent sleep = eventlet.sleep @@ -112,90 +238,6 @@ def joinall(threads): BoundedSemaphore = eventlet.semaphore.BoundedSemaphore TaskExit = greenlet.GreenletExit - class StreamServer(object): - def __init__(self, listen_info, handle=None, backlog=None, - spawn='default', **ssl_args): - assert backlog is None - assert spawn == 'default' - - if ip.valid_ipv6(listen_info[0]): - self.server = eventlet.listen(listen_info, - family=socket.AF_INET6) - elif os.path.isdir(os.path.dirname(listen_info[0])): - # Case for Unix domain socket - self.server = eventlet.listen(listen_info[0], - family=socket.AF_UNIX) - else: - self.server = eventlet.listen(listen_info) - - if ssl_args: - ssl_args.setdefault('server_side', True) - if 'ssl_ctx' not in ssl_args: - raise RuntimeError("no SSLContext ssl_ctx in ssl_args") - ctx = ssl_args.pop('ssl_ctx') - ctx.load_cert_chain(ssl_args.pop('certfile'), - ssl_args.pop('keyfile')) - if 'cert_reqs' in ssl_args: - ctx.verify_mode = ssl_args.pop('cert_reqs') - if 'ca_certs' in ssl_args: - ctx.load_verify_locations(ssl_args.pop('ca_certs')) - - def wrap_and_handle_ctx(sock, addr): - handle(ctx.wrap_socket(sock, **ssl_args), addr) - - self.handle = wrap_and_handle_ctx - else: - self.handle = handle - - def serve_forever(self): - while True: - sock, addr = self.server.accept() - spawn(self.handle, sock, addr) - - class StreamClient(object): - def __init__(self, addr, timeout=None, **ssl_args): - assert ip.valid_ipv4(addr[0]) or ip.valid_ipv6(addr[0]) - self.addr = addr - self.timeout = timeout - self.ssl_args = ssl_args - self._is_active = True - - def connect(self): - try: - if self.timeout is not None: - client = socket.create_connection(self.addr, - timeout=self.timeout) - else: - client = socket.create_connection(self.addr) - except socket.error: - return None - - if self.ssl_args: - ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) - ctx.load_cert_chain(self.ssl_args.pop('certfile'), - self.ssl_args.pop('keyfile')) - if 'cert_reqs' in self.ssl_args: - ctx.verify_mode = self.ssl_args.pop('cert_reqs') - if 'ca_certs' in self.ssl_args: - ctx.load_verify_location(self.ssl_args.pop('ca_certs')) - client = ctx.wrap_socket(client, **self.ssl_args) - - return client - - def connect_loop(self, handle, interval): - while self._is_active: - sock = self.connect() - if sock: - handle(sock, self.addr) - sleep(interval) - - def stop(self): - self._is_active = False - - class LoggingWrapper(object): - def write(self, message): - LOG.info(message.rstrip('\n')) - class WSGIServer(StreamServer): def serve_forever(self): self.logger = LoggingWrapper() @@ -204,41 +246,146 @@ def serve_forever(self): WebSocketWSGI = websocket.WebSocketWSGI Timeout = eventlet.timeout.Timeout + HubEvent = eventlet.event.Event - class Event(object): - def __init__(self): - self._ev = eventlet.event.Event() - self._cond = False +elif HUB_TYPE == 'native': + LOG.warning("The native implementation is incomplete " + "and should not be used in production environments.") + import threading + import queue + import time + + class HubThread(threading.Thread): + def wait(self, timeout=None): + self.join(timeout) - def _wait(self, timeout=None): - while not self._cond: - self._ev.wait() + def __init__(self, target, args=(), kwargs=None): + self.raise_error = kwargs.pop('raise_error', False) + super().__init__(target=target, args=args, kwargs=kwargs) - def _broadcast(self): - self._ev.send() - # Since eventlet Event doesn't allow multiple send() operations - # on an event, re-create the underlying event. - # Note: _ev.reset() is obsolete. - self._ev = eventlet.event.Event() + def run(self): + try: + super().run() + except TaskExit: + pass + except BaseException as e: + if self.raise_error: + raise e + LOG.error('HubThread uncaught exception: %s', + traceback.format_exc()) - def is_set(self): - return self._cond + def spawn(func, *args, **kwargs): + thread = HubThread(func, args, kwargs) + thread.start() + return thread - def set(self): - self._cond = True - self._broadcast() + def spawn_after(seconds, func, *args, **kwargs): + def delayed_func(): + time.sleep(seconds) + func(*args, **kwargs) + return spawn(delayed_func) - def clear(self): - self._cond = False + def joinall(threads): + for thread in threads: + thread.join() - def wait(self, timeout=None): - if timeout is None: - self._wait() + def kill(thread): + # NOTE(sahid): There is no safe and reliable way to force + # stopping a thread in Python. It is recommended to implement + # a proper termination mechanism using a flag or an event. + pass + + getcurrent = threading.current_thread() + sleep = time.sleep + + Queue = queue.Queue + QueueEmpty = queue.Empty + Semaphore = threading.Semaphore + BoundedSemaphore = threading.BoundedSemaphore + TaskExit = Exception + + HubEvent = threading.Event + + class HubEvent(threading.Event): + def send(self): + self.set() + + class Timeout(BaseException): + """ + Largely inspired by: + https://github.com/eventlet/eventlet/blob/master/eventlet/timeout.py + """ + def __init__(self, seconds=None, exception=None): + self._event = threading.Event() + self._queue = queue.Queue() + + self.seconds = seconds + self.exception = exception + + self.timer = None + + self.start() + + def start(self): + if self.seconds is None: + # "fake" timeout (never expires) + self.timer = None + else: + self.timer = threading.Timer(self.seconds, self._on_timeout) + self.timer.start() + self._wait() + return self + + def __enter__(self): + if self.timer is None: + self.start() + return self + + def __exit__(self, typ, value, tb): + self.cancel() + if value is self and self.exception is False: + return True + + def _on_timeout(self): + if self.exception is None or isinstance(self.exception, bool): + # timeout that raises self + exc = self else: + exc = self.exception + self._queue.put(exc) + self._event.set() + + def cancel(self): + self.timer.cancel() + self._event.set() + + def _wait(self): + self._event.wait() + try: + raise self._queue.get_nowait() + except queue.Empty: + pass + + def listen(addr, family=socket.AF_INET, backlog=50, reuse_addr=True, + reuse_port=None): + """ + Largely inspired by: + https://github.com/eventlet/eventlet/../eventlet/convenience.py + """ + sock = socket.socket(family, socket.SOCK_STREAM) + if reuse_addr and sys.platform[:3] != 'win': + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + elif reuse_port is None: + reuse_port = True + if reuse_port and hasattr(socket, 'SO_REUSEPORT'): try: - with Timeout(timeout): - self._wait() - except Timeout: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) + except Exception: + # Not supported by the platform pass - - return self._cond + sock.bind(addr) + sock.listen(backlog) + return sock +else: + raise NotImplementedError( + "Invalid OSKEN_HUB_TYPE. Expected one of ('eventlet', 'native').") diff --git a/os_ken/tests/unit/lib/test_hub.py b/os_ken/tests/unit/lib/test_hub.py new file mode 100644 index 0000000..e2652f2 --- /dev/null +++ b/os_ken/tests/unit/lib/test_hub.py @@ -0,0 +1,351 @@ +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import os +import unittest +from unittest import mock +import importlib +import socket +import threading +import time + +import os_ken.lib.hub + + +class TestHubType(unittest.TestCase): + + @mock.patch.dict(os.environ, {"OSKEN_HUB_TYPE": "eventlet"}) + def test_eventlet_mode(self): + hub = importlib.reload(os_ken.lib.hub) + self.assertEqual("eventlet", hub.HUB_TYPE) + + @mock.patch.dict(os.environ, {"OSKEN_HUB_TYPE": "native"}) + def test_native_mode(self): + hub = importlib.reload(os_ken.lib.hub) + self.assertEqual("native", hub.HUB_TYPE) + + @mock.patch.dict(os.environ, {"OSKEN_HUB_TYPE": "other"}) + def test_other_mode(self): + self.assertRaises(NotImplementedError, importlib.reload, os_ken.lib.hub) + + +class TestStreamServerEventlet(unittest.TestCase): + @mock.patch.dict(os.environ, {"OSKEN_HUB_TYPE": "eventlet"}) + def setUp(self): + self.hub = importlib.reload(os_ken.lib.hub) + + @mock.patch("os_ken.lib.ip.valid_ipv6", return_value=False) + @mock.patch("os_ken.lib.hub.listen") + def test_ipv4_server(self, mock_listen, mock_valid_ipv6): + mock_listen.return_value = mock.Mock() + handle = mock.Mock() + + server = self.hub.StreamServer(("127.0.0.1", 1234), handle=handle) + + mock_listen.assert_called_once_with(("127.0.0.1", 1234)) + self.assertEqual(server.handle, handle) + + @mock.patch("os_ken.lib.ip.valid_ipv6", return_value=True) + @mock.patch("os_ken.lib.hub.listen") + def test_ipv6_server(self, mock_listen, mock_valid_ipv6): + mock_listen.return_value = mock.Mock() + handle = mock.Mock() + + server = self.hub.StreamServer(("::1", 1234), handle=handle) + + mock_listen.assert_called_once_with(("::1", 1234), family=socket.AF_INET6) + self.assertEqual(server.handle, handle) + + @mock.patch("os.path.isdir", return_value=True) + @mock.patch("os_ken.lib.hub.listen") + def test_unix_socket_server(self, mock_listen, mock_isdir): + mock_listen.return_value = mock.Mock() + handle = mock.Mock() + + server = self.hub.StreamServer(("/tmp/test_socket",), handle=handle) + + mock_listen.assert_called_once_with("/tmp/test_socket", family=socket.AF_UNIX) + self.assertEqual(server.handle, handle) + + @mock.patch("os_ken.lib.ip.valid_ipv6", return_value=False) + @mock.patch("os_ken.lib.hub.listen") + def test_ssl_server(self, mock_listen, mock_valid_ipv6): + mock_listen.return_value = mock.Mock() + handle = mock.Mock() + + ssl_ctx = mock.Mock() + ssl_args = { + "ssl_ctx": ssl_ctx, + "certfile": "cert.pem", + "keyfile": "key.pem", + "cert_reqs": mock.sentinel.cert_reqs, + "ca_certs": "ca.pem", + } + + server = self.hub.StreamServer(("127.0.0.1", 1234), handle=handle, **ssl_args) + + ssl_ctx.load_cert_chain.assert_called_once_with("cert.pem", "key.pem") + ssl_ctx.load_verify_locations.assert_called_once_with("ca.pem") + self.assertEqual(ssl_ctx.verify_mode, mock.sentinel.cert_reqs) + + wrapped_handle = server.handle + sock = mock.Mock() + addr = ("127.0.0.1", 5678) + wrapped_handle(sock, addr) + + ssl_ctx.wrap_socket.assert_called_once_with(sock, server_side=True) + handle.assert_called_once_with(ssl_ctx.wrap_socket(), addr) + + +class TestStreamServerNative(TestStreamServerEventlet): + + @mock.patch.dict(os.environ, {"OSKEN_HUB_TYPE": "native"}) + def setUp(self): + self.hub = importlib.reload(os_ken.lib.hub) + + +class TestStreamClientEventlet(unittest.TestCase): + + @mock.patch.dict(os.environ, {"OSKEN_HUB_TYPE": "eventlet"}) + def setUp(self): + self.hub = importlib.reload(os_ken.lib.hub) + + def test_connection(self): + addr = ("127.0.0.1", 1234) + timeout = 5 + with mock.patch("socket.create_connection") as mock_create_conn: + mock_create_conn.return_value = mock.Mock() + client = self.hub.StreamClient(addr, timeout=timeout) + connection = client.connect() + + self.assertIsNotNone(connection) + mock_create_conn.assert_called_once_with(addr, timeout=timeout) + + +class TestStreamClientNative(TestStreamClientEventlet): + + @mock.patch.dict(os.environ, {"OSKEN_HUB_TYPE": "native"}) + def setUp(self): + self.hub = importlib.reload(os_ken.lib.hub) + + +class TestEventEventlet(unittest.TestCase): + + @mock.patch.dict(os.environ, {"OSKEN_HUB_TYPE": "eventlet"}) + def setUp(self): + self.hub = importlib.reload(os_ken.lib.hub) + self.event = self.hub.Event() + + def test_initial_state(self): + self.assertFalse(self.event.is_set()) + + def test_set_event(self): + self.event.set() + self.assertTrue(self.event.is_set()) + + def test_clear_event(self): + self.event.set() + self.event.clear() + self.assertFalse(self.event.is_set()) + + def test_wait_success(self): + def set_event_after_delay(): + threading.Timer(0.1, self.event.set).start() + + set_event_after_delay() + result = self.event.wait(timeout=1) + self.assertTrue(result) + + def test_wait_timeout(self): + result = self.event.wait(timeout=1) + self.assertFalse(result) + + +class TestEventNative(TestEventEventlet): + + @mock.patch.dict(os.environ, {"OSKEN_HUB_TYPE": "native"}) + def setUp(self): + self.hub = importlib.reload(os_ken.lib.hub) + self.event = self.hub.Event() + + +class TestThreadManagementEventlet(unittest.TestCase): + + @mock.patch.dict(os.environ, {"OSKEN_HUB_TYPE": "eventlet"}) + def setUp(self): + self.hub = importlib.reload(os_ken.lib.hub) + + def test_spawn(self): + result = [] + + def task(): + result.append(mock.sentinel.value) + + thread = self.hub.spawn(task) + thread.wait() + self.assertEqual([mock.sentinel.value], result) + + def test_spawn_after(self): + result = [] + + def task(): + result.append(mock.sentinel.value) + + start_time = time.time() + thread = self.hub.spawn_after(1, task) + thread.wait() + + self.assertGreaterEqual(time.time() - start_time, 1) + self.assertEqual([mock.sentinel.value], result) + + @mock.patch('os_ken.lib.hub.LOG.error') + def test_spawn_raise_error_false(self, mock_log_error): + def failing_task(): + raise Exception() + + thread = self.hub.spawn(failing_task, raise_error=False) + thread.wait() + self.assertIn("exception", mock_log_error.call_args[0][0]) + + def test_spawn_raise_error_true(self): + def failing_task(): + raise Exception() + + thread = self.hub.spawn(failing_task, raise_error=True) + self.assertRaises(Exception, thread.wait) + + def test_spawn_task_exit(self): + def exit_task(): + raise os_ken.lib.hub.TaskExit() + + try: + thread = self.hub.spawn(exit_task, raise_error=True) + except: + self.fail("Should not be here") + + def test_joinall(self): + result = [] + + def task1(): + result.append(mock.sentinel.value1) + + def task2(): + result.append(mock.sentinel.value2) + + thread1 = self.hub.spawn(task1) + thread2 = self.hub.spawn_after(1, task2) + self.hub.joinall([thread1, thread2]) + + self.assertEqual([mock.sentinel.value1, + mock.sentinel.value2], result) + + def test_defined(self): + self.assertTrue(callable(self.hub.getcurrent)) + self.assertTrue(callable(self.hub.sleep)) + self.assertTrue(callable(self.hub.Queue)) + self.assertTrue(callable(self.hub.QueueEmpty)) + self.assertTrue(callable(self.hub.Semaphore)) + self.assertTrue(callable(self.hub.BoundedSemaphore)) + self.assertTrue(callable(self.hub.TaskExit)) + + +class TestThreadManagementNative(unittest.TestCase): + + @mock.patch.dict(os.environ, {"OSKEN_HUB_TYPE": "native"}) + def setUp(self): + self.hub = importlib.reload(os_ken.lib.hub) + + +class TestListenEventlet(unittest.TestCase): + + @mock.patch.dict(os.environ, {"OSKEN_HUB_TYPE": "eventlet"}) + def setUp(self): + self.hub = importlib.reload(os_ken.lib.hub) + + self.patcher = mock.patch('eventlet.green.socket.socket') + self.mock_socket = self.patcher.start() + + self.sock = mock.MagicMock() + self.mock_socket.return_value = self.sock + + +class TestListenEventlet(unittest.TestCase): + + @mock.patch.dict(os.environ, {"OSKEN_HUB_TYPE": "eventlet"}) + def setUp(self): + self.hub = importlib.reload(os_ken.lib.hub) + + self.patcher = mock.patch('eventlet.green.socket.socket') + self.mock_socket = self.patcher.start() + + self.sock = mock.MagicMock() + self.mock_socket.return_value = self.sock + + def tearDown(self): + self.patcher.stop() + + def test_default_socket_creation(self): + addr = ('127.0.0.1', 8080) + sock = self.hub.listen(addr) + + self.mock_socket.assert_called_once_with( + socket.AF_INET, socket.SOCK_STREAM) + + self.sock.setsockopt.assert_has_calls([ + mock.call(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1), + mock.call(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1), + ]) + + self.sock.bind.assert_called_once_with(addr) + self.sock.listen.assert_called_once_with(50) + + self.assertEqual(self.sock, sock) + + def test_socket_with_ipv6(self): + addr = ('::1', 8080) + sock = self.hub.listen(addr, family=socket.AF_INET6) + + self.mock_socket.assert_called_once_with( + socket.AF_INET6, socket.SOCK_STREAM) + + self.sock.setsockopt.assert_has_calls([ + mock.call(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1), + mock.call(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1), + ]) + + self.sock.bind.assert_called_once_with(addr) + self.sock.listen.assert_called_once_with(50) + + self.assertEqual(self.sock, sock) + + def test_reuse_port_enabled(self): + addr = ('127.0.0.1', 8081) + + sock = self.hub.listen(addr, reuse_port=True) + + self.sock.setsockopt.assert_any_call( + socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) + self.sock.bind.assert_called_once_with(addr) + + +class TestListenNative(unittest.TestCase): + + @mock.patch.dict(os.environ, {"OSKEN_HUB_TYPE": "native"}) + def setUp(self): + self.hub = importlib.reload(os_ken.lib.hub) + + self.patcher = mock.patch('socket.socket') + self.mock_socket = self.patcher.start() + + self.sock = mock.MagicMock() + self.mock_socket.return_value = self.sock diff --git a/releasenotes/notes/add-support-for-native-92012287cc366890.yaml b/releasenotes/notes/add-support-for-native-92012287cc366890.yaml new file mode 100644 index 0000000..4502138 --- /dev/null +++ b/releasenotes/notes/add-support-for-native-92012287cc366890.yaml @@ -0,0 +1,6 @@ +--- +features: + - | + Added support for ``native`` in OSKen Hub, using standard Python + libraries in replacement to eventlet. To enable ``native`` mode, set + the environment variable: ``OSKEN_HUB_TYPE=native``.