giuseppe-carboni: @matteobachetti as you can see from the tests outcome, the monitor tests are actually passing without any issue, the build is still failing because of other known issues we haven't been able to fix yet. I was wondering if there is any chance to merge this today/tomorrow morning, since the telescope activities might be starting again on saturday.
matteobachetti: @giuseppe-carboni: could you please rebase to the current master? 
diff --git a/.travis.yml b/.travis.yml
index aa6630f..a17178e 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -48,7 +48,7 @@ matrix:
         # may run for a long time
         # Try all python versions with the latest numpy
 
-        - env: PYTHON_VERSION=3.7 SETUP_CMD='test --coverage' SETUP_XVFB=False USE_WATCHDOG=yes
+        - env: PYTHON_VERSION=3.7 SETUP_CMD='test --coverage' SETUP_XVFB=False USE_WATCHDOG=yes USE_TORNADO=yes
 #        # No matplotlib, with old scipy
 #        - env: PYTHON_VERSION=3.6 CONDA_DEPENDENCIES='scipy=0.19 pytest pip h5py statsmodels pyyaml numba pyregion' SETUP_CMD='test --coverage'
         # No NUMBA, pyregion
@@ -80,6 +80,7 @@ install:
     # install since this ensures Numpy does not get automatically upgraded.
     - if [[ $SETUP_CMD != egg_info ]] && [[ $USE_NUMBA == yes ]]; then $CONDA_INSTALL numba; fi
     - if [[ $SETUP_CMD != egg_info ]] && [[ $USE_WATCHDOG == yes ]]; then $PIP_INSTALL watchdog; fi
+    - if [[ $SETUP_CMD != egg_info ]] && [[ $USE_TORNADO == yes ]]; then $PIP_INSTALL tornado; fi
     - if [[ $SETUP_CMD != egg_info ]] && [[ $USE_PYREG == yes ]]; then $PIP_INSTALL pyregion; fi
     - if [[ $SETUP_CMD != egg_info ]] && [[ $USE_STATSM == yes ]]; then $CONDA_INSTALL statsmodels; fi
     - if [[ $SETUP_CMD != egg_info ]]; then pip install .; fi
diff --git a/requirements.txt b/requirements.txt
index ad442b1..6d1cfcf 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -2,10 +2,10 @@ scipy>=1.0
 numpy>=1.17
 pytest
 pytest_astropy
+pytest_asyncio
 pip
 h5py
 pyyaml
 astropy>=3
 sphinx-astropy
 statsmodels>=0.8.0
-
diff --git a/srttools/monitor.py b/srttools/monitor.py
deleted file mode 100644
index 424585f..0000000
--- a/srttools/monitor.py
+++ /dev/null
@@ -1,488 +0,0 @@
-
-import time
-import os
-import shutil
-import re
-import sys
-import signal
-import argparse
-try:
-    from watchdog.observers import Observer
-    from watchdog.observers.polling import PollingObserver
-    from watchdog.events import PatternMatchingEventHandler, FileMovedEvent
-    HAS_WATCHDOG = True
-except ImportError:
-    PatternMatchingEventHandler = object
-    HAS_WATCHDOG = False
-
-import warnings
-import glob
-import threading
-from multiprocessing import Process, Lock
-
-from astropy import log
-
-try:
-    from http.server import HTTPServer, SimpleHTTPRequestHandler, HTTPStatus
-except ImportError:
-    from BaseHTTPServer import HTTPServer
-    from SimpleHTTPServer import SimpleHTTPRequestHandler
-    class HTTPStatus:
-        pass
-    HTTPStatus.NOT_FOUND = 404
-
-from srttools.read_config import read_config
-from srttools.scan import product_path_from_file_name
-from srttools.imager import main_preprocess
-try:
-    import matplotlib.pyplot as plt
-    plt.switch_backend('Agg')
-except ImportError:
-    pass
-
-MAX_FEEDS = 7
-
-class MyEventHandler(PatternMatchingEventHandler):
-    patterns = \
-        ["*/*.fits"] + ["*/*.fits{}".format(x) for x in range(MAX_FEEDS)]
-
-    def __init__(self, n_proc, nosave=False, verbosity=0, test=False):
-        super().__init__()
-        self.conf = getattr(sys.modules[__name__], 'conf')
-        create_index_file(self.conf['debug_file_format'])
-
-        self.max_proc = n_proc
-
-        self.timers = {}
-        self.lock = Lock()
-        self.processing_queue = []
-        self.waiting_queue = []
-
-        self.nosave = nosave
-        self.verbosity = verbosity
-
-        self.on_modified = self._start_timer
-        self.on_created = self._start_timer
-        self.on_moved = self._start_timer
-
-        self.test = test
-
-    def _start_timer(self, event):
-        infile = ''
-        if isinstance(event, FileMovedEvent):
-            for pattern in self.patterns:
-                pattern = pattern.rsplit('.')[-1]
-                if event.dest_path.endswith(pattern):
-                    infile = event.dest_path
-            if not infile:
-                return
-        else:
-            infile = event.src_path
-
-        if self.timers.get(infile):
-            self.timers[infile].cancel()
-
-        self.timers[infile] = threading.Timer(
-            1,
-            self._enqueue,
-            args=[infile]
-        )
-        self.timers[infile].daemon = True
-        self.timers[infile].start()
-
-    def _enqueue(self, infile):
-        if self.timers.get(infile):
-            del self.timers[infile]
-        if infile not in self.waiting_queue:
-            self.waiting_queue.append(infile)
-            while len(self.processing_queue) == self.max_proc:
-                time.sleep(0.05)
-            self.waiting_queue.remove(infile)
-            self.processing_queue.append(infile)
-            proc_args = (
-                infile,
-                self.conf,
-                self.nosave,
-                self.verbosity,
-                self.lock
-            )
-            if self.test:
-                p = threading.Thread(target=self.process, args=proc_args)
-            else:
-                p = Process(target=self.process, args=proc_args)
-            p.daemon = True
-            p.start()
-            p.join()
-            try:
-                if p.exitcode not in [0, 1]:
-                    log.info('Process {} exited with unexpected code {}'.format(
-                        p.pid, p.exitcode
-                    ))
-            except AttributeError:
-                pass
-            self.processing_queue.remove(infile)
-
-    @staticmethod
-    def process(infile, conf, nosave, verbosity, lock):
-        try:
-            pid = os.getpid()
-            log.info('Loading file {}, pid {}'.format(infile, pid))
-            ext = conf['debug_file_format']
-
-            productdir, fname = product_path_from_file_name(
-                infile,
-                productdir=conf['productdir'],
-                workdir=conf['workdir']
-            )
-            root = os.path.join(productdir, fname.rsplit('.fits')[0])
-
-            pp_args = ['--plot', '-c', conf['configuration_file_name']]
-            if nosave:
-                pp_args.append('--nosave')
-            pp_args.append(infile)
-
-            skip = False
-            try:
-                with warnings.catch_warnings():
-                    if not verbosity:
-                        warnings.simplefilter('ignore')
-                    if main_preprocess(pp_args):
-                        skip = True
-            except KeyboardInterrupt:
-                raise KeyboardInterrupt
-            except:
-                log.exception(sys.exc_info()[1])
-                skip = True
-
-            if skip:
-                log.info('Aborted file {}, pid {}'.format(infile, pid))
-                sys.exit(1)
-
-            feed_idx = ''
-            if not infile.endswith('.fits'):
-                feed_idx = infile.rsplit('.fits')[-1]
-
-            lock.acquire()
-
-            newfiles = []
-            for debugfile in glob.glob(root + '{}_*.{}'.format(feed_idx, ext)):
-                if feed_idx:
-                    newfile = debugfile.replace(root + infile[-1], 'latest')
-                else:
-                    newfile = debugfile.replace(root, 'latest')
-                newfile = newfile.rstrip('.{}'.format(ext))
-                if feed_idx:
-                    new_index = 2 * int(infile.rsplit('.fits')[-1])
-                    newfile = (
-                        newfile.split('_')[0]
-                        + '_'
-                        + str(new_index + int(newfile.rsplit('_')[-1]))
-                    )
-                newfile = newfile + '.{}'.format(ext)
-                newfiles.append(newfile)
-                if os.path.exists(debugfile):
-                    if nosave:
-                        shutil.move(debugfile, newfile)
-                    else:
-                        shutil.copyfile(debugfile, newfile)
-            if nosave and conf['productdir'] \
-                    and conf['workdir'] not in conf['productdir']:
-                prodpath = os.path.relpath(root, conf['productdir'])
-                prodpath = prodpath.split('/')[0]
-                prodpath = os.path.join(conf['productdir'], prodpath)
-                for dirname, _, _ in os.walk(prodpath, topdown=False):
-                    if not os.listdir(dirname):
-                        os.rmdir(dirname)
-
-            if not feed_idx:
-                oldfiles = glob.glob('latest*.{}'.format(ext))
-                for oldfile in oldfiles:
-                    if oldfile not in newfiles and os.path.exists(oldfile):
-                        os.remove(oldfile)
-
-            lock.release()
-
-            log.info('Completed file {}, pid {}'.format(infile, pid))
-        except KeyboardInterrupt:
-            pass
-        sys.exit(0)
-
-
-class MyRequestHandler(SimpleHTTPRequestHandler):
-
-    def __init__(self, request, client_address, server):
-        self.allowed_paths = ['index.html', 'index.htm']
-        conf = getattr(sys.modules[__name__], 'conf')
-        self.re_pattern = '^latest_([0-9]*).%s$' % conf['debug_file_format']
-        SimpleHTTPRequestHandler.__init__(self, request, client_address, server)
-
-    def do_GET(self):
-        path = self.path.lstrip('/')
-        if path == '':
-            path = 'index.html'
-        elif '?' in path:
-            path = path.split('?')[0]
-        if path not in self.allowed_paths \
-                and not re.match(self.re_pattern, path):
-            self.send_error(HTTPStatus.NOT_FOUND)
-            return
-        try:
-            SimpleHTTPRequestHandler.do_GET(self)
-        except (BrokenPipeError, ConnectionResetError):
-            pass
-
-    def log_message(self, format, *args):
-        return
-
-
-def main_monitor(args=None):
-    description = ('Run the SRT quicklook in a given directory.')
-    parser = argparse.ArgumentParser(description=description)
-
-    min_proc = 1
-    max_proc = MAX_FEEDS
-
-    def workers_count(w):
-        try:
-            w = int(w)
-            if not (w < min_proc or w > max_proc):
-                return w
-            else:
-                raise ValueError
-        except (ValueError, TypeError):
-            raise argparse.ArgumentTypeError(
-                "Choose an number of processes between {} and {}.".format(
-                    min_proc, max_proc
-                )
-            )
-
-    parser.add_argument(
-        "directories",
-        help="Directories to monitor",
-        default=None,
-        nargs='+',
-        type=str
-    )
-    parser.add_argument(
-        "-c", "--config",
-        help="Config file",
-        default=None,
-        type=str
-    )
-    parser.add_argument(
-        "--test",
-        help="Only to be used in tests!",
-        action='store_true',
-        default=False
-    )
-    parser.add_argument(
-        "--nosave",
-        help="Do not save the hdf5 intermediate files",
-        action='store_true',
-        default=False
-    )
-    parser.add_argument(
-        "-p", "--polling",
-        help="Use a platform-independent, polling watchdog",
-        action='store_true',
-        default=False
-    )
-    parser.add_argument(
-        "--http-server-port",
-        help="Share the results via HTTP server on given HTTP_SERVER_PORT",
-        type=int
-    )
-    parser.add_argument(
-        '-v', '--verbosity',
-        action='count',
-        default=0,
-        help='Set the verbosity level'
-    )
-    parser.add_argument(
-        '-w', '--workers',
-        type=workers_count,
-        default=1,
-        help='The maximum number of worker processes to spawn'
-    )
-    parser.add_argument(
-        '--timeout',
-        type=float,
-        default=None,
-        help='Set a timeout for the monitor'
-    )
-    args = parser.parse_args(args)
-
-    timeout = args.timeout
-    if args.timeout is None:
-        timeout = 1e32
-        if args.test:
-            timeout = 10
-
-    if not args.test:
-        del log.handlers[:]  # Needed to prevent duplicate logging entries
-
-    from astropy.logger import logging
-    logging.basicConfig(
-
-        level=logging.INFO,
-        format='%(asctime)s - %(message)s',
-        datefmt='%Y-%m-%d %H:%M:%S'
-    )
-
-    log.info('Starting monitor process, pid {}'.format(os.getpid()))
-
-    def sigterm_received(signum, frame):
-        os.kill(os.getpid(), signal.SIGINT)
-
-    if threading.current_thread() is threading.main_thread():
-        signal.signal(signal.SIGTERM, sigterm_received)
-
-    if not HAS_WATCHDOG:
-        raise ImportError('To use SDTmonitor, you need to install watchdog: \n'
-                          '\n   > pip install watchdog')
-
-    if args.config is None:
-        config_file = create_dummy_config()
-    else:
-        config_file = args.config
-
-    with warnings.catch_warnings():
-        if not args.verbosity:
-            warnings.simplefilter('ignore')
-        conf = read_config(config_file)
-
-    conf['configuration_file_name'] = config_file
-    setattr(sys.modules[__name__], 'conf', conf)
-
-    event_handler = MyEventHandler(
-        n_proc=args.workers,
-        nosave=args.nosave,
-        test=args.test
-    )
-    observer = None
-    if args.polling:
-        observer = PollingObserver()
-    else:
-        observer = Observer()
-
-    for path in args.directories:
-        observer.schedule(event_handler, path, recursive=True)
-
-    observer.start()
-
-    if args.http_server_port:
-        http_server = HTTPServer(('', args.http_server_port), MyRequestHandler)
-        t = threading.Thread(target=http_server.serve_forever)
-        t.daemon = True
-        t.start()
-
-    try:
-        t0 = time.time()
-        while time.time() - t0 < timeout:
-            time.sleep(1)
-
-        raise KeyboardInterrupt
-    except KeyboardInterrupt:
-        pass
-
-    if args.http_server_port:
-        http_server.shutdown()
-
-    observer.stop()
-    log.info('Stopping monitor process, pid {}'.format(os.getpid()))
-
-
-def create_dummy_config():
-    config_str = """
-    [local]
-    [analysis]
-    [debugging]
-    debug_file_format : png
-    """
-    with open('monitor_config.ini', 'w') as fobj:
-        print(config_str, file=fobj)
-
-    return 'monitor_config.ini'
-
-
-def create_index_file(extension, interval=500):
-    """
-    :param extension: the file extension of the image files to look for.
-    :param interval: expressed in milliseconds, it represents the time between
-        two subsequent calls to the ``updatePage`` function. Since the images
-        get reloaded without any flickering (as opposed to when the whole page
-        gets reloaded from the browser), its value can be set to a fraction of
-        a second without any visible issue.
-    """
-    html_string = \
-    """<html>
-    <script type="text/javascript">
-        window.onload = function()
-        {
-            document.body.innerHTML = "";
-
-            function remove_div(index)
-            {
-                var div = document.getElementById("div_" + index.toString());
-                if(div != null)
-                {
-                    div.parentElement.removeChild(div);
-                    remove_div(index + 1);
-                }
-            }
-
-            function update(index)
-            {
-                image_id = "img_" + index.toString();
-
-                var image = document.getElementById(image_id);
-
-                if(image == null)
-                {
-                    image = new Image();
-                    image.id = image_id;
-                    image.style.width = "100%";
-
-                    image.addEventListener("load", function()
-                    {
-                        if(this.parentElement == null)
-                        {
-                            index = parseInt(this.id.split("_")[1]);
-
-                            var div = document.getElementById("div_" + index.toString());
-
-                            if(div == null)
-                            {
-                                div = document.createElement("DIV");
-                                div.setAttribute("id", "div_" + index.toString());
-                                div.setAttribute("style", "width:50%; float:left;");
-                                document.body.appendChild(div);
-                            }
-
-                            while(div.firstChild)
-                            {
-                                div.removeChild(div.firstChild);
-                            }
-                            div.appendChild(this);
-                        }
-
-                        update(index + 1);
-                    });
-
-                    image.addEventListener("error", function()
-                    {
-                        index = parseInt(this.id.split("_")[1]);
-                        remove_div(index);
-                    });
-                }
-
-                image.src = "latest_" + index.toString() + ".""" + extension + """?" + new Date().getTime();
-            }
-
-            update(0);
-            setInterval(update, """ + str(interval) + """, 0);
-        }
-    </script>\n</html>"""
-
-    with open('index.html', 'w') as fobj:
-        print(html_string, file=fobj)
diff --git a/srttools/monitor/__init__.py b/srttools/monitor/__init__.py
new file mode 100644
index 0000000..3c7639f
--- /dev/null
+++ b/srttools/monitor/__init__.py
@@ -0,0 +1,110 @@
+import time
+import os
+import signal
+import argparse
+import threading
+
+from srttools.monitor.monitor import Monitor
+from srttools.monitor.common import MAX_FEEDS
+
+
+def main_monitor(args=None):
+    description = ('Run the SRT quicklook in a given directory.')
+    parser = argparse.ArgumentParser(description=description)
+
+    min_proc = 1
+    max_proc = MAX_FEEDS
+
+    def workers_count(w):
+        try:
+            w = int(w)
+            if not (w < min_proc or w > max_proc):
+                return w
+            else:
+                raise ValueError
+        except (ValueError, TypeError):
+            raise argparse.ArgumentTypeError("Choose a number of processes between {} and {}.".format(min_proc, max_proc))
+
+    def config_file(filename):
+        if not filename:
+            return ''
+        elif os.path.isfile(filename):
+            return filename
+        else:
+            raise argparse.ArgumentTypeError("Provided configuration file '{}' does not exist!".format(filename))
+
+    def port_available(port):
+        try:
+            port = int(port)
+        except ValueError:
+            raise argparse.ArgumentTypeError("Argument `port` should be an integer!")
+        import socket
+        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+            if s.connect_ex(('localhost', port)) == 0:
+                raise argparse.ArgumentTypeError('Port {} is already being used, choose a different one!'.format(port))
+        return port
+
+    parser.add_argument(
+        "directories",
+        help="Directories to monitor",
+        default=None,
+        nargs='+',
+        type=str
+    )
+    parser.add_argument(
+        "-c", "--config",
+        help="Configuration file",
+        default='',
+        type=config_file
+    )
+    parser.add_argument(
+        "--polling",
+        help="Use a platform-independent, polling watchdog",
+        action='store_true',
+        default=False
+    )
+    parser.add_argument(
+        "-p", "--port",
+        help="The port on which the server will be listening",
+        type=int,
+        default=8080
+    )
+    parser.add_argument(
+        "-v", "--verbosity",
+        action='count',
+        default=0,
+        help='Set the verbosity level'
+    )
+    parser.add_argument(
+        "-w", "--workers",
+        type=workers_count,
+        default=1,
+        help='The maximum number of worker processes to spawn'
+    )
+    args = parser.parse_args(args)
+
+    # This block is required to translate a SIGTERM into a KeyboardInterrupt, in order to handle the process as a service
+    def sigterm_received(signum, frame):
+        os.kill(os.getpid(), signal.SIGINT)
+    if threading.current_thread() is threading.main_thread():
+        signal.signal(signal.SIGTERM, sigterm_received)
+
+    try:
+        monitor = Monitor(
+            args.directories,
+            config_file=args.config,
+            workers=args.workers,
+            verbosity=args.verbosity,
+            polling=args.polling,
+            port=args.port
+        )
+    except OSError as e:
+        parser.error(str(e))
+
+    monitor.start()
+    try:
+        while True:
+            time.sleep(0.1)
+    except KeyboardInterrupt:
+        pass
+    monitor.stop()
diff --git a/srttools/monitor/common.py b/srttools/monitor/common.py
new file mode 100644
index 0000000..1b78dfa
--- /dev/null
+++ b/srttools/monitor/common.py
@@ -0,0 +1,11 @@
+# Replace the default logging configuration with a custom one
+from astropy.logger import logging
+log = logging.getLogger('SDTmonitor')
+log.propagate = False
+sh = logging.StreamHandler()
+f = logging.Formatter('%(asctime)s - %(message)s', datefmt='%Y-%m-%d %H:%M:%S')
+sh.setFormatter(f)
+log.addHandler(sh)
+log.setLevel(logging.INFO)
+
+MAX_FEEDS = 7
diff --git a/srttools/monitor/monitor.py b/srttools/monitor/monitor.py
new file mode 100644
index 0000000..3a79be4
--- /dev/null
+++ b/srttools/monitor/monitor.py
@@ -0,0 +1,272 @@
+import time
+import os
+import shutil
+import sys
+import warnings
+import glob
+import threading
+import queue
+import multiprocessing as mp
+
+try:
+    from watchdog.observers import Observer
+    from watchdog.observers.polling import PollingObserver
+    from watchdog.events import PatternMatchingEventHandler, FileMovedEvent
+except ImportError:
+    raise ImportError('To use SDTmonitor, you need to install watchdog: \n'
+                          '\n   > pip install watchdog')
+
+# Set the matplotlib backend
+try:
+    import matplotlib.pyplot as plt
+    plt.switch_backend('Agg')
+except ImportError:
+    pass
+
+from srttools.read_config import read_config
+from srttools.scan import product_path_from_file_name
+from srttools.imager import main_preprocess
+from srttools.monitor.common import MAX_FEEDS, log
+from srttools.monitor.webserver import WebServer
+
+
+def create_dummy_config(filename='monitor_config.ini', extension='png'):
+    config_str = """[local]\n[analysis]\n[debugging]\ndebug_file_format : {}""".format(extension)
+    with open(filename, 'w') as fobj:
+        print(config_str, file=fobj)
+    return filename
+
+
+class MyEventHandler(PatternMatchingEventHandler):
+    patterns = \
+        ["*/*.fits"] + ["*/*.fits{}".format(x) for x in range(MAX_FEEDS)]
+
+    def __init__(self, observer):
+        self._observer = observer
+        self.on_modified = self._parse_filename
+        self.on_created = self._parse_filename
+        self.on_moved = self._parse_filename
+        super().__init__()
+
+    def _parse_filename(self, event):
+        infile = ''
+        if isinstance(event, FileMovedEvent):
+            for pattern in self.patterns:
+                pattern = pattern.rsplit('.')[-1]
+                if event.dest_path.endswith(pattern):
+                    infile = event.dest_path
+            if not infile:
+                return
+        else:
+            infile = event.src_path
+
+        if self._observer._timers.get(infile):
+            if not self._observer._timers[infile].processing:
+                self._observer._timers[infile].cancel()
+                del self._observer._timers[infile]
+            else:
+                return
+
+        t = threading.Timer(
+            1,
+            self._observer._enqueue,
+            args=(infile,)
+        )
+        t.processing = False
+        self._observer._timers[infile] = t
+        self._observer._timers[infile].start()
+
+
+class Monitor(object):
+    def __init__(self, directories, config_file='', workers=1, verbosity=0, polling=False, port=8080):
+        # Save constructor parameters
+        self._directories = directories
+        if not config_file:
+            config_file = create_dummy_config()            
+        self._config_file = config_file
+        self._workers = workers
+        self._verbosity = verbosity
+        self._polling = polling
+        self._port = port
+
+        # Load file configuration and save needed parameters
+        with warnings.catch_warnings():
+            if not self._verbosity:
+                warnings.simplefilter('ignore')
+            configuration = read_config(config_file)
+        self._productdir = configuration['productdir']
+        self._workdir = configuration['workdir']
+        self._extension = configuration['debug_file_format']
+
+        # Objects needed by the file observer
+        self._timers = {}
+        self._processing = queue.Queue(self._workers)
+        self._files = queue.Queue()
+
+        # Initialize the event handler and the file observer
+        self._event_handler = MyEventHandler(self)
+        if self._polling:
+            self._observer = PollingObserver()
+        else:
+            self._observer = Observer()
+
+        for path in directories:
+            self._observer.schedule(self._event_handler, path, recursive=True)
+
+        self._stop = False
+        self._worker_thread = None
+
+        # Initialize the web server, this will raise a OSError if the given port is already being used
+        self._web_server = WebServer(self._extension, self._port)
+
+    def start(self):
+        self._stop = False
+        self._worker_thread = threading.Thread(target=self._worker_method)
+        self._worker_thread.start()
+        self._observer.start()
+        self._web_server.start()
+        log.info('SDTmonitor started, process id: {}'.format(os.getpid()))
+
+    def stop(self):
+        # Stop the worker thread
+        self._stop = True
+        if self._worker_thread:
+            self._worker_thread.join()
+        # Stop the web server
+        self._web_server.stop()
+        # Stop the observer from enqueuing newly arrived files
+        self._observer.stop()
+        # Cancel and delete any pending timer
+        for infile, timer in self._timers.copy().items():
+            timer.cancel()
+            timer.join()
+            try:
+                del self._timers[infile]
+            except KeyError:
+                pass
+        # Terminate any running process
+        while True:
+            try:
+                p = self._processing.get_nowait()
+                p.terminate()
+            except AttributeError:  # Process enqueued but not started yet
+                pass
+            except queue.Empty:
+                break
+        log.info('SDTmonitor stopped, process id: {}'.format(os.getpid()))
+
+    def _worker_method(self):
+        while not self._stop:
+            try:
+                to_update = []
+                paths, oldfiles, prodpath = self._files.get_nowait()
+
+                for key in [key for key in paths if not os.path.exists(key)]: del paths[key]
+                for oldfile in oldfiles:
+                    if oldfile not in paths.values() and os.path.exists(oldfile):
+                        os.remove(oldfile)
+                        to_update.append(oldfile)
+                for oldfile, newfile in paths.items():
+                    shutil.move(oldfile, newfile)
+                    to_update.append(newfile)
+                if prodpath:
+                    for dirname, _, _ in os.walk(prodpath, topdown=False):
+                        if not os.listdir(dirname):
+                            os.rmdir(dirname)
+                for image in to_update:
+                    self._web_server.update(image)
+            except queue.Empty:
+                pass
+            time.sleep(0.01)
+
+    @staticmethod
+    def _process(pp_args, verbosity):
+        """Calls the main_preprocess function as a separate process, so that
+        multiple processes can run concurrently speeding up the whole operation
+        when receiving separate feeds files."""
+        exit_code = 0
+        try:
+            with warnings.catch_warnings():
+                if not verbosity:
+                    warnings.simplefilter('ignore')
+                if main_preprocess(pp_args):
+                    exit_code = 1
+        except KeyboardInterrupt:
+            exit_code = 15 
+        except:
+            log.exception(sys.exc_info()[1])
+            exit_code = 1
+        sys.exit(exit_code)
+
+    def _enqueue(self, infile):
+        self._timers[infile].processing = True
+        proc_args = (
+            ['--plot', '--nosave', '-c', self._config_file, infile],
+            self._verbosity
+        )
+        p = mp.Process(target=self._process, args=proc_args)
+        # The next call will stop if the queue is already full
+        while not self._stop:
+            try:
+                self._processing.put_nowait(p)
+                break
+            except queue.Full:  # The queue is full, just sleep and wait for a free spot
+                pass
+            time.sleep(0.01)
+        if self._stop:
+            return
+        p.start()
+        log.info('Loading file {}, pid {}'.format(infile, p.pid))
+
+        # While the process executes, we retrieve
+        # information regarding original and new files
+        productdir, fname = product_path_from_file_name(
+            infile,
+            productdir=self._productdir,
+            workdir=self._workdir
+        )
+        root = os.path.join(productdir, fname.rsplit('.fits')[0])
+
+        feed_idx = ''
+        offset = 0
+        if not infile.endswith('.fits'):
+            feed_idx = infile.rsplit('.fits')[-1]
+            offset = 2 * int(feed_idx)
+
+        paths = {
+            '{}{}_{}.{}'.format(root, feed_idx, i, self._extension):
+            'latest_{}.{}'.format(i + offset, self._extension)
+            for i in range(2 if feed_idx else MAX_FEEDS * 2)
+        }
+
+        prodpath = None
+        if self._productdir and self._workdir not in self._productdir:
+            prodpath = os.path.relpath(root, self._productdir)
+            prodpath = prodpath.split('/')[0]
+            prodpath = os.path.join(self._productdir, prodpath)
+
+        # Retrieve the list of image files already in the page directory
+        # They will be overwritten when new images come out
+        oldfiles = []
+        if not feed_idx:
+            oldfiles = glob.glob('latest*.{}'.format(self._extension))
+
+        p.join()  # Wait for process completion
+        if p.exitcode == 0:  # Completed successfully
+            self._files.put((paths, oldfiles, prodpath))
+            log.info('Completed file {}, pid {}'.format(infile, p.pid))
+        elif p.exitcode == 1:  # Aborted
+            log.info('Aborted file {}, pid {}'.format(infile, p.pid))
+        elif p.exitcode == 15:  # Forcefully terminated
+            log.info('Forcefully terminated process {}, file {}'.format(p.pid, infile))
+        else:  # Unexpected code
+            log.info('Process {} exited with unexpected code {}'.format(p.pid, p.exitcode))
+
+        # Eventually notify that the queue is not full anymore
+        try:
+            with self._processing.not_empty:
+                self._processing.queue.remove(p)
+                self._processing.not_full.notify()
+        except ValueError:
+            pass
+        del self._timers[infile]
diff --git a/srttools/monitor/webserver.py b/srttools/monitor/webserver.py
new file mode 100644
index 0000000..1ae5255
--- /dev/null
+++ b/srttools/monitor/webserver.py
@@ -0,0 +1,257 @@
+import threading
+import asyncio
+import json
+import base64
+try:
+    import tornado.web
+    import tornado.websocket
+    from tornado.log import access_log
+except ImportError:
+    raise ImportError('To use SDTmonitor, you need to install tornado: \n'
+                          '\n   > pip install tornado')
+
+from srttools.monitor.common import MAX_FEEDS, log
+
+
+def create_index_file(port, max_images=MAX_FEEDS*2):
+    html_string = '''
+<!DOCTYPE html>
+<html>
+    <head>
+        <meta charset="UTF-8">
+        <title>SRT Quicklook</title>
+    </head>
+    <body>
+        <script type="text/javascript">
+            window.onload = function()
+            {
+                function init_images(n)
+                {
+                    if(n % 2 == 0)
+                    {
+                        n++;
+                    }
+
+                    for(i = 0; i <= n; i++)
+                    {
+                        var div = document.getElementById("div_" + i.toString());
+
+                        if(div == null)
+                        {
+                            var image = new Image();
+                            image.id = "image_" + i.toString();
+                            image.style.width = "100%";
+                            image.src = "data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs%3D";
+
+                            div = document.createElement("DIV");
+                            div.setAttribute("id", "div_" + i.toString());
+                            div.setAttribute("style", "width:50%; float:left;");
+
+                            div.appendChild(image);
+                            document.body.appendChild(div);
+                        }
+                    }
+                }
+
+                function set_visibility()
+                {
+                    var white_image = "data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs%3D";
+
+                    for(i = 0; i < document.getElementsByTagName("IMG").length; i+=2)
+                    {
+                        var left = i;
+                        var right = i+1;
+                        var left_div = document.getElementById("div_" + left.toString());
+                        var right_div = document.getElementById("div_" + right.toString());
+                        var left_image = document.getElementById("image_" + left.toString());
+                        var right_image = document.getElementById("image_" + right.toString());
+
+                        if(left_image.src == white_image && right_image.src == white_image)
+                        {
+                            left_image.style.display = "none";
+                            right_image.style.display = "none";
+                        }
+                        else
+                        {
+                            left_image.style.display = "block";
+                            right_image.style.display = "block";
+                        }
+                    }
+                }
+
+                function connect()
+                {
+                    var destination = document.location.href;
+                    if(destination.startsWith("file"))
+                    {
+                        destination = "localhost";
+                    }
+                    else
+                    {
+                        destination = document.location.href.split(":")[1]
+                    }
+
+                    var ws = new WebSocket("ws:" + destination + ":''' + str(port) + '''/images");
+
+                    ws.onopen = function()
+                    {
+                        console.log('Connected')
+                    }
+                    
+                    ws.onmessage = function(message)
+                    {
+                        var msg = JSON.parse(message.data)
+                        init_images(msg.index);
+
+                        var image = document.getElementById("image_" + msg.index.toString());
+
+                        if(msg.image == "")
+                        {
+                            image.src = "data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs%3D";
+                        }
+                        else
+                        {
+                            image.src = "data:image/png;base64," + msg.image;
+                        }
+
+                        set_visibility();
+                    };
+
+                    ws.onclose = function(e)
+                    {
+                        console.log('Socket is closed. Reconnect will be attempted in 10 seconds.');
+                        setTimeout(function()
+                        {
+                            connect();
+                        }, 10000);
+                    };
+
+                    ws.onerror = function(err)
+                    {
+                        console.error('Socket encountered error. Closing socket');
+                        ws.close();
+                    };
+                }
+
+                connect();
+            }
+        </script>
+    </body>
+</html>'''
+    with open('index.html', 'w') as fobj:
+        print(html_string, file=fobj)
+
+
+class WSHandler(tornado.websocket.WebSocketHandler):
+    def initialize(self, connected_clients, images):
+        self.connected_clients = connected_clients
+        self.images = images
+
+    def check_origin(self, origin):
+        # This allows clients that did not send any request to the HTTPHandler previously
+        # i.e.: a client that opens the index.html page instead of accessing it via network
+        return True
+
+    def open(self):
+        log.info('Got connection from {}'.format(self.request.host_name))
+        self.connected_clients.add(self)
+        # Send all the images to new clients
+        keys = self.images.keys()
+        for index in keys:
+            self.send_image(index)
+
+    def on_close(self):
+        self.connected_clients.remove(self)
+        log.info('Client {} disconnected'.format(self.request.host_name))
+
+    def on_message(self, message):
+        pass
+
+    def send_image(self, index):
+        message = {
+            'index': index,
+            'image': self.images[index]
+        }
+        self.write_message(json.dumps(message))
+
+class HTTPHandler(tornado.web.RequestHandler):
+    def get(self):
+        # Answer the HTTP request with the index.html page
+        self.write(open('index.html', 'r').read())
+
+
+class WebServer(object):
+    def __init__(self, extension, port=8080):
+        self.extension = extension
+        self.port = port
+
+        # Load the current images
+        self.images = {}
+        for index in range(MAX_FEEDS * 2):
+            self._load_image('latest_{}.{}'.format(index, extension))
+
+        self.connected_clients = set()
+
+        self.t = None
+        self.started = False
+        application = tornado.web.Application([
+            (r'/images', WSHandler, dict(connected_clients=self.connected_clients, images=self.images)),
+            (r'/', HTTPHandler),
+            (r'/index.html', HTTPHandler)
+        ])
+        # Disable default log function, we use custom ones
+        def log_function(_):
+            pass
+        application.log_request = log_function
+        self.web_server = tornado.httpserver.HTTPServer(application)
+        try:
+            self.web_server.listen(self.port)
+        except OSError:
+            raise OSError('Port {} is already being used, choose a different one!'.format(self.port))
+
+    def start(self):
+        self._asyncioloop = None
+        try:
+            asyncio.get_event_loop()
+        except RuntimeError:
+            self._asyncioloop = asyncio.new_event_loop()
+            asyncio.set_event_loop(self._asyncioloop)
+        self.ioloop = tornado.ioloop.IOLoop.current()
+
+        create_index_file(self.port)
+
+        self.t = threading.Thread(target=self.ioloop.start)
+        self.t.start()
+        self.started = True
+
+    def stop(self):
+        if self.started:
+            self.ioloop.add_callback(self.ioloop.stop)
+            if self._asyncioloop:
+                self._asyncioloop.stop()
+        if self.t:
+            self.t.join()
+        self.started = False
+
+    def _load_image(self, image_file):
+        index = int(image_file.split('_')[1].split('.')[0]) 
+        try:
+            image_string = base64.b64encode(open(image_file, 'rb').read())
+            image_string = image_string.decode('utf-8')
+        except IOError:
+            image_string = ''
+        self.images[index] = image_string
+        return index, image_string
+
+    def update(self, image_file):
+        # Update the image in memory before sending it
+        index, image_string = self._load_image(image_file)
+        clients = self.connected_clients
+        for client in clients:
+            try:
+                self.ioloop.add_callback(client.send_image, index)
+            except tornado.websocket.WebSocketClosedError:
+                try:
+                    self.connected_clients.remove()
+                except KeyError:
+                    pass

Improve the whole SDTmonitor strategy
Right now the SDTmonitor is able to share a web page and the plotted images using the `HTTPserver` module. To share and upload the images, a polling strategy is used. Unfortunately, this strategy adds too much network overhead to the whole process since each web page asks for new images every 500ms, and each time, every image is retrieved as a new one (even if it is still the same image as the previous one). In order to avoid this behavior, the SDTmonitor should be able to notify the web pages (its clients) whenever a new image is ready to be retrieved and displayed.

In addition to the network strategy, the SDTmonitor has plenty of room for improvement.
diff --git a/srttools/tests/test_monitor.py b/srttools/tests/test_monitor.py
index 1e917b4..9a40b40 100644
--- a/srttools/tests/test_monitor.py
+++ b/srttools/tests/test_monitor.py
@@ -1,4 +1,3 @@
-
 import os
 import subprocess as sp
 import threading
@@ -6,15 +5,73 @@ import time
 import pytest
 import urllib
 import shutil
+import socket
+import asyncio
+import json
+import base64
+
+from srttools.read_config import read_config
+try:
+    from srttools.monitor import Monitor, MAX_FEEDS
+    from tornado.websocket import websocket_connect
+    HAS_DEPENDENCIES = True
+except ImportError:
+    HAS_DEPENDENCIES = False
+
+try:
+    import pytest_asyncio
+    HAS_PYTEST_ASYNCIO = True
+except ImportError:
+    HAS_PYTEST_ASYNCIO = False
 
-from ..read_config import read_config
-from ..monitor import main_monitor, create_dummy_config, HAS_WATCHDOG
-from ..scan import product_path_from_file_name
-from ..utils import look_for_files_or_bust
 
+from srttools.scan import product_path_from_file_name
+from srttools.utils import look_for_files_or_bust
 
 STANDARD_TIMEOUT = 10
 
+def get_free_tcp_port():
+    """This method provides an available TCP port to test
+    the WebSocket server without getting a SocketError"""
+    tcp = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+    tcp.bind(('', 0))
+    addr, port = tcp.getsockname()
+    tcp.close()
+    return port
+
+def compare_images(received_string, image_file):
+    image_string = base64.b64encode(open(image_file, 'rb').read())
+    image_string = image_string.decode('utf-8')
+    assert received_string == image_string
+
+
+class WebSocketClient(object):
+    def __init__(self, url):
+        self._url = url
+        self._ws = None
+
+    async def __aenter__(self):
+        self._ws = await websocket_connect(self._url)
+        return self
+
+    async def __aexit__(self, exc_type, exc, tb):
+        if self._ws:
+            self._ws.close()
+
+    async def get_messages(self, n, timeout=None):
+        messages = []
+        t0 = time.time()
+        while True:
+            elapsed = time.time() - t0
+            if len(messages) == n or (timeout and elapsed >= timeout):
+                break
+            try:
+                remaining = timeout - elapsed if timeout else 0
+                messages.append(await asyncio.wait_for(self._ws.read_message(), timeout=remaining))
+            except asyncio.TimeoutError:
+                pass
+        return messages
+
 
 class TestMonitor(object):
     @classmethod
@@ -82,7 +139,13 @@ class TestMonitor(object):
         if os.path.exists(klass.file_empty_pdf1):
             os.unlink(klass.file_empty_pdf1)
 
+    def setup_method(self):
+        self.monitor = None
+
     def teardown_method(self):
+        if self.monitor:
+            # This ensures to stop the monitor even when an exception is raised in a test
+            self.monitor.stop()
         files = [
             self.file_empty,
             self.file_empty_single_feed,
@@ -98,124 +161,101 @@ class TestMonitor(object):
             if os.path.exists(fname):
                 os.unlink(fname)
 
-    @pytest.mark.skipif('not HAS_WATCHDOG')
+    @pytest.mark.skipif('not HAS_DEPENDENCIES')
     def test_monitor_installed(self):
         sp.check_call('SDTmonitor -h'.split())
 
-    @pytest.mark.skipif('not HAS_WATCHDOG')
+    @pytest.mark.skipif('not HAS_DEPENDENCIES')
     def test_all(self):
-        def process():
-            main_monitor([self.datadir, '--test'])
+        port = get_free_tcp_port()
+        self.monitor = Monitor(
+            [self.datadir],
+            port=port
+        )
+        self.monitor.start()
 
-        w = threading.Thread(name='worker', target=process)
-        w.start()
         time.sleep(1)
 
         shutil.copy(self.file_empty_init, self.file_empty)
 
-        files = [self.file_empty_pdf0, self.file_empty_pdf1,
-                 'latest_0.png', 'latest_1.png']
+        files = ['latest_0.png', 'latest_1.png']
         look_for_files_or_bust(files, STANDARD_TIMEOUT)
 
-        w.join()
-
         for fname in files:
             os.unlink(fname)
 
-    @pytest.mark.skipif('not HAS_WATCHDOG')
+    @pytest.mark.skipif('not HAS_DEPENDENCIES')
     def test_all_new_with_config(self):
-        fname = self.config_file
-
-        def process():
-            main_monitor([self.datadir, '--test', '-c', fname])
+        port = get_free_tcp_port()
+        self.monitor = Monitor(
+            [self.datadir],
+            config_file = self.config_file,
+            port=port
+        )
+        self.monitor.start()
 
-        w = threading.Thread(name='worker', target=process)
-        w.start()
         time.sleep(1)
 
         shutil.copy(self.file_empty_init, self.file_empty)
 
-        files = [self.file_empty_pdf0_alt, self.file_empty_pdf1_alt,
-                      'latest_0.png', 'latest_1.png']
+        files = ['latest_0.png', 'latest_1.png']
         look_for_files_or_bust(files, STANDARD_TIMEOUT)
 
-        w.join()
-
         for fname in files:
             os.unlink(fname)
 
-    @pytest.mark.skipif('not HAS_WATCHDOG')
+    @pytest.mark.skipif('not HAS_DEPENDENCIES')
     def test_verbose(self):
-        fname = self.config_file
-
-        def process():
-            main_monitor([self.datadir, '--test', '-v', '-c', fname])
+        port = get_free_tcp_port()
+        self.monitor = Monitor(
+            [self.datadir],
+            verbosity=1,
+            config_file=self.config_file,
+            port=port
+        )
+        self.monitor.start()
 
-        w = threading.Thread(name='worker', target=process)
-        w.start()
         time.sleep(1)
 
         shutil.copy(self.file_empty_init, self.file_empty)
 
-        files = [self.file_empty_pdf0_alt, self.file_empty_pdf1_alt,
-                      'latest_0.png', 'latest_1.png']
+        files = ['latest_0.png', 'latest_1.png']
         look_for_files_or_bust(files, STANDARD_TIMEOUT)
 
-        w.join()
-
         for fname in files:
             os.unlink(fname)
 
-    @pytest.mark.skipif('not HAS_WATCHDOG')
+    @pytest.mark.skipif('not HAS_DEPENDENCIES')
     def test_a_single_feed(self):
-        fname = self.config_file
-
-        def process():
-            main_monitor([self.datadir, '--test', '-c', fname])
+        port = get_free_tcp_port()
+        self.monitor = Monitor(
+            [self.datadir],
+            config_file=self.config_file,
+            port=port
+        )
+        self.monitor.start()
 
-        w = threading.Thread(name='worker', target=process)
-        w.start()
         time.sleep(1)
 
         shutil.copy(self.file_empty_init_single_feed,
                     self.file_empty_single_feed)
 
-        files = [self.file_empty_pdf10, self.file_empty_hdf5_SF,
-                 'latest_10.png']
+        files = ['latest_10.png']
         look_for_files_or_bust(files, STANDARD_TIMEOUT)
 
-        w.join()
-
         for fname in files:
             os.unlink(fname)
 
-    @pytest.mark.skipif('not HAS_WATCHDOG')
+    @pytest.mark.skipif('not HAS_DEPENDENCIES')
     def test_polling(self):
-        def process():
-            main_monitor([self.datadir, '--test', '--polling'])
-
-        w = threading.Thread(name='worker', target=process)
-        w.start()
-        time.sleep(1)
-
-        shutil.copy(self.file_empty_init, self.file_empty)
-
-        files = [self.file_empty_pdf0, self.file_empty_pdf1,
-                 'latest_0.png', 'latest_1.png']
-        look_for_files_or_bust(files, STANDARD_TIMEOUT)
-
-        w.join()
-
-        for fname in files:
-            os.unlink(fname)
-
-    @pytest.mark.skipif('not HAS_WATCHDOG')
-    def test_nosave(self):
-        def process():
-            main_monitor([self.datadir, '--test', '--nosave'])
+        port = get_free_tcp_port()
+        self.monitor = Monitor(
+            [self.datadir],
+            polling=True,
+            port=port
+        )
+        self.monitor.start()
 
-        w = threading.Thread(name='worker', target=process)
-        w.start()
         time.sleep(1)
 
         shutil.copy(self.file_empty_init, self.file_empty)
@@ -223,35 +263,22 @@ class TestMonitor(object):
         files = ['latest_0.png', 'latest_1.png']
         look_for_files_or_bust(files, STANDARD_TIMEOUT)
 
-        w.join()
-
         for fname in files:
             os.unlink(fname)
 
-        for fname in [self.file_empty_pdf0, self.file_empty_pdf1,
-                      self.file_empty_hdf5]:
-            assert not os.path.exists(fname)
-
-    @pytest.mark.skipif('not HAS_WATCHDOG')
+    @pytest.mark.skipif('not HAS_DEPENDENCIES')
     def test_workers(self):
-        fname = self.config_file
-
-        files = [
-            self.file_empty_pdf10,
-            self.file_empty_pdf10.replace('dummy5', 'dummy4'),
-            self.file_empty_hdf5_SF,
-            self.file_empty_hdf5_SF.replace('dummy5', 'dummy4'),
-            'latest_8.png',
-            'latest_10.png'
-        ]
-
-        def process():
-            main_monitor([self.datadir, '--test', '-c', fname,
-                          '--timeout', '30',
-                          '--workers', '2'])
+        files = ['latest_8.png', 'latest_10.png']
+
+        port = get_free_tcp_port()
+        self.monitor = Monitor(
+            [self.datadir],
+            config_file=self.config_file,
+            workers=2,
+            port=port
+        )
+        self.monitor.start()
 
-        w = threading.Thread(name='worker', target=process)
-        w.start()
         time.sleep(1)
 
         shutil.copy(
@@ -263,137 +290,112 @@ class TestMonitor(object):
             self.file_empty_single_feed.replace('fits5', 'fits4')
         )
 
-        look_for_files_or_bust(files, 30)
-
-        w.join()
+        look_for_files_or_bust(files, STANDARD_TIMEOUT)
 
         for i, fname in enumerate(files):
             os.unlink(fname)
 
         os.unlink(self.file_empty_single_feed.replace('fits5', 'fits4'))
 
-    @pytest.mark.skipif('not HAS_WATCHDOG')
+    @pytest.mark.skipif('not HAS_DEPENDENCIES')
     def test_delete_old_images(self):
-        def process():
-            main_monitor([self.datadir, '--test'])
-
-        files = [self.file_empty_pdf0, self.file_empty_pdf1]
-        files += ['latest_{}.png'.format(i) for i in range(8)]
+        files = ['latest_{}.png'.format(i) for i in range(8)]
 
         for fname in files[2:]:
             sp.check_call('touch {}'.format(fname).split())
 
-        w = threading.Thread(name='worker', target=process)
-        w.start()
+        port = get_free_tcp_port()
+        self.monitor = Monitor(
+            [self.datadir],
+            port=port
+        )
+        self.monitor.start()
+
         time.sleep(1)
 
         shutil.copy(self.file_empty_init, self.file_empty)
 
-        time.sleep(STANDARD_TIMEOUT)
-        w.join()
+        look_for_files_or_bust(files[:2], STANDARD_TIMEOUT)
 
-        for fname in files[4:]:
+        for fname in files[2:]:
             assert not os.path.exists(fname)
-        for fname in files[:4]:
-            assert os.path.exists(fname)
-            os.unlink(fname)
-
-    @pytest.mark.skipif('not HAS_WATCHDOG')
-    def test_http_server_found(self):
-        def process():
-            main_monitor([self.datadir, '--test',
-                          '--http-server-port', '10000', '--timeout', '30'])
-
-        w = threading.Thread(name='worker', target=process)
-        w.start()
-        time.sleep(1)
 
-        shutil.copy(self.file_empty_init, self.file_empty)
-
-        time.sleep(7)
-        urls = [
-            '',
-            'index.html',
-            'latest_0.png',
-            'latest_1.png'#,
-            # 'latest_0.png?%d' % int(time.time())
-        ]
-        # Check that the files are provided by the HTTP server
-        for url in urls:
-            url = 'http://127.0.0.1:10000/' + url
-            print(url)
-            # import socket
-            # try:
-            r = urllib.request.urlopen(url, timeout=5)
-            assert r.code == 200
-            # except socket.timeout as e:
-            #     w.join()
-            #     raise
-
-        print("Done")
-
-        w.join()
-
-        for fname in [self.file_empty_pdf0, self.file_empty_pdf1,
-                      'latest_0.png', 'latest_1.png']:
-            assert os.path.exists(fname)
+        for fname in files[:2]:
             os.unlink(fname)
 
-    @pytest.mark.skipif('not HAS_WATCHDOG')
-    def test_http_server_not_found(self):
-        def process():
-            main_monitor([self.datadir, '--test', '--http-server-port', '10000'])
+    @pytest.mark.skipif('not HAS_DEPENDENCIES')
+    def test_http_server(self):
+        port = get_free_tcp_port()
+        self.monitor = Monitor(
+            [self.datadir],
+            port=port
+        )
+        self.monitor.start()
 
-        w = threading.Thread(name='worker', target=process)
-        w.start()
         time.sleep(1)
 
         shutil.copy(self.file_empty_init, self.file_empty)
 
-        time.sleep(7)
-
-        # Ask for non-existent files
-        for i in [20, 21]:
-            url = 'http://127.0.0.1:10000/latest_{}.png'.format(i)
-            with pytest.raises(urllib.error.HTTPError):
-                r = urllib.request.urlopen(url, timeout=10)
+        # Check that index.html is provided by the HTTP server
+        url = 'http://127.0.0.1:{}/'.format(port)
+        r = urllib.request.urlopen(url, timeout=5)
+        assert r.code == 200
 
-        w.join()
+        files = ['latest_0.png', 'latest_1.png']
+        look_for_files_or_bust(files, STANDARD_TIMEOUT)
 
-        for fname in [self.file_empty_pdf0, self.file_empty_pdf1,
-                      'latest_0.png', 'latest_1.png']:
+        for fname in files:
             assert os.path.exists(fname)
             os.unlink(fname)
 
-    @pytest.mark.skipif('not HAS_WATCHDOG')
-    def test_http_server_forbidden(self):
-        def process():
-            main_monitor([self.datadir, '--test', '--http-server-port', '10000'])
+    @pytest.mark.asyncio
+    @pytest.mark.skipif('not HAS_DEPENDENCIES or not HAS_PYTEST_ASYNCIO')
+    async def test_websocket_server(self):
+        port = get_free_tcp_port()
+        self.monitor = Monitor(
+            [self.datadir],
+            port=port
+        )
+        self.monitor.start()
 
-        w = threading.Thread(name='worker', target=process)
-        w.start()
         time.sleep(1)
 
-        shutil.copy(self.file_empty_init, self.file_empty)
-
-        time.sleep(7)
-
-        # Create a dummy 'latest_0.txt' file.
-        # The file should not be accessible since its name does not match
-        # 'index.html' nor 'index.html' nor 'latest_X.EXT' pattern, with EXT
-        # equal to the configured file extension, in this case is 'png'
-        forbidden_file = 'latest_0.txt'
-        open(forbidden_file, 'w').close()
-        url = 'http://127.0.0.1:10000/' + forbidden_file
-        with pytest.raises(urllib.error.HTTPError):
-            r = urllib.request.urlopen(url, timeout=10)
+        ws_url = 'ws://localhost:{}/images'.format(port)
+        async with WebSocketClient(ws_url) as ws:
+            # Retrieve the starting images, they should be 14 blanks,
+            # we ask for 15 and make sure they are actually 14
+            l = await ws.get_messages(15, timeout=1)
+            assert len(l) == 14
+            for image_string in l:
+                image = json.loads(image_string)
+                assert image['image'] == ''
+
+            # Now trigger the process of a file
+            shutil.copy(self.file_empty_init, self.file_empty)
+
+            files = ['latest_0.png', 'latest_1.png']
+            look_for_files_or_bust(files, STANDARD_TIMEOUT)
+
+            # Ask the new images
+            l = await ws.get_messages(2, timeout=1)
+            assert len(l) == 2
+            for image_string in l:
+                image = json.loads(image_string)
+                assert image['index'] in [0, 1]
+                compare_images(image['image'], 'latest_{}.png'.format(image['index']))
+
+            for fname in files:
+                assert os.path.exists(fname)
+                os.unlink(fname)
 
-        w.join()
+    @pytest.mark.skipif('HAS_DEPENDENCIES')
+    def test_dependencies_missing(self):
+        with pytest.raises(ImportError) as exc:
+            from srttools.monitor import Monitor
+        missing_watchdog = 'install watchdog' in str(exc.value)
+        missing_tornado  = 'install tornado'  in str(exc.value)
+        assert missing_watchdog or missing_tornado
 
-        for fname in [self.file_empty_pdf0, self.file_empty_pdf1,
-                      'latest_0.png', 'latest_1.png', forbidden_file]:
-            assert os.path.exists(fname)
-            os.unlink(fname)
 
     @classmethod
     def teardown_class(klass):

pip install -e .
parse_log_pytest
requirements.txt
pytest pytest_astropy
3.9
requirements.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
alabaster==0.7.16
astropy==6.0.1
astropy-iers-data==0.2025.3.31.0.36.18
astropy-sphinx-theme==2.0
attrs==25.3.0
babel==2.17.0
certifi==2025.1.31
charset-normalizer==3.4.1
contourpy==1.3.0
coverage==7.8.0
cycler==0.12.1
docutils==0.21.2
exceptiongroup==1.2.2
fonttools==4.56.0
h5py==3.13.0
hypothesis==6.130.5
idna==3.10
imagesize==1.4.1
importlib_metadata==8.6.1
importlib_resources==6.5.2
iniconfig==2.1.0
Jinja2==3.1.6
kiwisolver==1.4.7
MarkupSafe==3.0.2
matplotlib==3.9.4
numpy==1.26.4
numpydoc==1.8.0
packaging==24.2
pandas==2.2.3
patsy==1.0.1
pillow==11.1.0
pluggy==1.5.0
pyerfa==2.0.1.5
Pygments==2.19.1
pyparsing==3.2.3
pytest==8.3.5
pytest-arraydiff==0.6.1
pytest-astropy==0.11.0
pytest-astropy-header==0.2.2
pytest-cov==6.0.0
pytest-doctestplus==1.4.0
pytest-filter-subpackage==0.2.0
pytest-mock==3.14.0
pytest-remotedata==0.4.1
python-dateutil==2.9.0.post0
pytz==2025.2
PyYAML==6.0.2
requests==2.32.3
scipy==1.13.1
six==1.17.0
snowballstemmer==2.2.0
sortedcontainers==2.4.0
Sphinx==7.4.7
sphinx-astropy==1.9.1
sphinx-automodapi==0.18.0
sphinx-gallery==0.19.0
sphinxcontrib-applehelp==2.0.0
sphinxcontrib-devhelp==2.0.0
sphinxcontrib-htmlhelp==2.1.0
sphinxcontrib-jquery==4.1
sphinxcontrib-jsmath==1.0.1
sphinxcontrib-qthelp==2.0.0
sphinxcontrib-serializinghtml==2.0.0
-e git+https://github.com/discos/srt-single-dish-tools.git@184c400bd5bacc199dbeb0611be7c3b152ae0868#egg=srttools
statsmodels==0.14.4
tabulate==0.9.0
tomli==2.2.1
tzdata==2025.2
urllib3==2.3.0
zipp==3.21.0

name: srt-single-dish-tools
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=3.0.16=h5eee18b_0
  - pip=25.0=py39h06a4308_0
  - python=3.9.21=he870216_1
  - readline=8.2=h5eee18b_0
  - setuptools=75.8.0=py39h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - wheel=0.45.1=py39h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - alabaster==0.7.16
      - astropy==6.0.1
      - astropy-iers-data==0.2025.3.31.0.36.18
      - astropy-sphinx-theme==2.0
      - attrs==25.3.0
      - babel==2.17.0
      - certifi==2025.1.31
      - charset-normalizer==3.4.1
      - contourpy==1.3.0
      - coverage==7.8.0
      - cycler==0.12.1
      - docutils==0.21.2
      - exceptiongroup==1.2.2
      - fonttools==4.56.0
      - h5py==3.13.0
      - hypothesis==6.130.5
      - idna==3.10
      - imagesize==1.4.1
      - importlib-metadata==8.6.1
      - importlib-resources==6.5.2
      - iniconfig==2.1.0
      - jinja2==3.1.6
      - kiwisolver==1.4.7
      - markupsafe==3.0.2
      - matplotlib==3.9.4
      - numpy==1.26.4
      - numpydoc==1.8.0
      - packaging==24.2
      - pandas==2.2.3
      - patsy==1.0.1
      - pillow==11.1.0
      - pluggy==1.5.0
      - pyerfa==2.0.1.5
      - pygments==2.19.1
      - pyparsing==3.2.3
      - pytest==8.3.5
      - pytest-arraydiff==0.6.1
      - pytest-astropy==0.11.0
      - pytest-astropy-header==0.2.2
      - pytest-cov==6.0.0
      - pytest-doctestplus==1.4.0
      - pytest-filter-subpackage==0.2.0
      - pytest-mock==3.14.0
      - pytest-remotedata==0.4.1
      - python-dateutil==2.9.0.post0
      - pytz==2025.2
      - pyyaml==6.0.2
      - requests==2.32.3
      - scipy==1.13.1
      - six==1.17.0
      - snowballstemmer==2.2.0
      - sortedcontainers==2.4.0
      - sphinx==7.4.7
      - sphinx-astropy==1.9.1
      - sphinx-automodapi==0.18.0
      - sphinx-gallery==0.19.0
      - sphinxcontrib-applehelp==2.0.0
      - sphinxcontrib-devhelp==2.0.0
      - sphinxcontrib-htmlhelp==2.1.0
      - sphinxcontrib-jquery==4.1
      - sphinxcontrib-jsmath==1.0.1
      - sphinxcontrib-qthelp==2.0.0
      - sphinxcontrib-serializinghtml==2.0.0
      - statsmodels==0.14.4
      - tabulate==0.9.0
      - tomli==2.2.1
      - tzdata==2025.2
      - urllib3==2.3.0
      - zipp==3.21.0
prefix: /opt/conda/envs/srt-single-dish-tools

srttools/tests/test_monitor.py::TestMonitor::test_dependencies_missing
srttools/monitor/common.py srttools/monitor/__init__.py srttools/monitor/monitor.py .travis.yml srttools/monitor.py requirements.txt srttools/monitor/webserver.py
srttools/monitor/common.py srttools/monitor/__init__.py srttools/monitor/monitor.py .travis.yml srttools/monitor.py requirements.txt srttools/monitor/webserver.py