Compare commits

...
29 Commits
Author SHA1 Message Date
R. Richard dc5e26843a Refactor MessageSender 2020-06-24 13:44:07 +02:00
Gergo Turcsanyi e5d0e91e93 Fix message timing on empty queue 2020-05-29 12:01:37 +02:00
Gergo Turcsanyi e58f0b1d04 Update inotify paths on pattern change 2020-05-29 11:57:54 +02:00
R. Richard ea0fff5662 Remove the custom on_close event to keep the session alive 2020-05-21 17:45:03 +02:00
R. Richard 70f4d666e3 Keep websocket connection alive 2020-05-18 18:02:07 +02:00
R. Richard dc76f1b732 Fix broken ZMQ messages 2020-05-07 09:47:35 +02:00
R. Richard f374cb7e46 Look up every module in the package 2020-02-03 16:45:42 +01:00
Kristóf Tóth 9481e8f921 Add support for stopping/starting ProcessLogHandler from API 2019-12-19 16:21:15 +01:00
Kristóf Tóth c8d4080ef5 Fix fsm.update messages being thiggered without a state change 2019-12-19 13:58:48 +01:00
Kristóf Tóth 269cab691e Add priorities to TFW processes 2019-11-21 14:23:34 +01:00
Kristóf Tóth 16f0335d76 Make /etc/supervisor a writable volume 2019-11-13 12:52:20 +01:00
R. Richard 52280acf41 Check for a generic Mapping type instead of dict 2019-11-12 14:23:09 +01:00
Kristóf Tóth 1b274fa019 Avoid mixing up terminal.write command with user input 2019-11-08 11:34:15 +01:00
Kristóf Tóth ba4803d660 Fix race condition in FrontendConfigHandler (frontend.ready) 2019-10-31 15:23:55 +01:00
Kristóf Tóth ac198e5731 Avoid 'Update frontend' commits with Dockerfile build-args 2019-10-31 15:17:43 +01:00
Kristóf Tóth 135760854e Update frontend 2019-10-21 16:09:03 +02:00
Kristóf Tóth e6b840c7be Update frontend 2019-10-14 15:09:02 +02:00
Kristóf Tóth 6cdfb0f6ec Implement message handler command to drain message queue 2019-10-14 13:59:13 +02:00
Kristóf Tóth 88bc42c7f2 Fix race conditions in networking test suite 2019-10-10 15:31:56 +02:00
Kristóf Tóth 628485cff1 Implement robust unit test cases for message queue timing 2019-10-08 17:53:08 +02:00
Kristóf Tóth 641709c04e Add a fallback port for the terminal to avoid crashing proxies 2019-10-08 14:24:31 +02:00
Kristóf Tóth 251bc6325a Display typing indicator based on global message queue 2019-10-07 13:24:23 +02:00
Kristóf Tóth 0518716df8 Avoid waiting before first queued message 2019-10-07 13:10:37 +02:00
Kristóf Tóth 15b4ab62e5 Update frontend 2019-10-02 14:19:51 +02:00
Kristóf Tóth c2faa4732a Remove drone.yml 2019-09-30 14:49:11 +02:00
Kristóf Tóth 3934b79cf3 Update frontend 2019-09-30 13:52:06 +02:00
Kristóf Tóth 086df3700d Avoid reloading IDE on inotify events from the previous write 2019-09-27 15:16:08 +02:00
ni-richard ed4fdb92a5 Update README.md 2019-09-17 05:25:47 +02:00
ni-richard 2ce5e6ee1b Update README.md 2019-09-17 00:59:48 +02:00
22 changed files with 282 additions and 440 deletions
-13
View File
@@ -1,13 +0,0 @@
pipeline:
build:
image: eu.gcr.io/avatao-challengestore/challenge-builder
volumes:
- /etc/docker:/etc/docker:ro
- /root/.docker:/root/.docker:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
commands:
- docker build --pull -t eu.gcr.io/avatao-challengestore/tutorial-framework:${DRONE_TAG} .
- docker push eu.gcr.io/avatao-challengestore/tutorial-framework:${DRONE_TAG}
when:
event: 'tag'
branch: refs/tags/chausie-20*
+9 -3
View File
@@ -1,9 +1,14 @@
FROM avatao/frontend-tutorial-framework:chausie-20190915 as frontend
ARG FRONTEND_VERSION
FROM avatao/frontend-tutorial-framework:${FRONTEND_VERSION} as frontend
FROM avatao/debian:buster
ARG FRONTEND_VERSION
LABEL tfw.frontend.version=${FRONTEND_VERSION}
RUN apt-get update &&\
apt-get install -y --no-install-recommends \
supervisor \
ncat \
libzmq5 \
nginx \
jq \
@@ -22,7 +27,8 @@ RUN curl -Ls https://github.com/krallin/tini/releases/download/v0.18.0/tini-amd6
ENV TFW_PUBLIC_PORT=8888 \
TFW_WEB_PORT=4242 \
TFW_LOGIN_APP_PORT=6666 \
TFW_TERMINADO_PORT=7878 \
TFW_TERMINAL_PORT=7878 \
TFW_TERMINAL_FALLBACK_PORT=7879 \
TFW_SUPERVISOR_HTTP_PORT=9001 \
TFW_PUB_PORT=7654 \
TFW_PULL_PORT=8765
@@ -67,7 +73,7 @@ ONBUILD COPY ${BUILD_CONTEXT}/supervisor/ ${TFW_SUPERVISORD_COMPONENTS}
ONBUILD RUN for f in "${TFW_NGINX_DEFAULT}" ${TFW_NGINX_COMPONENTS}/*.conf; do \
envsubst "$(printenv | cut -d= -f1 | grep TFW_ | sed -e 's/^/$/g')" < $f > $f~ && mv $f~ $f ;\
done
ONBUILD VOLUME ["/etc/nginx", "/var/lib/nginx", "/var/log/nginx", "${TFW_LIB_DIR}/tfw"]
ONBUILD VOLUME ["/etc/nginx", "/etc/supervisor", "/var/lib/nginx", "/var/log/nginx", "${TFW_LIB_DIR}/tfw"]
ENTRYPOINT ["/bin/init", "--"]
CMD exec supervisord --nodaemon --configuration ${TFW_SUPERVISORD_CONF}
+2 -339
View File
@@ -1,342 +1,5 @@
# baseimage-tutorial-framework
This is the beating heart of TFW the Docker baseimage containing the internals of the framework.
This is the beating heart of TFW the Docker baseimage containing the internals of the framework. It's most important element is the robust messaging system that makes it straightforward to implement interactive exercises.
Every tutorial-framework based challenge has a `solvable` Docker image based on this one: their `Dockerfile`s begin with `FROM eu.gcr.io/avatao-challengestore/tutorial-framework`.
Note that TFW is not avaliable on Docker Hub due to legal reasons and is only accessible through local builds (don't worry, we've got you covered with build scripts in the [test-tutorial-framework](https://github.com/avatao-content/test-tutorial-framework) repo).
This document explains the general concepts of TFW and should be the first thing you read before getting started with development.
For more on building and running you should check the [test-tutorial-framework](https://github.com/avatao-content/test-tutorial-framework) repo.
## The framework
The goal of the tutorial-framework is to help content developers in creating interactive tutorials for the Avatao platform.
To make this possible TFW implements a robust messaging system and provides several pre-written components built upon it, such as a file editor and a terminal (both running in your browser).
The foundation of the whole framework is the messaging system connecting the frontend with the backend.
Frontend components use websockets to connect to the TFW server, to which you can hook several *event handlers* defining how to handle specific messages.
![TFW architecture](docs/tfw_architecture.png)
### Networking details
Event handlers connect to the TFW server using ZMQ.
They receive messages on their `SUB`(scribe) sockets, which are connected to the `PUB`(lish) socket of the server.
Event handlers reply on their `PUSH` socket, then their messages are received on the `PULL` socket of the server.
The TFW server is basically just a fancy proxy.
It's behaviour is quite simple: it proxies every message received from the fontend to the event handlers and vice versa.
The server is also capable of "mirroring" messages back to their source.
This is useful for communication between event handlers or frontend components (event handler to event handler or frontend component to frontend component communication).
Components can also broadcast messages (broadcasted messages are received both by event handlers and the frontend as well).
### Event handlers
Imagine event handlers as callbacks that are invoked when TFW receives a specific type of message. For instance, you could send a message to the framework when the user does something of note.
Event handler allow you to define actions triggered on the backend when the user presses a button on the frontend or moves the cursor to a specific area, etc.
Event handlers use ZeroMQ to connect to the framework. Due to this they are as loosely-coupled as possible: usually they are running in separate processes and only communicate with TFW through ZMQ.
Our pre-made event handlers are written in Python3, but you can write event handlers in any language that has ZeroMQ bindings (this means virtually any language).
This makes the framework really flexible: you can demonstrate the concepts you want to in any language while using the same set of tools provided by TFW.
Inside Avatao this means that any of the content teams can use the framework with ease.
To implement an event handler in Python3 you should subclass the `EventHandlerBase` or `FSMAwareEventHandler` class in `tfw.event_handler_base` (the first provides a minimal working `EventHandler`, the second allows you to execute code on FSM events).
### FSM
Another unique feature of the framework is the FSM finite state machine representing the state of your challenge.
This allows you to track users progressing with the tasks you've defined for them to complete.
For instance, you could represent whether the user managed to create a malicious user with a state called `user_registered` and subscribe callbacks to events regarding that state (like entering or leaving).
You could create challenges that can be completed in several different ways: imagine a state called `challenge_complete`, which indicates if the challenge is completed. Several series of actions (triggers) could lead to this state.
This enables you to guide your users through the experience you've envisioned with your tutorial.
We can provide a whole new level of interactivity in our challenges because we know what the user is doing.
This includes context-dependent hints and the automatic typing of commands to a terminal.
### Frontend
Note that our frontend implementation is written in Angular. It is maintained and documented in the [frontend-tutorial-framework](https://github.com/avatao-content/frontend-tutorial-framework) repository.
### Messaging format
The framework uses JSON messages internally and in exposed APIs as well.
These messages must comply with some rules.
Don't worry, we are not too fond of rules around these parts.
The TFW message format:
```text
{
"key: ...some identifier used for addressing...,
"data":
{
...
JSON object carrying anything, preferably cats
...
},
"trigger": ...FSM action...,
"signature": ...HMAC signature for authenticated messages...,
"seq": ...sequence number...
}
```
- The `key` field is used by TFW for addressing and every message must have one (it can be an empty string though)
- The `data` object can contain anything you might want to send
- The `trigger` key is an optional field that triggers an FSM action with that name from the current state (whatever that might be)
- The `signature` field is present on authenticated messages (such as `fsm_update`s)
- The `seq` key is a counter incremented with each proxied message in the TFW server
To mirror messages back to their sources you can use a special messaging format, in which the message to be mirrored is enveloped inside the `data` field of the outer message:
```text
"key": "mirror",
"data":
{
...
The message you want to mirror (with it's own "key" and "data" fields)
...
}
```
Broadcasting messages is possible in a similar manner by using `"key": "broadcast"` in the outer message.
## Where to go next
Most of the components you need have docstrings included (hang on tight, this is work in progress) refer to them for usage info.
In the `docs` folder you can find our Sphinx-based documentation, which you can build using the `hack/tfw.sh` script in the [test-tutorial-framework](https://github.com/avatao-content/test-tutorial-framework) repository.
To get started you should take a look at [test-tutorial-framework](https://github.com/avatao-content/test-tutorial-framework), which serves as an example project as well.
## API
APIs exposed by our pre-witten event handlers are documented here.
### IdeEventHandler
This event handler is responsible for reading and writing files shown in the frontend code editor.
You can read the content of the currently selected file like so:
```
{
"key": "ide",
"data":
{
"command": "read"
}
}
```
Use the following message to overwrite the content of the currently selected file:
```
{
"key": "ide",
"data":
{
"command": "write",
"content": ...string...
}
}
```
To select a file use the following message:
```
{
"key": "ide",
"data":
{
"command": "select",
"filename": ...string...
}
}
```
You can switch to a new working directory using this message (note that the directory must be in `allowed_directories`):
```
{
"key": "ide",
"data":
{
"command": "selectdir",
"directory": ...string...
}
}
```
Overwriting the current list of excluded file patterns is possible with this message:
```
{
"key": "ide",
"data":
{
"command": "exclude",
"exclude": ...array of strings...
}
}
```
### TerminalEventHandler
Event handler responsible for running a backend for `xterm.js` to connect to (frontend terminal backend).
By default callbacks on terminal history are invoked *as soon as* a command starts to execute in the terminal (they do not wait for the started command to finish, the callback may even run in paralell with the command).
If you want to wait for them and invoke your callbacks *after* the command has finished, please set the `TFW_DELAY_HISTAPPEND` envvar to `1`.
Practically this can be done by appending an `export` to the user's `.bashrc` file from your `Dockerfile`, like so:
`RUN echo "export TFW_DELAY_HISTAPPEND=1" >> /home/${AVATAO_USER}/.bashrc`
Writing to the terminal:
```
{
"key": "shell",
"data":
{
"command": "write",
"value": ...string...
}
}
```
You can read terminal command history like so:
```
{
"key": "shell",
"data":
{
"command": "read",
"count": ...number...
}
}
```
### ProcessManagingEventHandler
This event handler is responsible for managing processes controlled by supervisord.
Starting, stopping and restarting supervisor processes can be done using similar messages (where `command` is `start`, `stop` or `restart`):
```
{
"key": "processmanager",
"data":
{
"command": ...string...,
"process_name": ...string...
}
}
```
### LogMonitoringEventHandler
Event handler emitting real time logs (`stdout` and `stderr`) from supervisord processes.
To change which supervisor process is monitored use this message:
```
{
"key": "logmonitor",
"data" :
{
"command": "process_name",
"value": ...string...
}
}
```
To set the tail length of logs (the monitor will send back the last `value` characters of the log):
```
{
"key": "logmonitor",
"data" :
{
"command": "log_tail",
"value": ...number...
}
}
```
### FSMManagingEventHandler
This event handler controls the TFW finite state machine (FSM).
To attempt executing a trigger on the FSM use (this will also generate an FSM update message):
```
{
"key": "fsm",
"data" :
{
"command": "trigger",
"value": ...string...
}
}
```
To force the broadcasting of an FSM update you can use this message:
```
{
"key": "fsm",
"data" :
{
"command": "update"
}
}
```
This event handler broadcasts FSM update messages after handling commands in the following format:
```
{
"key": "fsm_update",
"data" :
{
"current_state": ...string...,
"valid_transitions": ...array of {"trigger": ...string...} objects...
}
}
```
### DirectorySnapshottingEventHandler
Event handler capable of taking and restoring snapshots of directories (saving and restoring directory contens).
You can take a snapshot of the directories with the following message:
```
{
"key": "snapshot",
"data" :
{
"command": "take_snapshot"
}
}
```
To restore the state of the files in the directories use:
```
{
"key": "snapshot",
"data" :
{
"command": "restore_snapshot",
"value": ...date string (can parse ISO 8601, unix timestamp, etc.)...
}
}
```
It is also possible to exclude files that match given patterns (formatted like lines in `.gitignore` files):
```
{
"key": "snapshot",
"data" :
{
"command": "exclude",
"value": ...list of patterns to exclude from snapshots...
}
}
```
To learn more about the framework and get started, please consult our [wiki](https://github.com/avatao-content/baseimage-tutorial-framework/wiki).
+1 -1
View File
@@ -1,5 +1,5 @@
location = /terminal {
proxy_pass http://127.0.0.1:${TFW_TERMINADO_PORT};
proxy_pass http://terminal;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
+5
View File
@@ -1,3 +1,8 @@
upstream terminal {
server 127.0.0.1:${TFW_TERMINAL_PORT};
server 127.0.0.1:${TFW_TERMINAL_FALLBACK_PORT} backup;
}
server {
listen ${TFW_PUBLIC_PORT};
server_name localhost;
+2 -2
View File
@@ -1,6 +1,6 @@
from os.path import dirname, realpath, join
from setuptools import setup
from setuptools import setup, find_packages
here = dirname(realpath(__file__))
@@ -17,7 +17,7 @@ setup(
author='Avatao.com Innovative Learning Kft.',
author_email='support@avatao.com',
license='custom',
packages=['tfw'],
packages=find_packages(),
package_dir={'tfw': 'tfw'},
install_requires=requirements,
extras_require={
@@ -0,0 +1,4 @@
[program:terminal_fallback]
command=ncat -klp %(ENV_TFW_TERMINAL_FALLBACK_PORT)s
autostart=true
autorestart=true
+1
View File
@@ -4,3 +4,4 @@ directory=/tmp
command=bash tfw_init.sh
autorestart=false
startsecs=0
priority=1
+1
View File
@@ -2,3 +2,4 @@
user=root
directory=%(ENV_TFW_SERVER_DIR)s
command=python3 -u tfw_server.py
priority=2
@@ -12,7 +12,7 @@ class FrontendConfigHandler:
def handle_event(self, _, connector):
# pylint: disable=no-self-use
for message in self._config_messages:
connector.send_message(message)
connector.send_message(message, scope=Scope.WEBSOCKET)
connector.send_message({'key': 'frontend.ready'}, scope=Scope.WEBSOCKET)
@property
@@ -1,43 +1,91 @@
from time import sleep
from queue import Queue
import logging
from time import sleep, time
from queue import Queue, Empty
from threading import Thread
from contextlib import suppress
LOG = logging.getLogger(__name__)
class MessageQueueHandler:
keys = ['message.queue']
type_id = 'ControlEventHandler'
avg_word_len = 5
drain_poll_freq = 0.2
def __init__(self, wpm):
self.connector = None
self.wpm = wpm
self._queue = Queue()
self._drain_queue = Queue()
self._thread = Thread(target=self._dispatch_messages)
self._commands = {
'message.queue': self.handle_queue,
'message.queue.drain': self.handle_drain
}
def _dispatch_messages(self):
for message in iter(self._queue.get, None):
wpm = message['wpm'] if 'wpm' in message else self.wpm
cps = 5 * wpm / 60
sleep(len(message['message']) / cps)
is_queue_empty = self._queue.empty()
message['typing'] = not is_queue_empty
self.connector.send_message(message)
self._sleep(self._get_sleep_time(message, is_queue_empty))
def _get_sleep_time(self, message, is_queue_empty):
words_per_min = message['wpm'] if 'wpm' in message else self.wpm
chars_per_min = self.avg_word_len * words_per_min / 60
return 0 if is_queue_empty else len(message['message']) / chars_per_min
def _sleep(self, seconds):
poll_freq = self.drain_poll_freq
if seconds < poll_freq:
poll_freq = seconds
sleep_until = time() + seconds
while time() < sleep_until:
sleep(poll_freq)
with suppress(Empty):
self._drain_queue.get(block=False)
self._drain()
break
def _drain(self):
with suppress(Empty):
while True:
message = self._queue.get(block=False)
if message is None:
break
message['typing'] = False
self.connector.send_message(message)
def handle_event(self, message, _):
try:
self._commands[message['key']](message)
except KeyError:
LOG.error('IGNORING MESSAGE: Invalid message received: %s', message)
def handle_queue(self, message):
for unpacked in self._generate_messages_from_queue(message):
self._queue.put(unpacked)
@staticmethod
def _generate_messages_from_queue(queue_message):
last = queue_message['messages'][-1]
for message in queue_message['messages']:
yield {
'key': 'message.send',
'typing': message is not last,
**message
}
def handle_drain(self, _):
self._drain_queue.put(True)
def start(self):
self._thread.start()
def cleanup(self):
# clearing the queue forces the loop in
# _dispatch_messages to block on _queue.get
self._queue.queue.clear()
self._queue.put(None)
self._thread.join()
@@ -1,8 +1,8 @@
# pylint: disable=redefined-outer-name
from math import inf
from time import sleep
from time import time
from os import urandom
from random import randint
from queue import Queue, Empty
import pytest
@@ -12,56 +12,141 @@ from .message_queue_handler import MessageQueueHandler
class MockConnector:
def __init__(self):
self.callback = None
self.messages = []
self.messages = Queue()
self.send_times = Queue()
def raise_event(self, message):
self.callback(message, self)
sleep(0.01)
def send_message(self, message):
self.messages.append(message)
self.messages.put(message)
self.send_times.put(time())
@pytest.fixture
def handler():
connector = MockConnector()
handler = MessageQueueHandler(inf)
handler.connector = connector
connector.callback = handler.handle_event
class NoSleepMessageQueueHandler(MessageQueueHandler):
sleep_start_times = Queue()
sleep_seconds = Queue()
sleep_end_times = Queue()
def _sleep(self, seconds):
self.sleep_start_times.put(time())
self.sleep_seconds.put(seconds)
super()._sleep(seconds)
self.sleep_end_times.put(time())
handler = NoSleepMessageQueueHandler(100000)
handler.connector = MockConnector()
handler.connector.callback = handler.handle_event
handler.start()
yield handler
handler.cleanup()
@pytest.fixture
def queue():
yield {
def get_message_queue(*, size=None):
size = randint(5, 10) if not size else size
return {
'key': 'message.queue',
'messages': [
{'originator': urandom(4).hex(), 'message': urandom(16).hex()}
for _ in range(randint(5, 10))
{'originator': urandom(4).hex(), 'message': urandom(randint(10, 20)).hex()}
for _ in range(size)
]
}
def test_message_order(handler, queue):
def test_order(handler):
queue = get_message_queue()
handler.connector.raise_event(queue)
old_list = queue['messages']
new_list = handler.connector.messages
length = len(old_list)
assert len(new_list) == length
for i in range(length):
unpacked = new_list[i]
assert unpacked['key'] == 'message.send'
assert unpacked['originator'] == old_list[i]['originator']
assert unpacked['typing'] == (i < length-1)
expected_messages = queue['messages']
actual_messages = []
for _ in expected_messages:
actual_messages.append(handler.connector.messages.get())
assert len(actual_messages) == len(expected_messages)
for i in range(len(expected_messages)): # pylint: disable=consider-using-enumerate
message = actual_messages[i]
assert message['key'] == 'message.send'
assert message['originator'] == expected_messages[i]['originator']
assert message['typing'] == (i < len(expected_messages)-1)
def test_wpm(handler, queue):
handler.wpm = 10000
handler.connector.raise_event(queue)
assert not handler.connector.messages
handler.wpm = 100000000
handler.connector.raise_event(queue)
sleep(0.25)
assert len(handler.connector.messages) == 2*len(queue['messages'])
def test_timing(handler):
q1 = get_message_queue(size=2)
q2 = get_message_queue(size=2)
handler.connector.raise_event(q1)
handler.connector.raise_event(q2)
messages = []
send_times = []
sleep_start_times = []
sleep_seconds = []
sleep_end_times = []
for _ in range(len(q1['messages']) + len(q2['messages'])):
messages.append(handler.connector.messages.get())
send_times.append(handler.connector.send_times.get())
sleep_start_times.append(handler.sleep_start_times.get())
sleep_seconds.append(handler.sleep_seconds.get())
sleep_end_times.append(handler.sleep_end_times.get())
# no sleep before first message
assert sleep_start_times[0] > send_times[0]
assert messages[0]['typing']
# at least 'seconds' sleep before sending next messages
assert (send_times[0] + sleep_seconds[0]) < sleep_end_times[0]
assert (send_times[0] + sleep_seconds[0]) < send_times[1]
assert messages[1]['typing']
assert (send_times[1] + sleep_seconds[1]) < sleep_end_times[1]
assert (send_times[1] + sleep_seconds[1]) < send_times[2]
assert messages[2]['typing']
# at least 'seconds' sleep after last message
assert (send_times[2] + sleep_seconds[2]) < sleep_end_times[3]
assert not messages[3]['typing']
def test_drain(handler):
q1 = get_message_queue()
q2 = get_message_queue()
messages_count = len(q1['messages']) + len(q2['messages'])
handler.wpm = 0.01 # very slow, everything will just block
handler.connector.raise_event(q1)
handler.connector.raise_event(q2)
handler.connector.messages.get(timeout=0.1) # no sleep before first message
with pytest.raises(Empty):
handler.connector.messages.get(timeout=0.1)
handler.connector.raise_event({'key': 'message.queue.drain'})
for _ in range(messages_count - 1):
handler.connector.messages.get()
def test_queue_works_after_drain(handler):
handler.connector.raise_event({'key': 'message.queue.drain'})
q = get_message_queue()
expected_msg_count = len(q['messages'])
handler.connector.raise_event(q)
messages = []
for _ in range(expected_msg_count):
messages.append(handler.connector.messages.get())
assert len(messages) == expected_msg_count
assert handler._thread.is_alive() # pylint: disable=protected-access
def test_queue_can_be_stopped(handler):
handler.wpm = 0.01 # very slow, everything will just block
q = get_message_queue()
handler.connector.raise_event(q)
handler.cleanup()
handler.connector.raise_event({'key': 'message.queue.drain'})
assert not handler._thread.is_alive() # pylint: disable=protected-access
+9 -8
View File
@@ -1,17 +1,22 @@
DEFAULT_ORIGINATOR = 'avataobot'
class MessageSender:
def __init__(self, uplink):
self.uplink = uplink
def send(self, message, originator=None):
def send(self, message, originator=DEFAULT_ORIGINATOR, buttons=None):
message = {
'key': 'message.send',
'message': message
}
if originator:
message['originator'] = originator
if buttons:
message['buttons'] = buttons
self.uplink.send_message(message)
def queue_messages(self, messages, originator=None):
def queue_messages(self, messages, originator=DEFAULT_ORIGINATOR, buttons=None):
message_queue = {
'key': 'message.queue',
'messages': []
@@ -21,10 +26,6 @@ class MessageSender:
if originator:
next_message['originator'] = originator
message_queue['messages'].append(next_message)
if buttons and message_queue['messages']:
message_queue['messages'][-1]['buttons'] = buttons
self.uplink.send_message(message_queue)
def set_originator(self, originator):
self.uplink.send_message({
'key': 'message.config',
'originator': originator
})
+6
View File
@@ -39,6 +39,7 @@ class IdeHandler:
self.connector = None
self.filemanager = FileManager(patterns)
self._initial_file = initial_file or ''
self._ignore_inotify_src = ''
self.monitor = InotifyObserver(
path=self.filemanager.parents,
@@ -53,6 +54,9 @@ class IdeHandler:
}
def _reload_frontend(self, event): # pylint: disable=unused-argument
if self._ignore_inotify_src == event.src_path:
self._ignore_inotify_src = ''
return
self.send_message({'key': 'ide.reload'})
@property
@@ -75,6 +79,7 @@ class IdeHandler:
def read(self, message):
if 'patterns' in message:
self.filemanager.patterns = message['patterns']
self.monitor.paths = self.filemanager.parents
try:
message['filename'] = self.filemanager.find_file(
message.get('filename') or self.initial_file
@@ -90,6 +95,7 @@ class IdeHandler:
def write(self, message):
try:
self._ignore_inotify_src = message['filename']
self.filemanager.write_file(message['filename'], message['content'])
except KeyError:
LOG.error('You must provide a filename to write!')
@@ -16,10 +16,13 @@ class ProcessLogHandler:
self._initial_log_tail = log_tail
self.command_handlers = {
'process.log.set': self.handle_set
'process.log.set': self.handle_set,
'process.log.start': self.handle_start,
'process.log.stop': self.handle_stop
}
def start(self):
if not self._monitor:
self._monitor = LogInotifyObserver(
connector=self.connector,
process_name=self.process_name,
@@ -40,5 +43,16 @@ class ProcessLogHandler:
if data.get('tail'):
self._monitor.log_tail = data['tail']
def cleanup(self):
def handle_start(self, _):
self.start()
def handle_stop(self, _):
self._stop_monitor()
def _stop_monitor(self):
if self._monitor:
self._monitor.stop()
self._monitor = None
def cleanup(self):
self._stop_monitor()
@@ -17,7 +17,7 @@ class TerminadoMiniServer:
url,
TerminadoMiniServer.ResetterTermSocket,
{'term_manager': self._term_manager}
)])
)], websocket_ping_interval=30)
@property
def term_manager(self):
@@ -33,10 +33,6 @@ class TerminadoMiniServer:
def check_origin(self, origin):
return True
def on_close(self):
self.term_manager.terminal = None
self.term_manager.get_terminal()
def listen(self):
self.application.listen(self.port)
@@ -42,6 +42,9 @@ class TerminalHandler:
LOG.error('IGNORING MESSAGE: Invalid message received: %s', message)
def handle_write(self, message):
concat = message.get('concat', False)
if not concat:
self.terminado_server.pty.write('\x15')
self.terminado_server.pty.write(message['command'])
def cleanup(self):
+3 -4
View File
@@ -1,6 +1,7 @@
import logging
from collections import defaultdict
from datetime import datetime
from contextlib import suppress
from transitions import Machine, MachineError
@@ -62,14 +63,12 @@ class FSMBase(Machine, CallbackMixin):
)
if all(predicate_results):
try:
with suppress(AttributeError, MachineError):
from_state = self.state
self.trigger(trigger)
if self.trigger(trigger):
self.update_event_log(from_state, trigger)
return True
except (AttributeError, MachineError):
LOG.debug('FSM failed to execute nonexistent trigger: "%s"', trigger)
return False
def update_event_log(self, from_state, trigger):
self.event_log.append({
+14 -1
View File
@@ -42,7 +42,8 @@ def deserialize_tfw_msg(*args):
"""
Return message from TFW multipart data
"""
return _deserialize_all(*args)[1]
envelope = _deserialize_all(*args)
return _repair_if_needed(envelope)
def _serialize_all(*args):
@@ -84,6 +85,18 @@ def _deserialize_single(data):
return _decode_if_needed(data)
def _repair_if_needed(envelope):
"""
Quick fix for broken messages received from separate processes.
"""
if len(envelope) == 2:
return envelope[1]
for part in envelope:
if isinstance(part, dict):
return part
return {}
def _encode_if_needed(value):
"""
Return input as bytes
+14 -5
View File
@@ -39,9 +39,12 @@ def zmq_connector(_listener_and_connector):
def run_ioloop_once():
# hack: we have to wait for the messages to get through
# Hack: we have to wait for the messages to get through
# the network stack of the OS while the IOLoop is waiting
# for them via select/epoll/kqueue
# for them via select/epoll/kqueue.
# This is an inherent race condition, but solving this
# problem properly would make the test code difficult
# to understand, so we use this half measure.
IOLoop.current().call_later(0.1, IOLoop.current().stop)
IOLoop.current().start()
@@ -67,7 +70,7 @@ def test_messages():
def wait_until_subscriber_connects(listener, connector):
# Warning: you are better off without comprehending how this works
# Reference: ZMQ PUB-SUB slow joiner problem
connector.subscribe('-', '_')
# Wait until something can go through the connection
dummy = {'key': '-'}
while True:
@@ -82,6 +85,7 @@ def wait_until_subscriber_connects(listener, connector):
with suppress(IOError):
if connector.recv_message(block=False) == sentinel:
break
connector.unsubscribe('-', '_')
def test_server_downlink(zmq_listener, zmq_connector, test_messages):
@@ -158,8 +162,10 @@ def test_connector_preserves_intent(zmq_listener, zmq_connector):
def test_server_uplink(zmq_listener, zmq_connector, test_messages):
messages = []
zmq_connector.subscribe('')
wait_until_subscriber_connects(zmq_listener, zmq_connector)
messages = []
zmq_connector.register_callback(messages.append)
for message in test_messages:
@@ -175,8 +181,10 @@ def test_connector_downlink_subscribe(zmq_listener, zmq_connector):
key2_messages = [{'key': '2', 'data': i} for i in range(randint(128, 256))]
all_messages = key1_messages + key2_messages
messages = []
zmq_connector.subscribe('1')
wait_until_subscriber_connects(zmq_listener, zmq_connector)
messages = []
zmq_connector.register_callback(messages.append)
for message in all_messages:
@@ -197,6 +205,7 @@ def test_listener_sync_recv(zmq_listener, zmq_connector, test_messages):
def test_connector_sync_recv(zmq_listener, zmq_connector, test_messages):
zmq_connector.subscribe('')
wait_until_subscriber_connects(zmq_listener, zmq_connector)
for message in test_messages:
zmq_listener.send_message(message)
assert zmq_connector.recv_message() == message
+2 -1
View File
@@ -2,6 +2,7 @@
from datetime import datetime
from typing import TextIO, Union
from dataclasses import dataclass
from collections.abc import Mapping
from traceback import format_exception
from logging import DEBUG, getLogger, Handler, Formatter, Filter
@@ -81,7 +82,7 @@ class LogFormatter(Formatter):
def format(self, record):
time = datetime.utcfromtimestamp(record.created).strftime('%H:%M:%S')
if record.args:
tuple_args = (record.args,) if isinstance(record.args, dict) else record.args
tuple_args = (record.args,) if isinstance(record.args, Mapping) else record.args
clean_args = tuple((self.trim(arg) for arg in tuple_args))
message = record.msg % clean_args
else:
+1 -1
View File
@@ -28,7 +28,7 @@ class TFWServer:
r'/ws', ZMQWebSocketRouter, {
'listener': self._listener,
}
)])
)], websocket_ping_interval=30)
def listen(self):
self.application.listen(TFWENV.WEB_PORT)