Clean up lint/LSP across gateway, Android, and desktop (alpha -> stable)
Gateway (gateway-plugin/): - Fix interactive_setup broken imports: print helpers were imported from the wrong hermes module (hermes_cli.config instead of hermes_cli.cli_output) plus a non-existent print_code; the try/except swallowed the ImportError so `hermes gateway setup` for android always bailed out early. - Fix release_scoped_lock type error (str | None passed where str required). - Rewrite empty `except: pass` blocks as contextlib.suppress with rationale. - Restructure two ambiguous ws_server try blocks (hello-auth, frame loop). - Ruff cleanup: type annotations, import sorting, line wrapping, magic values -> named constants, `raise ... from e`, complexity. Add gateway-plugin/ruff.toml. - Add pyrightconfig.json so the Python LSP resolves hermes-runtime imports. - Suppress verified false positives inline (parameterized SQL, column-name "secrets", hermes-generated media path). Android (app/androidApp + app/shared): - Consolidate launcher icons into a single mipmap-anydpi (minSdk 29 >= 26) with the monochrome layer; clears ObsoleteSdkInt + MonochromeLauncherIcon. - Bump core-splashscreen 1.0.1 -> 1.2.0; pin targetSdk 34 (deliberate). - Suppress verified findings inline (LAN ws:// default, correct GCM IV usage). Desktop (app/desktopApp): - Move the desktop to a Java 21 runtime (org.gradle.java.home) and set the desktop jvmTarget to 21 (Android stays JVM 17 / minSdk 29). Fixes the startup UnsupportedClassVersionError and restores Markdown renderer 0.44.0. Tooling/config: - .pi-lens.json: disable verified-noisy heuristics (documented in docs). - .gitleaks.toml: allowlist git-ignored false-positive paths. - docs/18-code-review.md: full findings + verification. Verified: ruff clean, pyright 0 errors, 64/64 gateway tests, all Kotlin tests, Android lint 0 issues, Android installed+launched on device, desktop launches on JDK 21.
This commit is contained in:
1 parent
9f3f9842c8
commit
678c0344c8
27 files changed
+928
-454
No files matched your search
+51
-47
@@ -24,11 +24,12 @@ Milestone M1.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import ssl
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import Any
|
||||
|
||||
from websockets.asyncio.server import ServerConnection, serve
|
||||
from websockets.exceptions import ConnectionClosed
|
||||
@@ -61,6 +62,9 @@ CLOSE_REPLACED = 4402
|
||||
CLOSE_RATE_LIMITED = 4403
|
||||
CLOSE_SHUTDOWN = 1001
|
||||
|
||||
# Max length of a client-supplied device_id.
|
||||
MAX_DEVICE_ID_LEN = 128
|
||||
|
||||
|
||||
class _TokenBucket:
|
||||
"""Minimal token bucket (stdlib only). One instance per connection."""
|
||||
@@ -93,9 +97,9 @@ class DeviceConnection:
|
||||
device_id: str
|
||||
device_name: str
|
||||
ws: ServerConnection
|
||||
caps: Dict[str, Any] = field(default_factory=dict)
|
||||
fcm_token: Optional[str] = None
|
||||
ntfy_topic: Optional[str] = None
|
||||
caps: dict[str, Any] = field(default_factory=dict)
|
||||
fcm_token: str | None = None
|
||||
ntfy_topic: str | None = None
|
||||
connected_at: float = field(default_factory=time.time)
|
||||
rate_bucket: _TokenBucket = field(
|
||||
default_factory=lambda: _TokenBucket(INBOUND_RATE_PER_S, INBOUND_BURST)
|
||||
@@ -108,8 +112,8 @@ class WsServer:
|
||||
def __init__(self, adapter: Any, devices: DeviceRegistry):
|
||||
self._adapter = adapter
|
||||
self._devices = devices
|
||||
self._server: Optional[Any] = None
|
||||
self._connections: Dict[str, DeviceConnection] = {}
|
||||
self._server: Any | None = None
|
||||
self._connections: dict[str, DeviceConnection] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
# ── Lifecycle ─────────────────────────────────────────────────────────
|
||||
@@ -118,7 +122,7 @@ class WsServer:
|
||||
"""Bind and start serving. Raises on bind failure (adapter maps it
|
||||
to a retryable fatal error)."""
|
||||
adapter = self._adapter
|
||||
ssl_ctx: Optional[ssl.SSLContext] = None
|
||||
ssl_ctx: ssl.SSLContext | None = None
|
||||
if adapter.ws_cert and adapter.ws_key:
|
||||
try:
|
||||
ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
@@ -144,36 +148,38 @@ class WsServer:
|
||||
)
|
||||
except OSError as e:
|
||||
adapter._set_fatal_error(
|
||||
"bind_failed", f"WS bind on {adapter.host}:{adapter.port} failed: {e}",
|
||||
"bind_failed",
|
||||
f"WS bind on {adapter.host}:{adapter.port} failed: {e}",
|
||||
retryable=True,
|
||||
)
|
||||
raise
|
||||
scheme = "wss" if ssl_ctx else "ws"
|
||||
logger.info(
|
||||
"android: WS server listening on %s://%s:%s/ws",
|
||||
scheme, adapter.host, adapter.port,
|
||||
scheme,
|
||||
adapter.host,
|
||||
adapter.port,
|
||||
)
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop serving and close all device sockets."""
|
||||
if self._server is not None:
|
||||
self._server.close()
|
||||
try:
|
||||
# Best-effort: the server is already closing; a failure here is
|
||||
# not actionable (nothing left to clean up besides the registry).
|
||||
with contextlib.suppress(Exception):
|
||||
await self._server.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
self._server = None
|
||||
for conn in list(self._connections.values()):
|
||||
try:
|
||||
# Best-effort: a socket that is already gone needs no handling.
|
||||
with contextlib.suppress(Exception):
|
||||
await conn.ws.close(code=CLOSE_SHUTDOWN, reason="gateway shutting down")
|
||||
except Exception:
|
||||
pass
|
||||
self._connections.clear()
|
||||
|
||||
# ── Registry ──────────────────────────────────────────────────────────
|
||||
|
||||
@property
|
||||
def connections(self) -> Dict[str, DeviceConnection]:
|
||||
def connections(self) -> dict[str, DeviceConnection]:
|
||||
return dict(self._connections)
|
||||
|
||||
def has_devices(self) -> bool:
|
||||
@@ -182,7 +188,7 @@ class WsServer:
|
||||
def device_ids(self) -> list:
|
||||
return list(self._connections.keys())
|
||||
|
||||
def connection(self, device_id: str) -> Optional[DeviceConnection]:
|
||||
def connection(self, device_id: str) -> DeviceConnection | None:
|
||||
return self._connections.get(device_id)
|
||||
|
||||
# ── Outbound ──────────────────────────────────────────────────────────
|
||||
@@ -194,11 +200,12 @@ class WsServer:
|
||||
data = frame.to_json()
|
||||
sent = 0
|
||||
for conn in list(self._connections.values()):
|
||||
try:
|
||||
# Best-effort: a dead or stalled socket is skipped (it is
|
||||
# deregistered on its own close); one slow peer must not starve
|
||||
# the rest of the broadcast.
|
||||
with contextlib.suppress(Exception):
|
||||
await asyncio.wait_for(conn.ws.send(data), timeout=SEND_TIMEOUT_S)
|
||||
sent += 1
|
||||
except Exception:
|
||||
pass
|
||||
return sent
|
||||
|
||||
async def send_to(self, device_id: str, frame: protocol.Frame) -> bool:
|
||||
@@ -218,11 +225,11 @@ class WsServer:
|
||||
# 1. hello auth -----------------------------------------------------
|
||||
try:
|
||||
raw = await asyncio.wait_for(ws.recv(), timeout=HELLO_TIMEOUT_S)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("android: dropping socket with no hello (timeout)")
|
||||
await self._close_quiet(ws, 1000, "no hello")
|
||||
return
|
||||
except ConnectionClosed:
|
||||
except (asyncio.TimeoutError, ConnectionClosed) as e:
|
||||
if isinstance(e, asyncio.TimeoutError):
|
||||
logger.warning("android: dropping socket with no hello (timeout)")
|
||||
await self._close_quiet(ws, 1000, "no hello")
|
||||
# A peer that vanished before hello needs no further handling.
|
||||
return
|
||||
|
||||
frame = protocol.Frame.from_json(raw)
|
||||
@@ -238,7 +245,7 @@ class WsServer:
|
||||
return
|
||||
|
||||
device_id = str(payload.get("device_id") or "").strip()
|
||||
if not device_id or len(device_id) > 128:
|
||||
if not device_id or len(device_id) > MAX_DEVICE_ID_LEN:
|
||||
await self._reject(ws, "device_id required")
|
||||
return
|
||||
|
||||
@@ -281,10 +288,9 @@ class WsServer:
|
||||
self._connections[device_id] = conn
|
||||
if old is not None:
|
||||
# Same device re-paired from a new socket: the new one wins.
|
||||
try:
|
||||
# Best-effort close of the superseded socket.
|
||||
with contextlib.suppress(Exception):
|
||||
await old.ws.close(code=CLOSE_REPLACED, reason="replaced by newer connection")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
ack = protocol.hello_ack(
|
||||
server_caps=self._adapter.server_caps(),
|
||||
@@ -311,10 +317,11 @@ class WsServer:
|
||||
# flood doesn't re-trigger the error+close per frame.
|
||||
if not await self._on_frame(ws, device_id, raw):
|
||||
break
|
||||
except ConnectionClosed:
|
||||
pass
|
||||
except Exception:
|
||||
logger.warning("android: frame loop error for %s", device_id, exc_info=True)
|
||||
except Exception as e:
|
||||
# A clean disconnect (ConnectionClosed) is the normal path and is
|
||||
# not worth a warning; anything else is unexpected.
|
||||
if not isinstance(e, ConnectionClosed):
|
||||
logger.warning("android: frame loop error for %s", device_id, exc_info=True)
|
||||
finally:
|
||||
async with self._lock:
|
||||
current = self._connections.get(device_id)
|
||||
@@ -324,7 +331,9 @@ class WsServer:
|
||||
try:
|
||||
self._adapter.on_connection_closed(device_id)
|
||||
except Exception:
|
||||
logger.warning("android: connection cleanup failed for %s", device_id, exc_info=True)
|
||||
logger.warning(
|
||||
"android: connection cleanup failed for %s", device_id, exc_info=True
|
||||
)
|
||||
logger.info("android: device disconnected: %s", device_id)
|
||||
|
||||
# ── Inbound dispatch ──────────────────────────────────────────────────
|
||||
@@ -346,14 +355,10 @@ class WsServer:
|
||||
# close, same pattern as auth rejection.
|
||||
conn = self._connection_for(ws)
|
||||
if conn is not None and not conn.rate_bucket.consume():
|
||||
logger.warning(
|
||||
"android: inbound rate limit exceeded for %s; closing", device_id
|
||||
)
|
||||
logger.warning("android: inbound rate limit exceeded for %s; closing", device_id)
|
||||
await self._send_quiet(
|
||||
ws,
|
||||
protocol.error(
|
||||
protocol.ERR_RATE_LIMITED, "inbound frame rate limit exceeded"
|
||||
),
|
||||
protocol.error(protocol.ERR_RATE_LIMITED, "inbound frame rate limit exceeded"),
|
||||
)
|
||||
await self._close_quiet(ws, CLOSE_RATE_LIMITED, "rate limited")
|
||||
return False
|
||||
@@ -406,7 +411,7 @@ class WsServer:
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
def _connection_for(self, ws: ServerConnection) -> Optional[DeviceConnection]:
|
||||
def _connection_for(self, ws: ServerConnection) -> DeviceConnection | None:
|
||||
"""The live registry entry for this exact socket (identity match, so
|
||||
a replaced socket never consumes the new connection's bucket)."""
|
||||
for conn in self._connections.values():
|
||||
@@ -415,17 +420,16 @@ class WsServer:
|
||||
return None
|
||||
|
||||
async def _send_quiet(self, ws: ServerConnection, frame: protocol.Frame) -> None:
|
||||
try:
|
||||
# "Quiet" by contract: the caller does not care whether the peer was
|
||||
# still there (e.g. an error frame right before the close).
|
||||
with contextlib.suppress(Exception):
|
||||
await ws.send(frame.to_json())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _reject(self, ws: ServerConnection, reason: str) -> None:
|
||||
await self._send_quiet(ws, protocol.error(protocol.ERR_AUTH, reason))
|
||||
await self._close_quiet(ws, CLOSE_AUTH_FAILED, "auth failed")
|
||||
|
||||
async def _close_quiet(self, ws: ServerConnection, code: int, reason: str) -> None:
|
||||
try:
|
||||
# "Quiet" by contract: closing an already-closed socket is a no-op.
|
||||
with contextlib.suppress(Exception):
|
||||
await ws.close(code=code, reason=reason)
|
||||
except Exception:
|
||||
pass
|
||||
Reference in new issue
Block a user