diff --git a/.github/workflows/scan-codeql.yml b/.github/workflows/scan-codeql.yml index bcf074579..442a07cd5 100644 --- a/.github/workflows/scan-codeql.yml +++ b/.github/workflows/scan-codeql.yml @@ -9,6 +9,9 @@ on: - opened - ready_for_review - reopened + # Without synchronize, pushes to an open PR are never rescanned: the + # last green result keeps showing while the code underneath changes. + - synchronize pull_request_review: types: - submitted diff --git a/.github/workflows/unit-tests-pr.yml b/.github/workflows/unit-tests-pr.yml index cafbed6c4..e5fc72414 100644 --- a/.github/workflows/unit-tests-pr.yml +++ b/.github/workflows/unit-tests-pr.yml @@ -39,6 +39,18 @@ jobs: - run: npm run lint - run: npm run build + backend-lint: + if: (github.actor != 'dependabot[bot]' && github.actor != 'renovate[bot]') + name: Backend lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + - run: pip install ruff==0.15.22 + - run: ruff check + backend-unit-tests: if: (github.actor != 'dependabot[bot]' && github.actor != 'renovate[bot]') runs-on: ubuntu-latest @@ -51,7 +63,14 @@ jobs: - run: pip install -r backend/requirements.txt - run: pip install pytest-cov pytest-xdist - run: pytest tests/unit --cov=backend --cov-report=xml --cov-branch -n auto - - name: SonarCloud Scan - uses: sonarsource/sonarqube-scan-action@v7.0.0 - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + + # Backend coverage.xml previously had exactly one consumer (SonarCloud). + # Codecov measures the actual PR diff rather than a stale branch baseline. + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v5.5.2 + with: + verbose: true + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false + files: coverage.xml + flags: backend diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 49fcba81b..fce03c8e4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,6 +14,12 @@ repos: hooks: - id: autoflake args: ["--remove-all-unused-imports", "--in-place"] + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.22 + hooks: + - id: ruff + args: ["--fix"] + files: ^backend/.*\.py$ # Copied from https://black.readthedocs.io/en/stable/integrations/source_version_control.html - repo: https://github.com/psf/black-pre-commit-mirror rev: 24.4.2 diff --git a/backend/consensus/base.py b/backend/consensus/base.py index d8d20494e..846dd9279 100644 --- a/backend/consensus/base.py +++ b/backend/consensus/base.py @@ -1134,6 +1134,7 @@ async def process_validator_appeal( "Appeal failed, no validators found to process the appeal", { "transaction_hash": context.transaction.hash, + "error": str(e), }, transaction_hash=context.transaction.hash, ) diff --git a/backend/consensus/worker_service.py b/backend/consensus/worker_service.py index 97d9e743f..6b41ea5e9 100644 --- a/backend/consensus/worker_service.py +++ b/backend/consensus/worker_service.py @@ -214,7 +214,9 @@ def handle_signal(sig, frame): # CRITICAL: Kill any orphaned GenVM processes from previous crashes # These zombie processes can consume gigabytes of memory outside Docker limits logger.info("Cleaning up orphaned GenVM processes from previous crashes...") - _pkill_rc = os.system("pkill -9 -f 'genvm (llm|web)' 2>/dev/null || true") + _pkill_rc = os.system( # noqa: ASYNC221 - one-shot startup orphan cleanup + "pkill -9 -f 'genvm (llm|web)' 2>/dev/null || true" + ) logger.info("GenVM cleanup complete") # Database setup @@ -479,7 +481,9 @@ async def run_worker_with_auto_restart(): # Final safety check: Kill any remaining genvm processes logger.info("Final cleanup: killing any remaining GenVM processes...") - os.system("pkill -9 -f 'genvm (llm|web)' 2>/dev/null || true") + os.system( # noqa: ASYNC221 - one-shot shutdown orphan cleanup + "pkill -9 -f 'genvm (llm|web)' 2>/dev/null || true" + ) logger.info("GenVM cleanup complete") print("Consensus Worker Service stopped") diff --git a/backend/database_handler/snapshot_manager.py b/backend/database_handler/snapshot_manager.py index f610abe62..9d22a3df5 100644 --- a/backend/database_handler/snapshot_manager.py +++ b/backend/database_handler/snapshot_manager.py @@ -69,7 +69,6 @@ def create_snapshot(self) -> Snapshot: "appealed": tx.appealed, "appeal_undetermined": tx.appeal_undetermined, "triggered_by_hash": tx.triggered_by_hash, - "appealed": tx.appealed, "timestamp_awaiting_finalization": tx.timestamp_awaiting_finalization, "num_of_initial_validators": tx.num_of_initial_validators, "last_vote_timestamp": tx.last_vote_timestamp, diff --git a/backend/node/base.py b/backend/node/base.py index b40dd2980..34e1e2052 100644 --- a/backend/node/base.py +++ b/backend/node/base.py @@ -472,10 +472,10 @@ async def stop_module(self, module_type: typing.Literal["llm", "web"]): body = await resp.json() if resp.status != 200: self.logger.error( - f"Failed to stop LLM module", body=body, status=resp.status + "Failed to stop LLM module", body=body, status=resp.status ) else: - self.logger.info(f"Stopped LLM module", body=body, status=resp.status) + self.logger.info("Stopped LLM module", body=body, status=resp.status) async def start_module( self, @@ -490,7 +490,7 @@ async def start_module( body = await resp.json() if resp.status != 200: self.logger.error( - f"Failed to start module", + "Failed to start module", module=module_type, body=body, status=resp.status, @@ -518,9 +518,7 @@ async def try_llms( async with aiohttp.request("POST", f"{self.url}/llm/check", json=data) as resp: body = await resp.json() if resp.status != 200: - self.logger.error( - f"Failed to check llms", body=body, status=resp.status - ) + self.logger.error("Failed to check llms", body=body, status=resp.status) # Return error response for each config when the check fails return [ { @@ -976,7 +974,7 @@ async def _run_genvm( transaction_hash: str | None = None, transaction_datetime: datetime.datetime | None, state_status: str | None = None, - timeout: float = 10 * 60, + timeout: float = 10 * 60, # noqa: ASYNC109 - forwarded GenVM deadline code: bytes | None = None, value: int = 0, origin_address: str | None = None, diff --git a/backend/node/genvm/base.py b/backend/node/genvm/base.py index f2593d511..695ba84dd 100644 --- a/backend/node/genvm/base.py +++ b/backend/node/genvm/base.py @@ -732,7 +732,7 @@ def _leader_results_to_list( async def run_genvm_host( host_supplier: typing.Callable[[socket.socket], Host], *, - timeout: float, + timeout: float, # noqa: ASYNC109 - retry budget spans multiple awaits manager_uri: str = "http://127.0.0.1:3999", logger: genvm_logger.Logger | None = None, is_sync: bool, @@ -859,7 +859,7 @@ async def run_genvm_host( raise except Exception as e: logger.error( - f"GenVM execution attempt failed", + "GenVM execution attempt failed", error=e, retry_count=retry_count, ) diff --git a/backend/node/genvm/origin/base_host.py b/backend/node/genvm/origin/base_host.py index 6d347a4d0..02ee6d645 100644 --- a/backend/node/genvm/origin/base_host.py +++ b/backend/node/genvm/origin/base_host.py @@ -488,7 +488,7 @@ async def _await_first_cancel_others(*it): async def run_genvm( handler: IHost, *, - timeout: float | None = None, + timeout: float | None = None, # noqa: ASYNC109 - drives a process timeout task manager_uri: str = "http://127.0.0.1:3999", ctx: Context, is_sync: bool, diff --git a/backend/protocol_rpc/app_lifespan.py b/backend/protocol_rpc/app_lifespan.py index 08504c228..1d3d6cf17 100644 --- a/backend/protocol_rpc/app_lifespan.py +++ b/backend/protocol_rpc/app_lifespan.py @@ -408,7 +408,7 @@ def get_session() -> Session: # Register handler for validator change events async def handle_validator_change(event_data): """Reload validators when they change.""" - logger.info(f"RPC worker reloading validators due to change event") + logger.info("RPC worker reloading validators due to change event") await validators_manager.restart() redis_subscriber.register_handler("validator_created", handle_validator_change) diff --git a/backend/protocol_rpc/endpoints.py b/backend/protocol_rpc/endpoints.py index a66701dfa..c150d4830 100644 --- a/backend/protocol_rpc/endpoints.py +++ b/backend/protocol_rpc/endpoints.py @@ -469,20 +469,20 @@ async def check_provider_is_available( if len(res) != 1: genvm_manager.logger.error( - f"LLM provider check failed", provider=provider, result=res + "LLM provider check failed", provider=provider, result=res ) return False res = res[0] if (text_response := res.get("response")) is None: genvm_manager.logger.error( - f"LLM provider check failed", provider=provider, result=res + "LLM provider check failed", provider=provider, result=res ) return False what_returned = text_response.strip().lower() if what_returned != "ok": genvm_manager.logger.error( - f"LLM provider check failed", provider=provider, text_response=text_response + "LLM provider check failed", provider=provider, text_response=text_response ) return False return True diff --git a/backend/protocol_rpc/message_handler/base.py b/backend/protocol_rpc/message_handler/base.py index 1d2ed5eec..cba510c0e 100644 --- a/backend/protocol_rpc/message_handler/base.py +++ b/backend/protocol_rpc/message_handler/base.py @@ -1,3 +1,4 @@ +import asyncio import os import json import copy @@ -47,8 +48,23 @@ def __init__(self, socketio: SocketIO, config: GlobalConfiguration): self.socketio = socketio self.config = config self.client_session_id = None + self._pending_tasks: set[asyncio.Task] = set() # Logging is configured at app startup + def _track_background_task(self, task: asyncio.Task) -> None: + """Retain a background task and report failures until it completes.""" + self._pending_tasks.add(task) + task.add_done_callback(self._background_task_done) + + def _background_task_done(self, task: asyncio.Task) -> None: + self._pending_tasks.discard(task) + if task.cancelled(): + return + + exception = task.exception() + if exception is not None: + logger.opt(exception=exception).error("Background message delivery failed") + def with_client_session(self, client_session_id: str): new_msg_handler = MessageHandler(self.socketio, self.config) new_msg_handler.client_session_id = client_session_id @@ -317,7 +333,6 @@ def setup_loguru_config(): # Get log level from environment log_level = os.environ.get("LOG_LEVEL", "INFO").upper() - logging_env = os.environ.get("LOGCONFIG", "dev") # Console handler with colors logger.add( diff --git a/backend/protocol_rpc/message_handler/fastapi_handler.py b/backend/protocol_rpc/message_handler/fastapi_handler.py index 5e0af271d..8ac632ea2 100644 --- a/backend/protocol_rpc/message_handler/fastapi_handler.py +++ b/backend/protocol_rpc/message_handler/fastapi_handler.py @@ -30,9 +30,25 @@ def __init__(self, broadcast: Broadcast, config: GlobalConfiguration): self.broadcast = broadcast self.config = config self.client_session_id = None + self._pending_tasks: set[asyncio.Task] = set() + + def _track_background_task(self, task: asyncio.Task) -> None: + """Retain a background task and report failures until it completes.""" + self._pending_tasks.add(task) + task.add_done_callback(self._background_task_done) + + def _background_task_done(self, task: asyncio.Task) -> None: + self._pending_tasks.discard(task) + if task.cancelled(): + return + + exception = task.exception() + if exception is not None: + logger.opt(exception=exception).error("Background broadcast publish failed") def with_client_session(self, client_session_id: str): new_msg_handler = MessageHandler(self.broadcast, self.config) + new_msg_handler._pending_tasks = self._pending_tasks new_msg_handler.client_session_id = client_session_id return new_msg_handler @@ -83,7 +99,10 @@ def _publish(self, channel: str, payload: dict[str, Any]) -> None: if not loop.is_running(): return - loop.create_task(self.broadcast.publish(channel=channel, message=message)) + task = loop.create_task( + self.broadcast.publish(channel=channel, message=message) + ) + self._track_background_task(task) def _socket_emit(self, log_event: LogEvent) -> None: """Emit a log event via broadcast channels. diff --git a/backend/protocol_rpc/message_handler/redis_worker_handler.py b/backend/protocol_rpc/message_handler/redis_worker_handler.py index c9551eea2..8aafcfa0d 100644 --- a/backend/protocol_rpc/message_handler/redis_worker_handler.py +++ b/backend/protocol_rpc/message_handler/redis_worker_handler.py @@ -182,7 +182,8 @@ def _socket_emit(self, log_event: LogEvent): loop = asyncio.get_event_loop() if loop.is_running(): # Schedule the async send operation - asyncio.create_task(self._publish_to_redis(log_event)) + task = asyncio.create_task(self._publish_to_redis(log_event)) + self._track_background_task(task) else: # If no loop is running, run it synchronously asyncio.run(self._publish_to_redis(log_event)) diff --git a/backend/protocol_rpc/message_handler/worker_handler.py b/backend/protocol_rpc/message_handler/worker_handler.py index cd24a20b7..d78ca810b 100644 --- a/backend/protocol_rpc/message_handler/worker_handler.py +++ b/backend/protocol_rpc/message_handler/worker_handler.py @@ -114,7 +114,8 @@ def _socket_emit(self, log_event: LogEvent): loop = asyncio.get_event_loop() if loop.is_running(): # Schedule the async send operation - asyncio.create_task(self._send_event_to_server(log_event)) + task = asyncio.create_task(self._send_event_to_server(log_event)) + self._track_background_task(task) else: # If no loop is running, try to run it synchronously asyncio.run(self._send_event_to_server(log_event)) diff --git a/backend/protocol_rpc/validators_init.py b/backend/protocol_rpc/validators_init.py index 1f012af45..8bbab783e 100644 --- a/backend/protocol_rpc/validators_init.py +++ b/backend/protocol_rpc/validators_init.py @@ -92,6 +92,7 @@ async def initialize_validators( desired_hash = _desired_config_hash(validators_json) except (TypeError, KeyError) as e: # Bad config fields — fall through to the creation loop which gives a better error + logger.warning(f"Unable to hash validators config; validation will report: {e}") desired_hash = None current_hash = _current_config_hash(validators_manager.registry) diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 000000000..e0f6748bc --- /dev/null +++ b/ruff.toml @@ -0,0 +1,8 @@ +target-version = "py312" +include = ["backend/**/*.py"] +extend-exclude = ["backend/node/genvm/origin/keccak.py"] + +[lint] +select = ["E9", "F", "ASYNC", "RUF006"] +# Star-import cleanup requires restructuring and is tracked separately. +ignore = ["F403", "F405"] diff --git a/sonar-project.properties b/sonar-project.properties deleted file mode 100644 index 2b55f0f75..000000000 --- a/sonar-project.properties +++ /dev/null @@ -1,20 +0,0 @@ -sonar.organization=yeagerai -sonar.projectKey=yeagerai_genlayer-simulator - -# relative paths to source directories. More details and properties are described -# in https://sonarcloud.io/documentation/project-administration/narrowing-the-focus/ -sonar.sources=. -# The legacy synced E2E consumer workflow delegates to the central private -# genlayer-e2e workflows. Keep Sonar focused on Studio-owned code; the central -# E2E workflow is scanned in genlayer-e2e. -sonar.exclusions=tests/**,frontend/test/**/*,.github/workflows/e2e.yml -sonar.python.coverage.reportPaths=coverage.xml -# The Sonar scan runs in the backend-unit-tests job and only receives Python -# coverage.xml. Frontend coverage is produced in a separate job and uploaded to -# Codecov, so do not count frontend/explorer sources as uncovered in Sonar. -# examples/contracts are GenVM intelligent-contract fixtures that execute -# inside the GenVM, never under the backend test runner - same rationale. -sonar.coverage.exclusions=frontend/**,explorer/**,examples/contracts/**,backend/database_handler/migration/versions/b8c9d0e1f2a3_add_terminal_snapshot_archive_candidate_index.py,backend/database_handler/migration/versions/c9d0e1f2a3b4_add_transaction_snapshot_archives.py -# These files mirror the same Studio RPC fee response contracts for separate UI -# apps. Keep CPD focused on executable code until the repo has shared TS types. -sonar.cpd.exclusions=frontend/src/types/responses.ts,explorer/src/lib/types.ts