Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ mobi-view = "MoBI_View.main:main"
[dependency-groups]
dev = [
"pytest>=8.3.5",
"pytest-asyncio>=0.23.0",
"mypy>=1.15.0",
"pre-commit>=4.2.0",
"pytest-cov>=6.1.1",
Expand All @@ -33,6 +34,7 @@ docs = ["pdoc>=15.0.3"]
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
asyncio_mode = "auto"

[tool.mypy]
ignore_missing_imports = true
Expand Down
11 changes: 6 additions & 5 deletions src/MoBI_View/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from MoBI_View.core import discovery
from MoBI_View.presenters import main_app_presenter
from MoBI_View.web import server as web_server

logging.basicConfig(
level=logging.INFO,
Expand Down Expand Up @@ -38,22 +39,22 @@ def _launch() -> None:


def main() -> None:
"""Discovers LSL streams and creates presenter (web server in future branch)."""
"""Discovers LSL streams, creates presenter, and starts the web server."""
logger.info("Discovering LSL streams...")

inlets = discovery.discover_and_create_inlets()

if not inlets:
logger.info("No LSL streams found.")
logger.info(
"Future: Use 'Discover Streams' button in web UI to search for streams."
)
logger.info("Use 'Discover Streams' button in web UI to search for streams.")
else:
logger.info("Found %d stream(s)", len(inlets))

presenter = main_app_presenter.MainAppPresenter(data_inlets=inlets)
logger.info("Created presenter with %d inlet(s)", len(presenter.data_inlets))
logger.info("Note: Web server functionality will be added in future branch")

schedule_browser_launch()
web_server.run_server(presenter)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that this does something should we add a smoke test for main to check we actually open the web page (which has nothing in it right?)



if __name__ == "__main__":
Expand Down
91 changes: 89 additions & 2 deletions src/MoBI_View/web/server.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
"""WebSocket server for MoBI-View real-time data streaming.

This module provides WebSocket connection handling and static file serving
via the websockets process_request hook.
This module provides WebSocket connection handling, static file serving
via the websockets process_request hook, and the `run_server` entry
point that orchestrates the broadcaster and server lifecycle.
"""

import asyncio
import functools
import json
import logging
import mimetypes
import pathlib
import signal
from urllib import parse

from websockets import datastructures, http11
Expand Down Expand Up @@ -179,3 +183,86 @@ def _error_response(status_code: int, reason: str) -> http11.Response:
"""Build a plain-text HTTP error response."""
headers = datastructures.Headers([("Content-Type", "text/plain")])
return http11.Response(status_code, reason, headers, reason.encode())


def run_server(
presenter: main_app_presenter.MainAppPresenter,
host: str = "localhost",
port: int = 8765,
) -> None:
"""Entry point that starts the server runtime from synchronous code.

This function bridges the synchronous application startup path
(`main()`) into the async server lifecycle by running
`_run_server_async` in a fresh event loop.

The async lifecycle handles broadcaster startup, WebSocket server
startup, HTTP static-file routing via `process_request`, and
graceful shutdown on SIGINT/SIGTERM.

Port 8765 is conventional for WebSocket development servers,
used by the `websockets` library examples and tutorials.

Args:
presenter: The MainAppPresenter providing stream data.
host: The hostname to bind to.
port: The port to listen on.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment on why the default is 8765? seems like a default tcp port?

"""
asyncio.run(_run_server_async(presenter, host, port))


async def _run_server_async(
presenter: main_app_presenter.MainAppPresenter,
host: str,
port: int,
) -> None:
"""Starts and manages the async WebSocket server lifecycle.

This initializes the Broadcaster's background polling thread and
starts the WebSocket server on the specified host and port. It acts
as the main router, serving static files for plain HTTP requests
and directing WebSocket connections to the streaming handler.

The server runs continuously until it receives a shutdown signal
(SIGINT/SIGTERM), ensuring a graceful teardown of the broadcaster.

Args:
presenter: The MainAppPresenter providing stream data.
host: The hostname to bind to.
port: The port to listen on.
"""
active_broadcaster = broadcaster.Broadcaster(presenter)
active_broadcaster.start()

stop_event = asyncio.Event()
_register_shutdown_signals(stop_event)

handler = functools.partial(
ws_handler,
active_broadcaster=active_broadcaster,
presenter=presenter,
)

try:
async with server.serve(
handler,
host,
port,
process_request=process_request,
):
logger.info("Server listening on ws://%s:%d", host, port)
await stop_event.wait()
finally:
active_broadcaster.stop()
logger.info("Server shut down")


def _register_shutdown_signals(stop_event: asyncio.Event) -> None:
"""Register SIGINT and SIGTERM to set the stop event.

Args:
stop_event: The asyncio event to set when a signal is received.
"""
loop = asyncio.get_running_loop()
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, stop_event.set)
45 changes: 45 additions & 0 deletions tests/smoke/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@

import time
from typing import Generator
from unittest.mock import MagicMock, patch

import numpy as np
import pytest
from pylsl import info as pylsl_info
from pylsl import outlet as pylsl_outlet
from pylsl import resolve as pylsl_resolve

from MoBI_View import main
from MoBI_View.core import data_inlet


Expand Down Expand Up @@ -124,3 +126,46 @@ def test_no_streams() -> None:
data_inlets.append(inlet)

assert isinstance(data_inlets, list)


def test_main_discovers_streams_and_starts_server() -> None:
"""Tests main() wires discovery, presenter, browser launch, and server."""
mock_inlet = MagicMock()
mock_presenter = MagicMock()

with (
patch.object(
main.discovery, "discover_and_create_inlets", return_value=[mock_inlet]
) as mock_discover,
patch.object(
main.main_app_presenter,
"MainAppPresenter",
return_value=mock_presenter,
) as mock_pres_cls,
patch.object(main, "schedule_browser_launch") as mock_browser,
patch.object(main.web_server, "run_server") as mock_run,
):
main.main()

mock_discover.assert_called_once()
mock_pres_cls.assert_called_once_with(data_inlets=[mock_inlet])
mock_browser.assert_called_once()
mock_run.assert_called_once_with(mock_presenter)


def test_schedule_browser_launch_opens_browser() -> None:
"""Tests schedule_browser_launch schedules a browser open call."""
with patch.object(main.webbrowser, "open") as mock_open:
with patch.object(main.threading, "Timer") as mock_timer_cls:
mock_timer = MagicMock()
mock_timer_cls.return_value = mock_timer

main.schedule_browser_launch()

mock_timer_cls.assert_called_once()
mock_timer.start.assert_called_once()

callback = mock_timer_cls.call_args[0][1]
callback()

mock_open.assert_called_once_with("http://localhost:8765", new=2, autoraise=True)
75 changes: 74 additions & 1 deletion tests/unit/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
import asyncio
import json
import pathlib
from unittest.mock import AsyncMock, MagicMock, patch
import signal
from unittest.mock import AsyncMock, MagicMock, call, patch

import pytest
from websockets import datastructures
Expand Down Expand Up @@ -341,3 +342,75 @@ def test_is_within_static_dir(

with patch.object(server, "STATIC_DIR", static):
assert server._is_within_static_dir(target) is expected


def test_run_server_calls_asyncio_run(mock_presenter: MagicMock) -> None:
"""Tests run_server delegates to _run_server_async via asyncio.run."""
with patch.object(
server, "_run_server_async", new_callable=AsyncMock
) as mock_async:
server.run_server(mock_presenter, host="0.0.0.0", port=9000)

mock_async.assert_awaited_once_with(mock_presenter, "0.0.0.0", 9000)


async def test_run_server_async_starts_and_stops_broadcaster(
mock_presenter: MagicMock,
) -> None:
"""Tests _run_server_async creates, starts, and stops the broadcaster."""
mock_bc = MagicMock(spec=broadcaster.Broadcaster)
mock_serve = AsyncMock()
mock_serve.__aenter__ = AsyncMock()
mock_serve.__aexit__ = AsyncMock(return_value=False)

with (
patch.object(
server.broadcaster, "Broadcaster", return_value=mock_bc
) as mock_bc_cls,
patch.object(server.server, "serve", return_value=mock_serve) as mock_serve_fn,
patch.object(server, "_register_shutdown_signals") as mock_signals,
):
mock_signals.side_effect = lambda event: event.set()

await server._run_server_async(mock_presenter, "localhost", 8765)

mock_bc_cls.assert_called_once_with(mock_presenter)
mock_bc.start.assert_called_once()
mock_bc.stop.assert_called_once()

_, serve_kwargs = mock_serve_fn.call_args
assert serve_kwargs["process_request"] is server.process_request


async def test_run_server_async_stops_broadcaster_on_error(
mock_presenter: MagicMock,
) -> None:
"""Tests broadcaster is stopped even when the server raises."""
mock_bc = MagicMock(spec=broadcaster.Broadcaster)
mock_serve = AsyncMock()
mock_serve.__aenter__ = AsyncMock(side_effect=OSError("address in use"))
mock_serve.__aexit__ = AsyncMock(return_value=False)

with (
patch.object(server.broadcaster, "Broadcaster", return_value=mock_bc),
patch.object(server.server, "serve", return_value=mock_serve),
patch.object(server, "_register_shutdown_signals"),
):
with pytest.raises(OSError, match="address in use"):
await server._run_server_async(mock_presenter, "localhost", 8765)

mock_bc.stop.assert_called_once()


async def test_register_shutdown_signals_registers_both_signals() -> None:
"""Tests both SIGINT and SIGTERM are registered on the event loop."""
stop_event = asyncio.Event()
loop = asyncio.get_running_loop()

with patch.object(loop, "add_signal_handler") as mock_add:
server._register_shutdown_signals(stop_event)

mock_add.assert_has_calls(
[call(signal.SIGINT, stop_event.set), call(signal.SIGTERM, stop_event.set)],
any_order=False,
)
25 changes: 25 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading