Repository: MycroftAI/mycroft-core

Issue:
can only connect once to gui
im having some weird issue with the GUI websocket

- start mycroft
- gui connects (debug cli GUI)
- kill debug cli
- start debug cli
- gui wont connect again until mycroft enclosure restart

I was making my [own cli GUI](https://gist.github.com/JarbasAl/031f66adcab4ab85824b8caef9576d9d)

enclosure logs bellow
```
2020-04-01 05:00:24.357 | INFO     |  9767 | mycroft.messagebus.client.client:on_open:67 | Connected
2020-04-01 05:00:32.569 | INFO     |  9767 | tornado.access | 101 GET /gui (127.0.0.1) 0.80ms
2020-04-01 05:00:44.081 | INFO     |  9767 | mycroft.client.enclosure.base:callback_disconnect:423 | Disconnecting!
2020-04-01 05:00:44.081 | INFO     |  9767 | mycroft.client.enclosure.base:callback_disconnect:425 | dict_keys(['dummy_10064'])
2020-04-01 05:00:44.082 | INFO     |  9767 | mycroft.client.enclosure.base:callback_disconnect:426 | deleting: dummy_10064
2020-04-01 05:00:51.334 | WARNING  |  9767 | tornado.general | tornado.autoreload started more than once in the same process
```

3 ways to replicate
- if gui is open in debug cli, my gui will not connect
- if gui is open in my script, gui cli will not connect
- if debug cli is restarted, second time gui wont connect 

so i guess the issue can be summed up as only 1 gui can connect, only once


NOTE: cli GUI always prints that it connected, that message is logged before it actually connects, so that needs refactoring


Discussion:
This is an issue that originated with the upgrade of tornado.

There is a branch with a workaround at [bugfix/workaround-multi-displays](https://github.com/MycroftAI/mycroft-core/tree/bugfix/workaround-multi-displays)

However it's not the ideal solution since tornado really doesn't like being run in this fashion nowadays. We're currently considering to rewrite the gui bus to be a single connection but isolating the displays by using jwt messages or a similar approach.
with the new message targetting mechanism you can even share the existing bus, the GUI is already connected to it anyway

The problem we'd like to resolve is the multicast, with targeting anything can still see the messages sent to the individual guis. The original idea with separation on different ports was to make sure different gui's could send things like a password without it being "easy" to listen to. It's a flawed mechanic right now and more obfuscation than an actual solution.

My idea was to setup a http(s) endpoint using tornado where a gui could register id and jwt key. Then connect to the public gui websocket and communicate privately using jwt.
with current implementation any GUI needs to connect to the core socket to register, skills send messages to the core socket before those are sent to the GUI socket, which means all GUIs get everything anyway

a big refactor is needed in the whole core, no point in securing the GUI socket if it is still getting all data from core socket

this is what i wanted to solve with the hivemind, you get messages targeted to you only, no direct access to the core socket at any point. If each gui used the Hivemind this would be solved
can the workaround fix branch be merged in dev till a better solution is sort out since it seems to work better than the quite broken current implementation in dev ? https://github.com/MycroftAI/mycroft-core/tree/bugfix/workaround-multi-displays
@JarbasAI point well made. In the thoughts I had the gui shouldn't connect to the core bus to get the gui bus port at all.

But keeping it even simpler as a start might be good. One possibility could be to still have a separate gui port just for having it open for external connections isolating the most dangerous things, (speech injection etc) on the internal core bus.

@aiix let me add a big todo notice of all the bad things in that solution before I do it.

Resolving patch:
diff --git a/mycroft/client/enclosure/base.py b/mycroft/client/enclosure/base.py
--- a/mycroft/client/enclosure/base.py
+++ b/mycroft/client/enclosure/base.py
@@ -22,9 +22,9 @@
 from mycroft.util import create_daemon
 from mycroft.util.log import LOG
 
-import tornado.web
 import json
-from tornado import autoreload, ioloop
+import tornado.web as web
+from tornado import ioloop
 from tornado.websocket import WebSocketHandler
 from mycroft.messagebus.message import Message
 
@@ -63,7 +63,6 @@ class Enclosure:
     def __init__(self):
         # Establish Enclosure's websocket connection to the messagebus
         self.bus = MessageBusClient()
-
         # Load full config
         Configuration.set_config_update_handlers(self.bus)
         config = Configuration.get()
@@ -72,6 +71,7 @@ def __init__(self):
         self.config = config.get("enclosure")
         self.global_config = config
 
+        self.gui = create_gui_service(self, config['gui_websocket'])
         # This datastore holds the data associated with the GUI provider. Data
         # is stored in Namespaces, so you can have:
         # self.datastore["namespace"]["name"] = value
@@ -94,7 +94,6 @@ def __init__(self):
         self.explicit_move = True  # Set to true to send reorder commands
 
         # Listen for new GUI clients to announce themselves on the main bus
-        self.GUIs = {}      # GUIs, either local or remote
         self.active_namespaces = []
         self.bus.on("mycroft.gui.connected", self.on_gui_client_connected)
         self.register_gui_handlers()
@@ -116,13 +115,14 @@ def run(self):
     ######################################################################
     # GUI client API
 
-    def send(self, *args, **kwargs):
+    def send(self, msg_dict):
         """ Send to all registered GUIs. """
-        for gui in self.GUIs.values():
-            if gui.socket:
-                gui.socket.send(*args, **kwargs)
-            else:
-                LOG.error('GUI connection {} has no socket!'.format(gui))
+        LOG.info('SENDING...')
+        for connection in GUIWebsocketHandler.clients:
+            try:
+                connection.send(msg_dict)
+            except Exception as e:
+                LOG.exception(repr(e))
 
     def on_gui_send_event(self, message):
         """ Send an event to the GUIs. """
@@ -398,37 +398,18 @@ def remove_pages(self, namespace, pages):
     # If the connection is lost, it must be renegotiated and restarted.
     def on_gui_client_connected(self, message):
         # GUI has announced presence
+        LOG.info('GUI HAS ANNOUNCED!')
+        port = self.global_config["gui_websocket"]["base_port"]
         LOG.debug("on_gui_client_connected")
         gui_id = message.data.get("gui_id")
 
-        # Spin up a new communication socket for this GUI
-        if gui_id in self.GUIs:
-            # TODO: Close it?
-            pass
-        try:
-            asyncio.get_event_loop()
-        except RuntimeError:
-            asyncio.set_event_loop(asyncio.new_event_loop())
-
-        self.GUIs[gui_id] = GUIConnection(gui_id, self.global_config,
-                                          self.callback_disconnect, self)
         LOG.debug("Heard announcement from gui_id: {}".format(gui_id))
 
         # Announce connection, the GUI should connect on it soon
         self.bus.emit(Message("mycroft.gui.port",
-                              {"port": self.GUIs[gui_id].port,
+                              {"port": port,
                                "gui_id": gui_id}))
 
-    def callback_disconnect(self, gui_id):
-        LOG.info("Disconnecting!")
-        # TODO: Whatever is needed to kill the websocket instance
-        LOG.info(self.GUIs.keys())
-        LOG.info('deleting: {}'.format(gui_id))
-        if gui_id in self.GUIs:
-            del self.GUIs[gui_id]
-        else:
-            LOG.warning('ID doesn\'t exist')
-
     def register_gui_handlers(self):
         # TODO: Register handlers for standard (Mark 1) events
         # self.bus.on('enclosure.eyes.on', self.on)
@@ -472,125 +453,73 @@ def register_gui_handlers(self):
 }
 
 
-class GUIConnection:
-    """ A single GUIConnection exists per graphic interface.  This object
-    maintains the socket used for communication and keeps the state of the
-    Mycroft data in sync with the GUIs data.
-
-    Serves as a communication interface between Qt/QML frontend and Mycroft
-    Core.  This is bidirectional, e.g. "show me this visual" to the frontend as
-    well as "the user just tapped this button" from the frontend.
-
-    For the rough protocol, see:
-    https://cgit.kde.org/scratch/mart/mycroft-gui.git/tree/transportProtocol.txt?h=newapi  # nopep8
-
-    TODO: Implement variable deletion
-    TODO: Implement 'models' support
-    TODO: Implement events
-    TODO: Implement data coming back from Qt to Mycroft
-    """
+def create_gui_service(enclosure, config):
+    import tornado.options
+    LOG.info('Starting message bus for GUI...')
+    # Disable all tornado logging so mycroft loglevel isn't overridden
+    tornado.options.parse_command_line(['--logging=None'])
 
-    _last_idx = 0  # this is incremented by 1 for each open GUIConnection
-    server_thread = None
+    routes = [(config['route'], GUIWebsocketHandler)]
+    application = web.Application(routes, debug=True)
+    application.enclosure = enclosure
+    application.listen(config['base_port'], config['host'])
 
-    def __init__(self, id, config, callback_disconnect, enclosure):
-        LOG.debug("Creating GUIConnection")
-        self.id = id
-        self.socket = None
-        self.callback_disconnect = callback_disconnect
-        self.enclosure = enclosure
+    create_daemon(ioloop.IOLoop.instance().start)
+    LOG.info('GUI Message bus started!')
+    return application
 
-        # Each connection will run its own Tornado server.  If the
-        # connection drops, the server is killed.
-        websocket_config = config.get("gui_websocket")
-        host = websocket_config.get("host")
-        route = websocket_config.get("route")
-        base_port = websocket_config.get("base_port")
 
-        while True:
-            self.port = base_port + GUIConnection._last_idx
-            GUIConnection._last_idx += 1
+class GUIWebsocketHandler(WebSocketHandler):
+    """The socket pipeline between the GUI and Mycroft."""
+    clients = []
 
-            try:
-                self.webapp = tornado.web.Application(
-                    [(route, GUIWebsocketHandler)], **gui_app_settings
-                )
-                # Hacky way to associate socket with this object:
-                self.webapp.gui = self
-                self.webapp.listen(self.port, host)
-            except Exception as e:
-                LOG.debug('Error: {}'.format(repr(e)))
-                continue
-            break
-        # Can't run two IOLoop's in the same process
-        if not GUIConnection.server_thread:
-            GUIConnection.server_thread = create_daemon(
-                ioloop.IOLoop.instance().start)
-        LOG.debug('IOLoop started @ '
-                  'ws://{}:{}{}'.format(host, self.port, route))
-
-    def on_connection_opened(self, socket_handler):
-        LOG.debug("on_connection_opened")
-        self.socket = socket_handler
+    def open(self):
+        GUIWebsocketHandler.clients.append(self)
+        LOG.info('New Connection opened!')
         self.synchronize()
 
+    def on_close(self):
+        LOG.info('Closing {}'.format(id(self)))
+        GUIWebsocketHandler.clients.remove(self)
+
     def synchronize(self):
-        """ Upload namespaces, pages and data. """
+        """ Upload namespaces, pages and data to the last connected. """
         namespace_pos = 0
-        for namespace, pages in self.enclosure.loaded:
+        enclosure = self.application.enclosure
+
+        for namespace, pages in enclosure.loaded:
+            LOG.info('Sync {}'.format(namespace))
             # Insert namespace
-            self.socket.send({"type": "mycroft.session.list.insert",
-                              "namespace": "mycroft.system.active_skills",
-                              "position": namespace_pos,
-                              "data": [{"skill_id": namespace}]
-                              })
+            self.send({"type": "mycroft.session.list.insert",
+                       "namespace": "mycroft.system.active_skills",
+                       "position": namespace_pos,
+                       "data": [{"skill_id": namespace}]
+                       })
             # Insert pages
-            self.socket.send({"type": "mycroft.gui.list.insert",
-                              "namespace": namespace,
-                              "position": 0,
-                              "data": [{"url": p} for p in pages]
-                              })
+            self.send({"type": "mycroft.gui.list.insert",
+                       "namespace": namespace,
+                       "position": 0,
+                       "data": [{"url": p} for p in pages]
+                       })
             # Insert data
-            data = self.enclosure.datastore.get(namespace, {})
+            data = enclosure.datastore.get(namespace, {})
             for key in data:
-                self.socket.send({"type": "mycroft.session.set",
-                                  "namespace": namespace,
-                                  "data": {key: data[key]}
-                                  })
-
+                self.send({"type": "mycroft.session.set",
+                           "namespace": namespace,
+                           "data": {key: data[key]}
+                           })
             namespace_pos += 1
 
-    def on_connection_closed(self, socket):
-        # Self-destruct (can't reconnect on the same port)
-        LOG.debug("on_connection_closed")
-        if self.socket:
-            LOG.debug("Server stopped: {}".format(self.socket))
-            # TODO: How to stop the webapp for this socket?
-            # self.socket.stop()
-            self.socket = None
-        self.callback_disconnect(self.id)
-
-
-class GUIWebsocketHandler(WebSocketHandler):
-    """
-    The socket pipeline between Qt and Mycroft
-    """
-
-    def open(self):
-        self.application.gui.on_connection_opened(self)
-
     def on_message(self, message):
-        LOG.debug("Received: {}".format(message))
+        LOG.info("Received: {}".format(message))
         msg = json.loads(message)
         if (msg.get('type') == "mycroft.events.triggered" and
                 (msg.get('event_name') == 'page_gained_focus' or
                     msg.get('event_name') == 'system.gui.user.interaction')):
             # System event, a page was changed
             msg_type = 'gui.page_interaction'
-            msg_data = {
-                'namespace': msg['namespace'],
-                'page_number': msg['parameters'].get('number')
-            }
+            msg_data = {'namespace': msg['namespace'],
+                        'page_number': msg['parameters'].get('number')}
         elif msg.get('type') == "mycroft.events.triggered":
             # A normal event was triggered
             msg_type = '{}.{}'.format(msg['namespace'], msg['event_name'])
@@ -602,10 +531,12 @@ def on_message(self, message):
             msg_data = msg['data']
 
         message = Message(msg_type, msg_data)
-        self.application.gui.enclosure.bus.emit(message)
+        LOG.info('Forwarding to bus...')
+        self.application.enclosure.bus.emit(message)
+        LOG.info('Done!')
 
     def write_message(self, *arg, **kwarg):
-        """ Wraps WebSocketHandler.write_message() with a lock. """
+        """Wraps WebSocketHandler.write_message() with a lock. """
         try:
             asyncio.get_event_loop()
         except RuntimeError:
@@ -614,13 +545,6 @@ def write_message(self, *arg, **kwarg):
         with write_lock:
             super().write_message(*arg, **kwarg)
 
-    def send_message(self, message):
-        if isinstance(message, Message):
-            self.write_message(message.serialize())
-        else:
-            LOG.info('message: {}'.format(message))
-            self.write_message(str(message))
-
     def send(self, data):
         """Send the given data across the socket as JSON
 
@@ -628,7 +552,9 @@ def send(self, data):
             data (dict): Data to transmit
         """
         s = json.dumps(data)
+        LOG.info('Sending {}'.format(s))
         self.write_message(s)
 
-    def on_close(self):
-        self.application.gui.on_connection_closed(self)
+    def check_origin(self, origin):
+        """Disable origin check to make js connections work."""
+        return True


Test patch: