Skip to content

feat: multi worker support - #237

Merged
M03ED merged 49 commits into
devfrom
nats-io
Feb 2, 2026
Merged

M03ED merged 49 commits into
devfrom
nats-io

Conversation

@M03ED

@M03ED M03ED commented Feb 2, 2026

Copy link
Copy Markdown
Contributor

Summary by CodeRabbit

  • New Features

    • Multi-worker mode with inter-worker messaging and role-based modes (panel, node-worker, scheduler)
    • Unified notification dispatcher with pluggable queue backend (NATS or in-memory)
    • Health check HTTP endpoint and container healthcheck script
    • Node worker and scheduler worker processes for background tasks
    • App factory for role-specific app creation and lifecycle management
  • Chores

    • New configuration flags for workers, NATS, scheduler and runtime behavior
    • Docker Compose multi-service setup and improved container startup scripts

ImMohammad20000 and others added 30 commits December 8, 2025 20:49
…istributed cache & notification queue + separate scheduler process
format files

fix

fix

fix

fix

fix

fix

fix tests

fix

fix

fix

fix

fix

fix

fix

fix

fix

try to fix tests

fix test

remove: redis files

fix

fix

fix

fix

fix

fix: path

fix: database waits in tests
@coderabbitai

coderabbitai Bot commented Feb 2, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

  • 🔍 Trigger a full review

Walkthrough

Adds multi-worker support with NATS/JetStream KV, a role-aware FastAPI app factory (panel/node-worker/scheduler), NATS-based RPC/router/client, node worker service, pluggable notification queues, scheduler/jobstore support, lifecycle registries, many job gating changes, deployment scripts, and CI adjustments.

Changes

Cohort / File(s) Summary
Config & Env
\.env.example, config.py
New flags (UVICORN_WORKERS, RUN_SCHEDULER, NODE_ROLE, IS_NODE_WORKER, MULTI_WORKER, NATS_*, SCHEDULER_JOBSTORE_URL, STOP_NODES_ON_SHUTDOWN), origin/feature flags and template/config constants; parsing/derivation logic updated.
App factory & lifecycle
app/__init__.py, app/app_factory.py, app/lifecycle.py, app/version.py
Introduce create_app(role), lifespan/on_startup/on_shutdown registries, move app construction to factory, expose version.
NATS infra
app/nats/...
app/nats/__init__.py, client.py, message.py, node_rpc.py, proto_utils.py, router.py
New NATS helpers: enable check, client/JetStream/KV setup, message types, proto (de)serialization, router with topic handlers, node RPC client and singleton.
State sync & core hosts
app/core/hosts.py, app/core/manager.py, app/core/abstract_core.py, app/core/xray.py
Add KV-backed snapshot/persist/load, NATS publish/subscribe hooks, host/core state caching, and JSON (de)serialize methods on core types.
Node subsystem & worker
app/node/...
app/node/__init__.py, sync.py, user.py, worker.py, node_worker.py
New NodeWorkerService (command + RPC handlers), sync_user/sync_users with local vs NATS paths, protobuf-based user APIs, node_worker entrypoint script.
Scheduler & jobs
app/scheduler.py, app/jobs/*, scheduler_worker.py
APScheduler AsyncIOScheduler jobstore, many jobs gated by RUN_SCHEDULER/IS_NODE_WORKER/MULTI_WORKER, explicit job ids + replace_existing, role-aware worker/scheduler runner script.
Notifications & queueing
app/notification/client.py, app/notification/queue_manager.py
Pluggable NotificationQueue (NATS or in-memory), unified dispatcher, lifecycle init/shutdown, type-tagged notification models, changed processing loop.
Telegram bot
app/telegram/__init__.py
TelegramBotManager singleton replacing module globals; guarded start/shutdown and sync_from_settings lifecycle.
Operations & routing
app/operation/*, app/routers/*, app/settings/__init__.py
Node/user operations delegated to sync/NATS paths, RPC error handling helper, settings refresh publishes NATS setting refresh, /health endpoint, node log streaming split local/remote.
Deployment & CI
Dockerfile, docker-compose.multi.yml, healthcheck.sh, .github/workflows/*, pyproject.toml, start.sh, main.py
Add curl and healthcheck script, multi-service docker-compose (nats/panel/node-worker/scheduler), role-aware start script, CI expanded multi-DB workflows, new runtime deps (nats-py, psycopg, pymysql).
Tests & helpers
tests/api/*, tests/db/crud/admin.py
Tests use create_app(role="panel"); helpers poll for inbounds when multi-worker/NATS enabled; small test adjustments.
Misc infra
app/notification/*, app/operation/*, others...`
Wide-reaching refactors: scheduler/job registration patterns, lifecycle wiring, and NATS integration across many modules — review state persistence, RPC error mappings, and job gating.

Sequence Diagram(s)

sequenceDiagram
    autonumber
    participant API as Panel API
    participant Router as NATS Router
    participant KV as JetStream KV
    participant Scheduler as Scheduler
    participant NodeWorker as NodeWorker

    API->>Router: publish Message(topic=CORE/SETTING, data)
    Router->>NodeWorker: deliver message to handler
    Router->>Scheduler: deliver message to handler
    Scheduler->>KV: persist snapshot
    NodeWorker->>KV: persist or request state (RPC)
    NodeWorker->>NodeWorker: handle command / RPC, call local handlers
Loading
sequenceDiagram
    autonumber
    participant Caller as Caller
    participant Op as NodeOperation
    participant NatsClient as node_nats_client
    participant Remote as Remote NodeWorker
    participant Local as LocalImpl

    Caller->>Op: update_node(node_id)
    Op->>Op: check IS_NODE_WORKER
    alt Running as node-worker
        Op->>Local: _update_node_local(node_id)
        Local-->>Op: result
    else
        Op->>NatsClient: request("update_node", payload, timeout)
        NatsClient->>Remote: RPC via NATS
        Remote-->>NatsClient: response
        NatsClient-->>Op: response
    end
    Op-->>Caller: return result
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Poem

🐰 I hopped through topics, queues in tow,

NATS bells ringing, jobs in a row.
Panel, Scheduler, Node all align,
KV keeps secrets safe and fine.
I twitched — the system hums, ready to go!

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 22.92% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: multi worker support' directly and clearly summarizes the primary change—addition of multi-worker functionality—as evidenced by extensive NATS integration, worker role configuration, and distributed state management changes throughout the codebase.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch nats-io

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@M03ED

M03ED commented Feb 2, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Feb 2, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 6

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

🤖 Fix all issues with AI agents
In `@app/core/hosts.py`:
- Around line 278-291: Replace insecure pickle serialization in
_persist_state/_snapshot_state with JSON-based serialization using the
SubscriptionInboundData Pydantic methods: when creating state bytes for
STATE_CACHE_KEY, convert each SubscriptionInboundData instance to JSON via
model_dump_json() (or use model_dump then json.dumps for nested dicts), store
the resulting UTF-8 bytes, and update loading logic elsewhere to reconstruct
objects with SubscriptionInboundData.model_validate_json() (or model_validate
after json.loads). Ensure all places that read from the KV use the new JSON
deserialization path and handle older pickle data gracefully (e.g., detect
pickle vs JSON and reject/ignore pickle) and keep references to _persist_state,
_snapshot_state, STATE_CACHE_KEY, and SubscriptionInboundData when making
changes.

In `@app/core/manager.py`:
- Line 1: The code currently imports and uses pickle for (de)serializing core
state (e.g., calls to pickle.loads/pickle.dumps when reading/writing the NATS KV
bucket) which is unsafe; replace pickle serialization with a safe format (JSON
or similar) by removing pickle usage and implementing to_json/from_json (or
serialize/deserialize) methods on the AbstractCore class hierarchy so each core
can produce a JSON-serializable dict and be reconstructed from it, then update
the KV read/write logic to call those methods (use json.dumps/json.loads)
instead of pickle.loads/pickle.dumps, and validate the deserialized shape before
constructing objects to avoid executing arbitrary code.

In `@app/jobs/reset_node_usage.py`:
- Around line 39-41: The call to the async coroutine connect_single_node is
incorrect: change the invocation inside the NodeStatus.connecting branch to
await the coroutine and pass the DB and node id parameters expected by the
signature; specifically replace the unawaited
node_operator.connect_single_node(node) with await
node_operator.connect_single_node(db, node.id) so the db from the enclosing
scope is used and the coroutine is awaited.

In `@app/lifecycle.py`:
- Around line 19-46: The lifespan function currently uses
func.__code__.co_varnames to detect an "app" parameter which can mis-detect
locals and fails for keyword-only params; update the logic in lifespan (handling
both startup_functions and shutdown_functions) to inspect each callable via
inspect.signature(func) and check for a parameter named "app" in the
signature.parameters mapping, then call the function with app passed as a
keyword argument (e.g., await func(app=app) or func(app=app)) when the parameter
exists, otherwise call with no args; preserve the existing coroutine vs sync
branching (asyncio.iscoroutinefunction) and ensure all calls use keyword passing
to avoid positional/kw-only issues.

In `@app/node/worker.py`:
- Around line 303-325: In _stream_logs, avoid leaking and calling result() on
cancelled tasks by creating explicit tasks for log_queue.get() and
stop_event.wait(), awaiting asyncio.wait(..., return_when=FIRST_COMPLETED), then
cancel any pending tasks and await them (suppressing asyncio.CancelledError via
contextlib.suppress) before accessing results; only call .result() on the task
that corresponds to log_queue.get() (handle the case where stop_event finished
first by breaking without reading result), and ensure you add "import
contextlib" at the top of the file so you can suppress cancelled-task exceptions
when cleaning up.

In `@node_worker.py`:
- Around line 20-29: The main() function currently only catches
KeyboardInterrupt and therefore won't exit cleanly on SIGTERM; modify main() to
register a SIGTERM handler that sets an asyncio.Event (e.g., shutdown_event) or
calls loop.stop so the while True sleep loop can break and allow async with
lifespan(app) to run shutdown hooks; use
asyncio.get_running_loop().add_signal_handler(signal.SIGTERM,
shutdown_event.set) (and also handle SIGINT similarly) and change the loop to
await shutdown_event.wait() (or check the event inside the sleep loop) so both
SIGTERM and KeyboardInterrupt trigger a graceful shutdown via lifespan(app) and
logger warnings remain unchanged.
🟡 Minor comments (16)
.github/workflows/test-database-migrations.yml-265-293 (1)

265-293: ⚠️ Potential issue | 🟡 Minor

Avoid logging DB passwords in CI output.

params_native includes the password; redact it before printing to logs.

Proposed fix
-                  print("Connecting with params:", params_native)
+                  safe_params = dict(params_native)
+                  if "password" in safe_params:
+                      safe_params["password"] = "***"
+                  print("Connecting with params:", safe_params)
                   try:
                       conn = pymysql.connect(**params_native)
                   except Exception as exc:
-                      print("FAILED to connect with params:", params_native)
+                      print("FAILED to connect with params:", safe_params)
                       raise
.github/workflows/test-database-migrations.yml-24-28 (1)

24-28: ⚠️ Potential issue | 🟡 Minor

Quote $GITHUB_ENV and group the writes.

Shellcheck flags SC2086/SC2129 here; grouping the echos and quoting the env file avoids word-splitting and repeated redirects. Apply the same pattern to the other “Set DATABASE_URL” steps.

Proposed fix
-                  echo "TEST_FROM=github" >> $GITHUB_ENV
-                  echo "TESTING=1" >> $GITHUB_ENV
-                  echo "SQLALCHEMY_DATABASE_URL=sqlite+aiosqlite:///./test.db" >> $GITHUB_ENV
+                  {
+                    echo "TEST_FROM=github"
+                    echo "TESTING=1"
+                    echo "SQLALCHEMY_DATABASE_URL=sqlite+aiosqlite:///./test.db"
+                  } >> "$GITHUB_ENV"
healthcheck.sh-15-41 (1)

15-41: ⚠️ Potential issue | 🟡 Minor

Add a curl timeout to avoid hanging healthchecks.
A stalled connection can block the script until Docker’s timeout; add a short, configurable timeout to keep probes responsive.

Suggested fix
-DEBUG=${DEBUG:-false}
+DEBUG=${DEBUG:-false}
+TIMEOUT=${HEALTHCHECK_TIMEOUT:-5}
@@
-    local curl_flags="-sf"
+    local curl_flags="-sf --max-time $TIMEOUT"
@@
-    curl -sf --unix-socket "$socket_path" "http://localhost/health" 2>/dev/null
+    curl -sf --max-time "$TIMEOUT" --unix-socket "$socket_path" "http://localhost/health" 2>/dev/null
config.py-128-129 (1)

128-129: ⚠️ Potential issue | 🟡 Minor

Typo in comment.

"featueres" should be "features".

-## Experimental featueres
+## Experimental features
app/notification/queue_manager.py-100-106 (1)

100-106: ⚠️ Potential issue | 🟡 Minor

timeout=0 will cause indefinite blocking.

The condition if timeout: evaluates to False when timeout=0, causing the method to fall through to await self.q.get() which blocks indefinitely. If timeout=0 is intended to mean "return immediately if empty", this is a bug.

🐛 Proposed fix
     async def dequeue(self, timeout: int | None = None):
-        if timeout:
+        if timeout is not None:
             try:
                 return await asyncio.wait_for(self.q.get(), timeout=timeout)
             except asyncio.TimeoutError:
                 return None
         return await self.q.get()
app/telegram/__init__.py-122-127 (1)

122-127: ⚠️ Potential issue | 🟡 Minor

Cancelled polling task should be awaited for clean shutdown.

After cancelling _polling_task, the code doesn't await it. This can leave the task in a pending state and may cause "Task was destroyed but it is pending" warnings.

🐛 Proposed fix
                     if self._polling_task is not None and not self._polling_task.done():
                         logger.info("stopping long polling")
                         # Force stop the dispatcher first
                         await self._dp.stop_polling()
                         # Cancel the polling task
                         self._polling_task.cancel()
+                        try:
+                            await self._polling_task
+                        except asyncio.CancelledError:
+                            pass
start.sh-3-8 (1)

3-8: ⚠️ Potential issue | 🟡 Minor

Boolean environment variables from docker-compose won't be recognized in start.sh.
The docker-compose files set RUN_SCHEDULER: True and RUN_SCHEDULER: False, but the shell script checks [ "${RUN_SCHEDULER:-0}" = 1 ], which only matches the literal string "1". When Docker Compose passes these as environment variables, they become the strings "True" or "False", causing the conditions to fail and fall back to default behavior.

♻️ Suggested normalization
 ROLE="${NODE_ROLE:-panel}"
 
-if [ "${ROLE}" = "node-worker" ] || [ "${IS_NODE_WORKER:-0}" = 1 ]; then
+is_truthy() {
+    case "${1,,}" in
+        1|true|yes|y|on) return 0 ;;
+        *) return 1 ;;
+    esac
+}
+
+if [ "${ROLE}" = "node-worker" ] || is_truthy "${IS_NODE_WORKER:-0}"; then
     python node_worker.py
-elif [ "${ROLE}" = "scheduler" ] || [ "${RUN_SCHEDULER:-0}" = 1 ]; then
+elif [ "${ROLE}" = "scheduler" ] || is_truthy "${RUN_SCHEDULER:-0}"; then
     python scheduler_worker.py
 else
app/operation/__init__.py-51-55 (1)

51-55: ⚠️ Potential issue | 🟡 Minor

Guard against non‑int RPC codes.

If exc.code is None or a non‑int, raise_error will hit a TypeError when comparing code <= 0. Coerce defensively and fall back to 500.

🛡️ Suggested fix
 async def handle_rpc_error(self, exc: RuntimeError):
     """Convert NATS RPC errors to appropriate HTTP responses."""
-    code = getattr(exc, "code", 500)
-    await self.raise_error(message=str(exc), code=code)
+    raw_code = getattr(exc, "code", None)
+    try:
+        code = int(raw_code) if raw_code is not None else 500
+    except (TypeError, ValueError):
+        code = 500
+    await self.raise_error(message=str(exc), code=code)
scheduler_worker.py-15-24 (1)

15-24: ⚠️ Potential issue | 🟡 Minor

Make the NATS-disabled warning reachable (or remove it).

create_app(role="scheduler") runs at import time, so if NATS is disabled it raises before main() logs the warning or enters lifespan handling. Move app creation into main() (or drop the warning) to keep behavior consistent.

🔧 Proposed adjustment
- app = create_app(role="scheduler")
-
 async def main():
     if not is_nats_enabled():
         logger.warning(
             "NATS is disabled; notification dispatching will only work when the scheduler shares a process with the API."
         )
 
+    app = create_app(role="scheduler")
     async with lifespan(app):
         try:
             while True:
                 await asyncio.sleep(3600)
         except KeyboardInterrupt:
             pass
tests/api/helpers.py-87-116 (1)

87-116: ⚠️ Potential issue | 🟡 Minor

Fail fast when inbounds never appear during polling.

When _WAIT_FOR_INBOUNDS is true, _poll() can return an empty list after retries, which may let tests proceed with invalid state and become flaky. Consider raising an assertion on timeout to surface readiness failures explicitly.

✅ Suggested assertion on timeout
     def _poll() -> list[str]:
         last_data: list[str] = []
         for _ in range(_INBOUNDS_RETRIES):
             code, data = _fetch()
             if code == status.HTTP_200_OK:
                 last_data = data
                 if last_data:
                     return last_data
             time.sleep(_INBOUNDS_DELAY_SEC)
-        return last_data
+        if _WAIT_FOR_INBOUNDS and not last_data:
+            raise AssertionError("Timed out waiting for /api/inbounds to be populated")
+        return last_data
main.py-27-33 (1)

27-33: ⚠️ Potential issue | 🟡 Minor

Type mismatch: require_nats_if_multiworker expects a boolean, but receives an integer.

The function signature from app/nats/__init__.py shows require_nats_if_multiworker(multi_worker: bool), but here you're passing workers (an int). The function checks if multi_worker and not is_nats_enabled(), which will evaluate truthy for any workers > 0, but this is semantically incorrect for single-worker mode.

🐛 Proposed fix
-require_nats_if_multiworker(workers)
+require_nats_if_multiworker(workers > 1)
app/operation/node.py-349-351 (1)

349-351: ⚠️ Potential issue | 🟡 Minor

Duplicate definition of _connect_single_node_remote.

The method _connect_single_node_remote is defined twice: at lines 349-350 and again at lines 613-614. Both have identical implementations. Remove the duplicate.

Proposed fix - remove duplicate at lines 349-351
-    async def _connect_single_node_remote(self, db: AsyncSession, node_id: int) -> None:
-        await node_nats_client.publish("connect_node", {"node_id": node_id})
-
     async def disconnect_single_node(self, node_id: int) -> None:

Also applies to: 613-614

app/operation/node.py-556-557 (1)

556-557: ⚠️ Potential issue | 🟡 Minor

Inconsistent user count calculation for max_message_size.

In _connect_single_node_local, max_message_size is calculated using len(users) (line 557), but in _connect_nodes_bulk_local, it uses get_users_count_by_status (lines 481-483). The users list from core_users() may not equal the count of active users from the database query, causing inconsistent sizing.

Consider using consistent logic across both methods, preferably the explicit get_users_count_by_status approach for accuracy.

app/operation/node.py-818-822 (1)

818-822: ⚠️ Potential issue | 🟡 Minor

Missing error handling in _update_core_remote.

Unlike other remote methods (e.g., _update_node_api_remote at line 802-806), _update_core_remote doesn't wrap the RPC call in try/except with handle_rpc_error. This could cause unhandled RuntimeError to propagate.

Proposed fix
     async def _update_core_remote(self, node_id: int, node_core_update: NodeCoreUpdate) -> dict:
-        return await node_nats_client.request(
-            "update_core",
-            {"node_id": node_id, "core_update": node_core_update.model_dump(mode="json")},
-        )
+        try:
+            return await node_nats_client.request(
+                "update_core",
+                {"node_id": node_id, "core_update": node_core_update.model_dump(mode="json")},
+            )
+        except RuntimeError as exc:
+            await self.handle_rpc_error(exc)
app/operation/node.py-834-838 (1)

834-838: ⚠️ Potential issue | 🟡 Minor

Missing error handling in _update_geofiles_remote.

Same issue as _update_core_remote - the method lacks try/except error handling for RPC failures.

Proposed fix
     async def _update_geofiles_remote(self, node_id: int, node_geofiles_update: NodeGeoFilesUpdate) -> dict:
-        return await node_nats_client.request(
-            "update_geofiles",
-            {"node_id": node_id, "geofiles_update": node_geofiles_update.model_dump(mode="json")},
-        )
+        try:
+            return await node_nats_client.request(
+                "update_geofiles",
+                {"node_id": node_id, "geofiles_update": node_geofiles_update.model_dump(mode="json")},
+            )
+        except RuntimeError as exc:
+            await self.handle_rpc_error(exc)
app/node/worker.py-298-301 (1)

298-301: ⚠️ Potential issue | 🟡 Minor

Stop subscription is never unsubscribed.

The subscription created for stop_subject at line 301 is never cleaned up. This could lead to subscription leaks if many log streams are started/stopped.

Proposed fix - store and unsubscribe
+        stop_sub = await self._nc.subscribe(stop_subject, cb=_stop_cb)

         async def _stream_logs():
             try:
                 # ... existing code ...
             finally:
+                await stop_sub.unsubscribe()
                 self._stop_events.pop(log_subject, None)
                 self._log_tasks.pop(log_subject, None)
🧹 Nitpick comments (32)
dashboard/__init__.py (1)

57-63: Potential duplicate startup registration if setup_dashboard is called multiple times.

The @on_startup decorator is applied inside setup_dashboard, so each call to setup_dashboard(app) will register a new run_dashboard function to startup_functions. If setup_dashboard is invoked more than once (e.g., during tests or reloads), multiple handlers will accumulate.

Consider moving the decorator outside or adding a guard:

♻️ Suggested refactor
-def setup_dashboard(app):
-    `@on_startup`
-    def run_dashboard():
-        if DEBUG:
-            run_dev()
-        else:
-            run_build(app)
+_dashboard_app = None
+
+@on_startup
+def run_dashboard():
+    if _dashboard_app is None:
+        return
+    if DEBUG:
+        run_dev()
+    else:
+        run_build(_dashboard_app)
+
+def setup_dashboard(app):
+    global _dashboard_app
+    _dashboard_app = app
app/notification/queue_manager.py (2)

82-88: Silent exception handling loses debugging context.

Parse errors and other exceptions in dequeue are silently swallowed. Consider logging these exceptions at debug/warning level to aid troubleshooting in production.

♻️ Suggested improvement
                 except Exception:
-                    await msg.nak()  # Negative ack on parse error
+                    await msg.nak()
+                    logger.warning("Failed to parse notification message", exc_info=True)
                     return None
-        except asyncio.TimeoutError:
-            return None
-        except Exception:
+        except asyncio.TimeoutError:
+            return None
+        except Exception:
+            logger.debug("Error fetching from notification queue", exc_info=True)
             return None

43-50: Use a specific exception type instead of catching bare Exception.

Catching Exception when adding a stream is fragile and may mask other errors (permissions issues, storage limits, configuration conflicts, etc.). In nats-py 2.12.0+, use nats.js.errors.APIError to catch JetStream-specific errors. For better clarity, consider checking if the stream exists first using stream_info() before calling add_stream(), which also handles edge cases like subject overlaps (error 10065) or misconfigured streams (error 10058).

app/telegram/__init__.py (1)

86-87: Silent RuntimeError catch obscures registration failures.

The except RuntimeError: pass block hides potential issues during handler/middleware registration. Consider logging at debug level to aid troubleshooting.

app/settings/__init__.py (1)

74-77: Narrow the exception type to ImportError.

Using a bare except Exception: is overly broad and may mask unrelated errors during the import or subsequent sync_from_settings() call. The intent appears to be handling cases where the telegram module isn't available.

♻️ Suggested fix
     try:
         from app.telegram import telegram_bot_manager
+        await telegram_bot_manager.sync_from_settings()
-    except Exception:
+    except ImportError:
         return
-    await telegram_bot_manager.sync_from_settings()
.env.example (1)

82-82: Add a trailing newline at end of file.

Per POSIX convention and dotenv-linter, files should end with a newline character.

Proposed fix
 # STOP_NODES_ON_SHUTDOWN=True
+
start.sh (1)

6-19: Consider exec for Python entrypoints to preserve signal handling.
This lets the Python process receive SIGTERM/SIGINT directly; apply to all branches.

♻️ Suggested update
-    python node_worker.py
+    exec python node_worker.py
 elif [ "${ROLE}" = "scheduler" ] || [ "${RUN_SCHEDULER:-0}" = 1 ]; then
-    python scheduler_worker.py
+    exec python scheduler_worker.py
 else
@@
-    python main.py
+    exec python main.py
 fi
docker-compose.multi.yml (1)

6-7: Consider restricting NATS port exposure.

Port 4222 is exposed publicly. If NATS is only used for internal inter-service communication, consider binding to localhost only (127.0.0.1:4222:4222) or removing the port mapping entirely since services communicate via Docker's internal network.

🔒 Proposed fix for internal-only NATS access
     ports:
-      - "4222:4222"
+      - "127.0.0.1:4222:4222"

Or remove the ports section entirely if external access is not needed.

app/scheduler.py (1)

5-8: Remove unused logger.

The logger variable is created but never used in this module.

🧹 Proposed fix to remove unused import and variable
-from app.utils.logger import get_logger
 from config import SCHEDULER_JOBSTORE_URL, SQLALCHEMY_DATABASE_URL

-logger = get_logger("scheduler")
-
app/node/user.py (1)

18-18: Parameter id shadows Python built-in.

Consider renaming the parameter to user_id to avoid shadowing the built-in id function.

🧹 Proposed fix to avoid shadowing built-in
-def _serialize_user_for_node(id: int, username: str, user_settings: dict, inbounds: list[str] = None) -> ProtoUser:
+def _serialize_user_for_node(user_id: int, username: str, user_settings: dict, inbounds: list[str] = None) -> ProtoUser:
     vmess_settings = user_settings.get("vmess", {})
     vless_settings = user_settings.get("vless", {})
     trojan_settings = user_settings.get("trojan", {})
     shadowsocks_settings = user_settings.get("shadowsocks", {})
 
     return create_user(
-        f"{id}.{username}",
+        f"{user_id}.{username}",
app/nats/client.py (2)

8-13: Consider explicit connection configuration for resilience, but defaults already provide good recovery behavior.

The nats.connect() call relies on reasonable defaults (connect_timeout=2, max_reconnect_attempts=60, reconnect_time_wait=2), which handle network hiccups in multi-worker environments. However, if explicit configuration is desired, customize conservatively to avoid reducing resilience—the default max_reconnect_attempts=60 is preferable to a lower value like 10. A connect_timeout increase to 5 seconds may be beneficial for slower networks.


21-27: Improve exception handling specificity.

The broad except Exception catches all errors, which may mask unexpected failures. When create_key_value() is called on an existing bucket, nats-py raises nats.js.errors.APIError with error code 10058. Catch this specific exception and check the error code instead of catching all exceptions.

🔧 Proposed fix for specific exception handling
+from nats.js.errors import APIError
+
 async def get_or_create_kv_bucket(js: JetStreamContext, bucket_name: str) -> KeyValue | None:
     """Get or create a JetStream KV bucket."""
     try:
         return await js.create_key_value(bucket=bucket_name)
-    except Exception:
-        # Bucket already exists
+    except APIError as e:
+        if e.err_code == 10058:  # stream name already in use
+            return await js.key_value(bucket=bucket_name)
+        raise
app/nats/node_rpc.py (2)

43-60: Consider using a custom exception class instead of attaching attributes to RuntimeError.

Attaching a code attribute to a RuntimeError instance (line 57) works but is unconventional and can be error-prone for callers who may not expect this attribute. A custom exception class would be cleaner and more explicit.

♻️ Proposed refactor using custom exception
+class NodeRPCError(Exception):
+    def __init__(self, message: str, code: int = 500):
+        super().__init__(message)
+        self.code = code
+
+
 class NodeNatsClient:
     # ... existing code ...

     async def request(self, action: str, payload: dict, timeout: float | None = None) -> dict:
         # ... existing code ...
         if not response.get("ok", False):
             error_msg = response.get("error", "Node RPC error")
             error_code = response.get("code", 500)
-            exc = RuntimeError(error_msg)
-            exc.code = error_code  # Attach code to exception for caller to handle
-            raise exc
+            raise NodeRPCError(error_msg, error_code)

62-65: Minor: Consider draining the client before closing.

The NATS client may have pending messages. Consider calling drain() before close() to ensure all pending messages are flushed.

♻️ Proposed change
     async def close(self):
         if self._nc and not self._nc.is_closed:
+            await self._nc.drain()
             await self._nc.close()
         self._nc = None
main.py (1)

27-33: Module-level side effects may cause issues during imports.

The worker validation, NATS check, and app creation run at module level, which means they execute whenever main.py is imported (e.g., by test frameworks or tooling). Consider moving this logic inside the if __name__ == "__main__": block, or guard it appropriately.

♻️ Proposed refactor
-workers = UVICORN_WORKERS or 1
-if workers < 1:
-    logger.warning(f"Invalid UVICORN_WORKERS value '{UVICORN_WORKERS}', defaulting to 1.")
-    workers = 1
-require_nats_if_multiworker(workers)
-
-app = create_app(role="panel")
+app = None
+workers = 1

+def _init_app():
+    global app, workers
+    workers = UVICORN_WORKERS or 1
+    if workers < 1:
+        logger.warning(f"Invalid UVICORN_WORKERS value '{UVICORN_WORKERS}', defaulting to 1.")
+        workers = 1
+    require_nats_if_multiworker(workers > 1)
+    app = create_app(role="panel")
+    return app

 # ... rest of file ...

 if __name__ == "__main__":
+    _init_app()
     # Validate UVICORN_SSL_CA_TYPE value

Note: If uvicorn requires the app to be available at module level for the "main:app" string reference, you may need to keep app = create_app(role="panel") at module level but move only the validation/NATS check inside the guard.

app/jobs/process_notification_queues.py (1)

19-24: Consider adding exception handling to prevent a single notification failure from breaking the drain loop.

If process_notification(item) raises an exception, the loop will terminate and remaining queued items won't be processed. Consider wrapping the call in a try-except to ensure all items are attempted.

♻️ Proposed fix
     queue = get_queue()
     while True:
         item = await queue.dequeue(timeout=0.1)
         if not item:
             break
-        await process_notification(item)
+        try:
+            await process_notification(item)
+        except Exception as exc:
+            logger.error(f"Failed to process notification item: {exc}")
app/node/sync.py (1)

6-6: Importing a private function from another module breaks encapsulation.

_serialize_user_for_node is prefixed with an underscore, indicating it's intended to be private to app.node.user. Consider either making it public (removing the underscore) or exposing a public wrapper in the source module.

app/core/manager.py (2)

100-118: Fragile duck-typing with inner class _PayloadCore.

The inner class _PayloadCore mimics the CoreConfig interface to pass data to _update_core_local. This is brittle—if CoreConfig or _update_core_local changes, this could silently break. Consider creating a proper dataclass or using the actual CoreConfig model.

♻️ Proposed refactor using a dataclass
+from dataclasses import dataclass
+
+@dataclass
+class CorePayload:
+    """Lightweight payload for core updates from NATS messages."""
+    id: int
+    config: dict
+    exclude_inbound_tags: set
+    fallbacks_inbound_tags: set

     async def _apply_core_payload(self, payload: dict):
         try:
             core_id = payload["id"]
             config = payload["config"]
         except Exception:
             await self._reload_from_cache()
             return

         exclude_tags = set(payload.get("exclude_inbound_tags") or [])
         fallback_tags = set(payload.get("fallbacks_inbound_tags") or [])

-        class _PayloadCore:
-            def __init__(self, cid, cfg, exclude, fallbacks):
-                self.id = cid
-                self.config = cfg
-                self.exclude_inbound_tags = exclude
-                self.fallbacks_inbound_tags = fallbacks
-
-        await self._update_core_local(_PayloadCore(core_id, config, exclude_tags, fallback_tags))
+        await self._update_core_local(CorePayload(core_id, config, exclude_tags, fallback_tags))

271-275: Accessing private attribute _nc from outside the class.

The shutdown function accesses core_manager._nc directly, breaking encapsulation. Consider adding a public close() or shutdown() method to CoreManager.

♻️ Proposed refactor
 class CoreManager:
     # ... existing methods ...
+
+    async def shutdown(self):
+        """Clean up resources on shutdown."""
+        if self._nc and not self._nc.is_closed:
+            await self._nc.close()

 # ... at module level ...
 `@on_shutdown`
 async def shutdown_core_manager():
-    # Close NATS connection
-    if core_manager._nc:
-        await core_manager._nc.close()
+    await core_manager.shutdown()
app/notification/client.py (3)

152-163: Silent failure on send errors.

When _send_discord_webhook_direct returns False (indicating a failure after retries), the function silently returns without logging the failure. Consider adding error logging for failed deliveries to aid debugging.

Proposed improvement
     if success:
         logger.debug("Discord notification delivered")
+    else:
+        logger.warning(f"Discord notification failed for webhook: {notification.webhook[:50]}...")

165-179: Silent failure on Telegram send errors.

Similar to Discord, when _send_telegram_message_direct returns False, there's no logging of the failure. This makes it harder to diagnose delivery issues.

Proposed improvement
     if success:
         logger.debug("Telegram notification delivered")
+    else:
+        logger.warning(f"Telegram notification failed for chat_id: {notification.chat_id}")

182-197: Swallowed exceptions lose context for debugging.

The process_notification function catches all exceptions and logs a generic message. For production debugging, consider logging the notification type and preserving the stack trace.

Proposed improvement
     except Exception as err:
-        logger.error(f"Failed to process notification: {err}")
+        logger.error(f"Failed to process notification (type={item.get('type')}): {err}", exc_info=True)
app/node/__init__.py (1)

109-112: _update_users iterates sequentially over nodes.

The internal _update_users method awaits each node.update_users(users) call sequentially. For many nodes, this could be slow. Consider parallelizing with asyncio.gather.

Proposed parallel implementation
     async def _update_users(self, users: list):
         async with self._lock.reader_lock:
-            for node in self._nodes.values():
-                await node.update_users(users)
+            await asyncio.gather(
+                *(node.update_users(users) for node in self._nodes.values()),
+                return_exceptions=True
+            )
app/nats/router.py (3)

24-28: Lazy client creation lacks connection validation.

_get_client caches the client but doesn't verify the connection is still alive on subsequent calls. If the connection drops, stale client references may cause silent failures.

Proposed improvement
     async def _get_client(self) -> nats.NATS | None:
         """Get or create NATS client."""
-        if not self._nc:
+        if not self._nc or self._nc.is_closed:
             self._nc = await create_nats_client()
         return self._nc

72-84: Race condition between _running flag and task state.

The start method checks self._running and self._listener_task.done() separately, but _running is set to False in the finally block of _listen. If _listen exits unexpectedly (e.g., exception), _running becomes False but the task object still exists. The check if self._listener_task and not self._listener_task.done() would prevent restart. Consider simplifying to rely only on task state.

Proposed simplification
     async def start(self):
         """Start the router listener."""
         if not MULTI_WORKER:
             return

-        if self._running:
-            return
-
         if self._listener_task and not self._listener_task.done():
             return

         self._running = True
         self._listener_task = asyncio.create_task(self._listen())

101-114: Publish silently fails without propagating errors to caller.

The publish method catches exceptions and only logs a warning. Callers have no way to know if publishing failed. Consider at least returning a boolean or offering a raise_on_error option for critical messages.

app/node/worker.py (1)

139-148: Error code inference from string matching is fragile.

The RPC error handling uses string matching ("NotFound" in error_msg) to determine HTTP status codes. This is brittle and may misclassify errors if message text changes or contains false matches.

Consider propagating structured error codes from the source exceptions (e.g., NodeAPIError.code) rather than parsing strings. If the underlying exceptions already have codes, preserve them through the call chain.

app/core/hosts.py (3)

293-316: _load_state_from_cache swallows all exceptions.

The bare except Exception at line 315 silently catches all errors including potentially important ones (e.g., data corruption). Consider logging the exception.

Proposed improvement
-        except Exception:
+        except Exception as exc:
+            self._logger.warning(f"Failed to load state from cache: {exc}")
             return False

444-455: Duplicate logic in _add_hosts_nats and _add_hosts_local.

The two methods share nearly identical code for preparing hosts. Consider extracting the common preparation logic into a shared helper to reduce duplication.


497-503: Accessing private _nc attribute from outside the class.

The shutdown_hosts function directly accesses host_manager._nc. Consider adding a public close() or shutdown() method to the HostManager class for cleaner encapsulation.

Proposed improvement

Add to HostManager:

async def close(self):
    """Close NATS connection if open."""
    if self._nc:
        await self._nc.close()
        self._nc = None

Then in shutdown_hosts:

 async def shutdown_hosts():
     if IS_NODE_WORKER:
         return
-    # Close NATS connection
-    if host_manager._nc:
-        await host_manager._nc.close()
+    await host_manager.close()
app/operation/node.py (2)

635-636: _get_logs_remote raises 409 Conflict - consider 501 Not Implemented.

Returning HTTP 409 (Conflict) for an unsupported operation is semantically incorrect. HTTP 501 (Not Implemented) or a custom error would be more appropriate.

Proposed fix
     async def _get_logs_remote(self, node_id: Node) -> Callable[[], AsyncIterator[asyncio.Queue]]:
-        await self.raise_error(message="Node logs are only available via node-worker", code=409)
+        await self.raise_error(message="Node logs are only available via node-worker", code=501)

372-382: Type annotation suggests dt = None is a default, but it's Optional.

The type hint start: dt = None is technically incorrect. It should be start: dt | None = None for clarity and correctness with type checkers.

Proposed fix
     async def get_usage(
         self,
         db: AsyncSession,
-        start: dt = None,
-        end: dt = None,
+        start: dt | None = None,
+        end: dt | None = None,
         period: Period = Period.hour,

Comment thread app/core/hosts.py
Comment thread app/core/manager.py Outdated
Comment thread app/jobs/reset_node_usage.py
Comment thread app/lifecycle.py Outdated
Comment thread app/node/worker.py
Comment thread node_worker.py
@M03ED

M03ED commented Feb 2, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Feb 2, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 8

🤖 Fix all issues with AI agents
In `@app/core/hosts.py`:
- Around line 446-473: The NATS add path in _add_hosts_nats is publishing
SubscriptionInboundData objects directly which may not be JSON-safe and it also
never persists the KV state; modify the publish payloads to convert any
SubscriptionInboundData/Host model instances returned from _prepare_host_entry
into plain JSON-serializable primitives (e.g., via
model_dump()/model_validate()->dict or similar) before calling _publish, and
after updating local state (after _add_prepared_hosts_local and removing hosts
from self._hosts) call self._persist_state() to persist the KV so multi-worker
caches stay consistent; keep existing host_id-only removal publish behavior.

In `@app/core/xray.py`:
- Around line 415-437: to_json references self.fallbacks_inbound_tags which
isn't initialized and from_json passes invalid kwargs (_inbounds,
_inbounds_by_tag, _fallbacks_inbound) that __init__ doesn't accept; update
XRayConfig.__init__ to initialize fallbacks_inbound_tags (e.g., accept or set
self.fallbacks_inbound_tags = set(...) where appropriate) and modify from_json
to call cls with the real constructor parameter names (use inbounds=...,
inbounds_by_tag=..., fallbacks_inbound=... and pass
exclude_inbound_tags/fallbacks_inbound_tags as sets) so the reconstructed
instance matches the attributes used by to_json and the initializer.

In `@app/node/worker.py`:
- Around line 233-235: The RPC handler _get_nodes_system_stats has the wrong
signature for how handlers are invoked by _dispatch_rpc (which calls
handler(data)); update _get_nodes_system_stats to accept the dispatched payload
(e.g., add a second parameter like data=None or **kwargs) and keep the existing
behavior (ignore or validate data as needed), so the function becomes compatible
with _dispatch_rpc without changing its return transformation; reference
_get_nodes_system_stats and _dispatch_rpc when making the change.
- Around line 88-93: The shutdown loop currently cancels tasks in
self._log_tasks and immediately clears them, which can race with the
_stream_logs coroutine trying to publish to NATS; instead, after calling
task.cancel() collect the task objects from self._log_tasks.values() and await
their completion (e.g., await asyncio.gather(*tasks, return_exceptions=True))
before clearing self._log_tasks and self._stop_events and before closing the
NATS connection; also ensure _stream_logs handles asyncio.CancelledError/cleanup
in its finally block so awaited cancellations finish cleanly.
- Around line 299-302: The subscription for stop_subject created in _start_logs
via await self._nc.subscribe(stop_subject, cb=_stop_cb) is never stored or
unsubscribed causing a resource leak; modify _start_logs to capture the returned
subscription object (or subject sid) when calling subscribe, store it (e.g.,
local variable or attach to self like self._stop_sub), and ensure you call its
unsubscribe() or self._stop_sub.unsubscribe() in the cleanup/shutdown path that
stops log streaming (or when stop_event is set), and remove any references
afterwards so the subscription is cleaned up.

In `@docker-compose.multi.yml`:
- Around line 39-41: The node-worker service in docker-compose.multi.yml sets an
explicit container_name ("container_name: node-worker"), which prevents scaling;
remove the container_name entry from the node-worker service definition so
Docker Compose can create multiple instances (allowing docker compose up --scale
node-worker=N); ensure no other services rely on that fixed name (update any
references or networks if necessary) and keep the service name node-worker as
the identifier for scaling.
- Around line 10-14: The nats service healthcheck uses "nc" which isn't present
in the nats:2.10-alpine image; update the healthcheck block (healthcheck ->
test) to either use a shell TCP probe like ["/bin/sh","-c","exec
3<>/dev/tcp/localhost/4222"] or extend the image to install netcat (add
Dockerfile step RUN apk add --no-cache netcat-openbsd) so the existing test
["CMD","nc","-z","localhost","4222"] works; alternatively, point the healthcheck
to a bundled script (e.g., /code/healthcheck.sh) like
["CMD","/code/healthcheck.sh"] if that script is included in the image.

In `@start.sh`:
- Line 19: In start.sh change the final process invocation so the shell is
replaced by the Python process: replace the plain "python main.py" invocation
with an exec-style invocation so signals (SIGTERM) are delivered to the Python
process directly; update the panel startup line (the invocation of python
main.py) to use exec to match node-worker and scheduler behavior and enable
graceful shutdown.
🧹 Nitpick comments (5)
start.sh (1)

5-8: Dual environment variable patterns may cause confusion.

The script checks both NODE_ROLE and legacy-style flags (IS_NODE_WORKER, RUN_SCHEDULER). If NODE_ROLE is set to something unexpected but IS_NODE_WORKER=1, the node-worker branch will still execute. This fallback pattern is fine for backward compatibility, but consider documenting the precedence or deprecating one approach to avoid ambiguity.

scheduler_worker.py (1)

43-51: Consider simplifying to just asyncio.run().

The fallback to get_event_loop().run_until_complete() targets Python < 3.7, which reached end-of-life in June 2023. If the project's minimum Python version is 3.7+, this can be simplified.

♻️ Simplified entry point for Python 3.7+
 if __name__ == "__main__":
     try:
-        if hasattr(asyncio, "run"):
-            asyncio.run(main())
-        else:
-            loop = asyncio.get_event_loop()
-            loop.run_until_complete(main())
+        asyncio.run(main())
     except KeyboardInterrupt:
         pass
node_worker.py (1)

26-32: Guard add_signal_handler on platforms where it’s unsupported.

loop.add_signal_handler can raise NotImplementedError on some platforms (e.g., Windows). If Windows support is expected, wrap these calls to avoid crashing at startup.

🔧 Proposed guard
     loop = asyncio.get_running_loop()
-    loop.add_signal_handler(signal.SIGINT, handle_signal)
-    loop.add_signal_handler(signal.SIGTERM, handle_signal)
+    for sig in (signal.SIGINT, signal.SIGTERM):
+        try:
+            loop.add_signal_handler(sig, handle_signal)
+        except NotImplementedError:
+            pass
app/node/worker.py (1)

42-43: Use Callable from typing instead of callable.

The type hint uses the built-in callable function rather than the Callable type from the typing module. While this won't cause runtime errors, it's not the correct type annotation.

Proposed fix

Add to imports:

from typing import Callable

Then update the type hints:

-        self._command_handlers: dict[str, callable] = {}
-        self._rpc_handlers: dict[str, callable] = {}
+        self._command_handlers: dict[str, Callable] = {}
+        self._rpc_handlers: dict[str, Callable] = {}
docker-compose.multi.yml (1)

24-24: Use canonical boolean strings for RUN_SCHEDULER.

Capitalized booleans are non-canonical in YAML 1.2 and may end up as strings; use "true"/"false" to avoid parser ambiguity.

✅ Suggested fix
-      RUN_SCHEDULER: False
+      RUN_SCHEDULER: "false"
...
-      RUN_SCHEDULER: True
+      RUN_SCHEDULER: "true"
...
-      RUN_SCHEDULER: True
+      RUN_SCHEDULER: "true"

Also applies to: 46-46, 60-60

Comment thread app/core/hosts.py
Comment thread app/core/xray.py
Comment thread app/node/worker.py Outdated
Comment thread app/node/worker.py Outdated
Comment thread app/node/worker.py Outdated
Comment thread docker-compose.multi.yml
Comment on lines +10 to +14
healthcheck:
test: ["CMD", "nc", "-z", "localhost", "4222"]
interval: 2s
timeout: 2s
retries: 10

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -name "docker-compose.multi.yml" -type f

Repository: PasarGuard/panel

Length of output: 86


🏁 Script executed:

cat -n ./docker-compose.multi.yml

Repository: PasarGuard/panel

Length of output: 2025


🏁 Script executed:

# Search for healthcheck.sh in the codebase
find . -name "healthcheck.sh" -type f

Repository: PasarGuard/panel

Length of output: 76


🏁 Script executed:

# Find Dockerfile
find . -name "Dockerfile*" -type f

Repository: PasarGuard/panel

Length of output: 72


🏁 Script executed:

cat -n ./Dockerfile

Repository: PasarGuard/panel

Length of output: 1638


🏁 Script executed:

# Search for any documentation or references about nc in nats image
# First, let's check if there's any reference in the repo
rg "nc" --type md --type txt --type yaml

Repository: PasarGuard/panel

Length of output: 1091


🏁 Script executed:

cat -n ./healthcheck.sh

Repository: PasarGuard/panel

Length of output: 2283


🌐 Web query:

nats:2.10-alpine netcat nc command availability

💡 Result:

In the official nats:2.10-alpine image, nc (netcat) is not guaranteed to be available by default—the Alpine variants intentionally keep extra utilities out unless explicitly installed. The published Dockerfile for the Alpine-based 2.10 images installs only minimal packages (for example ca-certificates and tzdata), and does not add netcat. [1], [2]

If you need nc, install it in a derived image, e.g.:

FROM nats:2.10-alpine
RUN apk add --no-cache netcat-openbsd

(Alternative on Alpine: apk add --no-cache busybox-extras, depending on which nc you want.) [1]

Sources: [1] [2]


Remove nc from nats healthcheck or install it in the image.

The nats:2.10-alpine image does not include nc by default, causing the healthcheck on line 11 to fail. Either use an alternative command (e.g., /bin/sh -c 'exec 3<>/dev/tcp/localhost/4222') or extend the image to install netcat: RUN apk add --no-cache netcat-openbsd.

The /code/healthcheck.sh script in the panel service is correctly included in the image via the Dockerfile and will work as expected.

🤖 Prompt for AI Agents
In `@docker-compose.multi.yml` around lines 10 - 14, The nats service healthcheck
uses "nc" which isn't present in the nats:2.10-alpine image; update the
healthcheck block (healthcheck -> test) to either use a shell TCP probe like
["/bin/sh","-c","exec 3<>/dev/tcp/localhost/4222"] or extend the image to
install netcat (add Dockerfile step RUN apk add --no-cache netcat-openbsd) so
the existing test ["CMD","nc","-z","localhost","4222"] works; alternatively,
point the healthcheck to a bundled script (e.g., /code/healthcheck.sh) like
["CMD","/code/healthcheck.sh"] if that script is included in the image.

Comment thread docker-compose.multi.yml
Comment thread start.sh Outdated
@M03ED

M03ED commented Feb 2, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Feb 2, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@app/node/worker.py`:
- Around line 84-95: The stop() coroutine iterates directly over
self._stop_events and self._log_tasks while _stream_logs may pop from those
dicts concurrently, causing RuntimeError; fix by snapshotting the values before
iterating (use list(self._stop_events.values()) and
list(self._log_tasks.values())) and perform stop_event.set(), task.cancel(), and
the asyncio.gather on those snapshots, then clear/cleanup the original dicts
(e.g., self._log_tasks.clear()) as currently intended; reference: stop(),
_stop_events, _log_tasks, and _stream_logs.
🧹 Nitpick comments (4)
app/core/hosts.py (4)

319-322: Consider removing unreachable branch.

Since cached_state comes from json.loads(), host_data will always be a dict (or primitive). The else branch at line 322 storing host_data directly is unreachable for valid JSON payloads and could mask unexpected data.

Suggested simplification
             for host_id_str, host_data in cached_state.items():
                 try:
                     host_id = int(host_id_str)
-                    if isinstance(host_data, dict):
-                        converted_state[host_id] = SubscriptionInboundData.model_validate(host_data)
-                    else:
-                        converted_state[host_id] = host_data
+                    converted_state[host_id] = SubscriptionInboundData.model_validate(host_data)
                 except (ValueError, TypeError):

340-356: Add defensive handling for malformed messages.

If host_entry is present but missing "id" or "data" keys, a KeyError will propagate. While this is an internal message, defensive handling improves resilience.

Suggested improvement
         elif action == "add":
             host_entry = data.get("host")
-            if host_entry:
-                await self._add_prepared_hosts_local([(host_entry["id"], host_entry["data"])])
+            if host_entry and "id" in host_entry and "data" in host_entry:
+                await self._add_prepared_hosts_local([(host_entry["id"], host_entry["data"])])
             else:
                 await self._reload_from_cache()

461-477: Consider batching host updates for improved performance.

Publishing messages one-by-one in a loop (lines 462-467) could be slow with many hosts. Additionally, the redundant _reset_cache() call at line 477 is unnecessary since _add_prepared_hosts_local already resets the cache.

Suggested optimization
         # Persist state to NATS KV
         await self._persist_state()
-        await self._reset_cache()

519-525: Consider adding error handling for NATS connection close.

If _nc.close() raises an exception during shutdown, it could prevent clean termination. A try-except wrapper would improve robustness.

Suggested improvement
 `@on_shutdown`
 async def shutdown_hosts():
     if IS_NODE_WORKER:
         return
     # Close NATS connection
     if host_manager._nc:
-        await host_manager._nc.close()
+        try:
+            await host_manager._nc.close()
+        except Exception as exc:
+            host_manager._logger.warning(f"Error closing NATS connection: {exc}")

Comment thread app/node/worker.py
Comment on lines +84 to +95
async def stop(self):
if not IS_NODE_WORKER:
return

for stop_event in self._stop_events.values():
stop_event.set()
for task in self._log_tasks.values():
task.cancel()
# Wait for all cancelled tasks to complete their cleanup
if self._log_tasks:
await asyncio.gather(*self._log_tasks.values(), return_exceptions=True)
self._log_tasks.clear()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's find and examine the worker.py file
find . -name "worker.py" -path "*/app/node/*" | head -5

Repository: PasarGuard/panel

Length of output: 80


🏁 Script executed:

# Read the worker.py file to understand the context
wc -l app/node/worker.py

Repository: PasarGuard/panel

Length of output: 82


🏁 Script executed:

# Read the relevant section and more context around the stop() method
cat -n app/node/worker.py | sed -n '1,120p'

Repository: PasarGuard/panel

Length of output: 5751


🏁 Script executed:

# Search for _stream_logs method to verify if it mutates _stop_events or _log_tasks
rg "_stream_logs|_stop_events|_log_tasks" app/node/worker.py -A 5 -B 2

Repository: PasarGuard/panel

Length of output: 2082


Snapshot dict values before iterating during shutdown to prevent RuntimeError: dictionary changed size during iteration.

The _stream_logs coroutine pops from _stop_events and _log_tasks during cleanup, and these pops can occur while stop() iterates over the same dictionaries. When stop_event.set() is called, it unblocks waiting tasks that then pop from the dicts concurrently with iteration, causing a runtime crash.

🛠️ Suggested fix
-        for stop_event in self._stop_events.values():
+        for stop_event in list(self._stop_events.values()):
             stop_event.set()
-        for task in self._log_tasks.values():
+        for task in list(self._log_tasks.values()):
             task.cancel()
         # Wait for all cancelled tasks to complete their cleanup
         if self._log_tasks:
-            await asyncio.gather(*self._log_tasks.values(), return_exceptions=True)
+            await asyncio.gather(*list(self._log_tasks.values()), return_exceptions=True)
🤖 Prompt for AI Agents
In `@app/node/worker.py` around lines 84 - 95, The stop() coroutine iterates
directly over self._stop_events and self._log_tasks while _stream_logs may pop
from those dicts concurrently, causing RuntimeError; fix by snapshotting the
values before iterating (use list(self._stop_events.values()) and
list(self._log_tasks.values())) and perform stop_event.set(), task.cancel(), and
the asyncio.gather on those snapshots, then clear/cleanup the original dicts
(e.g., self._log_tasks.clear()) as currently intended; reference: stop(),
_stop_events, _log_tasks, and _stream_logs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants