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
19 changes: 7 additions & 12 deletions WebATM/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

- server/session_manager.py: Session tracking
- server/routes.py: Basic Flask routes (index, commands, config, health)
- server/server_status.py: BlueSky server status
- server/bluesky_server_status.py: BlueSky server status
- server/socket_handlers.py: Socket.IO event handlers
"""

Expand Down Expand Up @@ -84,12 +84,9 @@ def create_app():
bluesky_proxy.socketio = socketio
set_bluesky_proxy(bluesky_proxy) # Set it globally for the subscriber callbacks

# NB: subscribers are NOT registered here. The proxy creates its network
# client lazily on connect (ZMQ pattern), so at app-creation time
# bluesky_client is still None and there is nothing to attach to.
# register_subscribers() is therefore called on connect instead -- by the
# /api/server/config route (standalone) and by the auto-start hook
# (integrated), both right after start_client() builds the client.
# NB: subscribers are NOT registered here — the network client doesn't
# exist yet. register_subscribers() runs on connect instead (the
# /api/server/config route, or the integrated auto-start hook).

# Store proxy reference in app for access in routes
app.bluesky_proxy = bluesky_proxy
Expand Down Expand Up @@ -121,11 +118,9 @@ def handle_exception(e):
# Harmless and unused in the default build.
app.session_manager = session_manager

# Optional integrated extensions: BlueSky server lifecycle control and
# live log streaming. This is a no-op in the default build -- the
# WEBATM_INTEGRATED env var is unset and the webatm_integrated package is
# not installed, so the import is skipped or caught. The core package
# never imports webatm_integrated; the dependency points the other way.
# Optional integrated extensions (server lifecycle control, live log
# streaming). A no-op in the default build: the env var is unset and the
# webatm_integrated package is not installed.
if os.environ.get("WEBATM_INTEGRATED") == "1":
try:
import webatm_integrated
Expand Down
33 changes: 24 additions & 9 deletions WebATM/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,29 @@
logger = get_logger()


def create_configured_app(bluesky_host=None):
"""Create the app with the default BlueSky server IP set on the proxy.

Shared bootstrap for every entry point: the dev server
(:func:`start_WebATM`) and the gunicorn WSGI scripts
(``script/wsgi.py``, ``script/wsgi_integrated.py``). The proxy is only
configured, not connected.

Args:
bluesky_host (str | None): BlueSky server hostname/IP. Falls back to
the ``BLUESKY_SERVER_HOST`` environment variable, then
``"localhost"``.

Returns:
tuple: The ``(app, socketio)`` pair from :func:`WebATM.app.create_app`.
"""
bluesky_host = bluesky_host or os.environ.get("BLUESKY_SERVER_HOST", "localhost")
app, socketio = create_app()
app.bluesky_proxy.server_ip = bluesky_host
logger.info(f"Default BlueSky server IP set to: {bluesky_host}")
return app, socketio


def start_WebATM(hostname=None, port=8082, debug=False):
"""Start the WebATM web server.

Expand All @@ -26,18 +49,10 @@ def start_WebATM(hostname=None, port=8082, debug=False):
set, takes precedence.
debug (bool): Whether to run the Socket.IO server in debug mode.
"""
# Get BlueSky server hostname from environment variable or parameter
bluesky_host = hostname or os.environ.get("BLUESKY_SERVER_HOST", "localhost")
web_port = int(os.environ.get("WEB_PORT", port))
web_host = os.environ.get("WEB_HOST", "localhost")

# create the app
app, socketio = create_app()

# Set default server IP on the client but don't connect - wait for user to configure
app.bluesky_proxy.server_ip = bluesky_host
logger.info("BlueSky Proxy initialized (not connected to BlueSky server)")
logger.info(f"Default BlueSky server IP set to: {bluesky_host}")
app, socketio = create_configured_app(hostname)
logger.info("Ready - Connect to BlueSky server via WebATM")

try:
Expand Down
8 changes: 0 additions & 8 deletions WebATM/proxy/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,10 +131,6 @@ def is_connected(self) -> bool:
"""
return self.was_connected and self.running and len(self.tracked_nodes) > 0

def _ensure_clean_zmq_context(self):
"""Ensure we have a clean environment for ZMQ connections."""
return self.connection_mgr._ensure_clean_zmq_context()

def _connect_bluesky_client_signals(self):
"""Connect BlueSky client signals to our handlers."""
return self.connection_mgr._connect_bluesky_client_signals()
Expand Down Expand Up @@ -176,10 +172,6 @@ def _close_bluesky_client(self):
"""Close network client following ZMQ pattern: close sockets first, then context."""
return self.connection_mgr._close_bluesky_client()

def reconnect(self, hostname=None):
"""Reconnect to BlueSky server following ZMQ pattern."""
return self.connection_mgr.reconnect(hostname)

def close(self):
"""Close all network connections and clear state like BlueSky's close() method."""
return self.connection_mgr.close()
Expand Down
135 changes: 19 additions & 116 deletions WebATM/proxy/managers/connection_manager.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
"""Connection management for the BlueSky proxy."""

import gc
import threading
import time

Expand All @@ -26,23 +25,6 @@ def __init__(self, proxy):
"""
self.proxy = proxy

def _ensure_clean_zmq_context(self):
"""Ensure we have a clean environment for ZMQ connections."""
try:
logger.debug("Preparing clean environment for ZMQ connection...")

# Force garbage collection to clean up any lingering references
gc.collect()

# Small delay to ensure cleanup completes
time.sleep(0.2)

logger.info("Environment cleanup complete - ready for new FixedClient")

except Exception as e:
logger.warning(f" Warning during cleanup: {e}")
# Continue anyway - the FixedClient might still work

def _connect_bluesky_client_signals(self):
"""Connect BlueSky client signals to our node-manager handlers."""
client = self.proxy.bluesky_client
Expand Down Expand Up @@ -276,26 +258,12 @@ def _handle_disconnection(self, reason="Unknown"):
# Don't try to reconnect - just stop running and close
self.proxy.running = False
self.proxy.allow_reconnection = False

# Reset connection failure counter
self.proxy.connection_failures = 0

# Clear active node reference immediately to prevent showing corrupted data
if self.proxy.bluesky_client and hasattr(self.proxy.bluesky_client, "act_id"):
self.proxy.bluesky_client.act_id = None

# Clear all tracked nodes and servers immediately
self.proxy.tracked_nodes.clear()
self.proxy.tracked_servers.clear()
# Clear the cached state BEFORE emitting below, so browsers receive
# the disconnected (empty) node/traffic picture, not the stale one.
self.proxy.data_mgr._reset_cached_state()

# Clear all cached data
self.proxy.traffic_data = {}
self.proxy.sim_data = {}
self.proxy.echo_data = {}
self.proxy.poly_data_by_node.clear()
self.proxy.polyline_data_by_node.clear()

# Clear all screen data and emit updates to show disconnected state
if self.proxy.socketio and self.proxy.connected_clients > 0:
try:
# Emit cleared data to remove all aircraft and simulation info from screen
Expand All @@ -307,66 +275,33 @@ def _handle_disconnection(self, reason="Unknown"):
except Exception as e:
logger.warning(f" Error sending disconnection updates: {e}")

# Close connections and clear state (we might reconnect with same client)
# Close the network client's sockets and clear remaining state
self.close()

logger.info("Disconnection cleanup complete - Ready for new connection")
logger.info("Use web interface settings to reconnect to BlueSky server")

def close(self):
"""Close all network connections and clear cached proxy state.
"""Close the network client's sockets and clear cached proxy state.

Mirrors BlueSky's own ``close()``: shuts the network client's sockets
(the ZMQ context is left to the client), resets connection monitoring,
and clears tracked nodes/servers, data caches, emission timestamps,
and the pending command dictionary.
The client instance itself is kept (only ``stop_client`` destroys it);
the app reconnects by creating a fresh ``BlueSkyProxy``, so this only
has to release the sockets and forget the cached server state.
"""
# Disable reconnection first
self.proxy.allow_reconnection = False

# Just close the network client - don't destroy ZMQ context
# The app creates a completely new BlueSkyProxy instance for reconnection
try:
logger.debug(" Closing network client...")
if self.proxy.bluesky_client:
self.proxy.bluesky_client.close()
logger.info(" Network client closed successfully")
except Exception as e:
logger.error(f" Error closing network client: {e}")

# We reuse the same network client instance - just close its sockets

# Reset connection monitoring
self.proxy.was_connected = False
self.proxy.last_successful_update = time.time()

# Clear all tracked state
self.proxy.tracked_nodes.clear()
self.proxy.tracked_servers.clear()

# Clear active node reference to prevent showing corrupted data
if hasattr(self.proxy.bluesky_client, "act_id"):
self.proxy.bluesky_client.act_id = None

# Clear data caches
self.proxy.traffic_data = {}
self.proxy.sim_data = {}
self.proxy.echo_data = {}

# Reset emission timestamps
self.proxy.last_siminfo_emit = 0
self.proxy.last_acdata_emit = 0
self.proxy.last_node_info_emit = 0

# Clear current map bounds
self.proxy.current_bbox = None

# Clear command dictionary
self.proxy.cmddict.clear()

# Following ZMQ pattern: clear client reference after closing
# (new client will be created when reconnecting)
client = self.proxy.bluesky_client
if client is not None:
try:
client.close()
logger.info(" Network client closed successfully")
except Exception as e:
logger.error(f" Error closing network client: {e}")
# Clear the active node so no stale data can be attributed to it.
if hasattr(client, "act_id"):
client.act_id = None

self.proxy.data_mgr._reset_cached_state()
logger.debug(" Client state cleared and connections closed")

def stop_client(self, context="disconnect"):
Expand Down Expand Up @@ -439,35 +374,3 @@ def _close_bluesky_client(self):
# Following ZMQ pattern: destroy client instance after closing sockets
self.proxy.bluesky_client = None
logger.info("Network client instance destroyed")

def reconnect(self, hostname=None):
"""Reconnect to the BlueSky server with fresh ZMQ resources.

Stops the current client, clears state, then starts a new connection
via ``start_client``.

Args:
hostname (str | None): BlueSky server hostname/IP to reconnect
to. When None, the previously configured host is reused.

Raises:
Exception: Propagated from ``start_client`` if reconnection fails.
"""
logger.info("Reconnecting to BlueSky server...")

# Following ZMQ pattern: close sockets and destroy context first
self.stop_client("disconnect")

# Wait briefly for ZMQ cleanup to complete
time.sleep(0.2)

# Clear state and prepare for fresh connection
self.proxy.data_mgr._clear_state()

# Following ZMQ pattern: create fresh context and sockets
try:
self.start_client(hostname=hostname)
logger.info(" Reconnection successful with fresh ZMQ resources")
except Exception as e:
logger.error(f" Reconnection failed: {e}")
raise
34 changes: 19 additions & 15 deletions WebATM/proxy/managers/data_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,41 +122,45 @@ def backup_data_emit(self):
# Schedule next backup emission
self.start_backup_timer()

def _clear_state(self, context="disconnect"):
"""Clear all cached client state after a stop or disconnect.

Args:
context (str): Cleanup context — ``"disconnect"`` for
reconnection, ``"manual"`` for a user-initiated disconnect,
``"shutdown"`` for app termination. Only affects the final
log message.
def _reset_cached_state(self):
"""Reset connection monitoring and drop all cached BlueSky state.

The single implementation of "forget everything we knew about the
server": tracked nodes/servers, data caches, emission throttles, map
bounds and the command dictionary. Shared by every teardown path
(``stop_client``, ``ConnectionManager.close`` and
``_handle_disconnection``) so the paths cannot drift apart.
"""
# Reset connection monitoring
self.proxy.was_connected = False
self.proxy.last_successful_update = time.time()

# Clear all tracked state
self.proxy.tracked_nodes.clear()
self.proxy.tracked_servers.clear()

# Clear data caches
self.proxy.traffic_data = {}
self.proxy.sim_data = {}
self.proxy.echo_data = {}
self.proxy.poly_data_by_node.clear()
self.proxy.polyline_data_by_node.clear()

# Reset emission timestamps
self.proxy.last_siminfo_emit = 0
self.proxy.last_acdata_emit = 0
self.proxy.last_node_info_emit = 0

# Clear current map bounds
self.proxy.current_bbox = None

# Clear command dictionary
self.proxy.cmddict.clear()

def _clear_state(self, context="disconnect"):
"""Clear all cached client state after a stop or disconnect.

Args:
context (str): Cleanup context — ``"disconnect"`` for
reconnection, ``"manual"`` for a user-initiated disconnect,
``"shutdown"`` for app termination. Only affects the final
log message.
"""
self._reset_cached_state()

# Emit updated node info to show disconnection
if self.proxy.socketio and self.proxy.connected_clients > 0:
try:
Expand Down
Loading