Skip to content

feat: API improvements - config reload, rule providers, log stability#1343

Merged
ibigbug merged 1 commit into
masterfrom
feat/api-improvements
Apr 27, 2026
Merged

feat: API improvements - config reload, rule providers, log stability#1343
ibigbug merged 1 commit into
masterfrom
feat/api-improvements

Conversation

@ibigbug
Copy link
Copy Markdown
Member

@ibigbug ibigbug commented Apr 27, 2026

Overview

API improvements for the clash-rs backend, extracted from the dashboard branch.

Changes

Config reload

  • Add config_path to GlobalState so the API can track which config file was loaded
  • PUT /configs (reload) now correctly finds the original config file path

Rule provider endpoints

  • GET /providers/rules – list all rule providers
  • PUT /providers/rules/:name – reload a rule provider
  • GET /providers/rules/:name/match?target=<domain|ip> – test if a target matches a rule provider

Log stability

  • Handle Lagged broadcast errors in log WS handler instead of silently dropping the connection

InboundManager improvements

  • Add start_idle_listeners / restart_idle to only restart listeners whose port/address changed, avoiding EADDRINUSE when patching config for unchanged ports

Router

  • Add get_rule_providers() to expose the rule provider map to API handlers

Summary by CodeRabbit

Release Notes

  • New Features

    • Added API endpoints to list, fetch, and match rule providers with update capabilities.
    • Implemented selective listener restart for improved performance when only port settings change.
    • Added ability to list and view rule counts from providers.
  • Bug Fixes

    • Improved config reload error handling with proper validation and error responses instead of crashes.
    • Enhanced websocket log streaming with better connection stability and lag detection.
    • Fixed port configuration changes to avoid unnecessary full service restarts.

…bility

- Add config_path to GlobalState for config reload support
- Add rule provider routes (/providers/rules/*) with list, reload, match endpoints
- Wire rule_routes in ApiRunner
- Handle lagged broadcast errors in log websocket handler
- Add start_idle_listeners/restart_idle to InboundManager to avoid EADDRINUSE on patch
- Add get_rule_providers() to Router
- Add list_rules()/ruleCount to RuleProvider

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 27, 2026

📝 Walkthrough

Walkthrough

This PR enhances configuration management, error handling, and introduces a rule provider API. Changes include adding config path tracking to global state, improving config reload path resolution with better error handling, optimizing restart behavior with a new restart_idle() method for port-only changes, exposing rule provider listing and matching via new API routes, and updating rule provider structures to track rule counts.

Changes

Cohort / File(s) Summary
API Route Handlers
clash-lib/src/app/api/handlers/config.rs, clash-lib/src/app/api/handlers/log.rs
Config handler now returns config_path from global state and improves file reload validation with guarded error handling instead of panics. Log handler restructures websocket event loop with explicit RecvError handling for lag and closure scenarios.
Rule Provider API
clash-lib/src/app/api/handlers/provider.rs
New rule_routes function adds API endpoints for listing/fetching rule providers, triggering updates, listing provider rules (capped at 500), and matching rules against constructed sessions via target query parameter.
API Router Setup
clash-lib/src/app/api/runner.rs
Registers new rule provider routes under "/providers/rules" endpoint group and clones router for nested rules path to avoid consuming original router.
Listener Management
clash-lib/src/app/inbound/manager.rs
Introduces restart_idle() for partial restarts and start_idle_listeners() to resume only inactive listeners. Updates change_ports() to track port changes and use partial restart; improves shutdown flow with explicit task abortion and awaiting to ensure socket cleanup.
Rule Provider Core
clash-lib/src/app/remote_content_manager/providers/rule_provider/provider.rs, clash-lib/src/app/remote_content_manager/providers/rule_provider/mrs.rs
Rule providers now track rule counts in RuleContent variants, support list_rules(limit) API with domain/IPCIDR returning empty lists, and expose ruleCount metadata. Domain/IPCIDR parsing updated to pass counts into RuleContent.
Router Registry & Startup
clash-lib/src/app/router/mod.rs, clash-lib/src/lib.rs
Router stores initialized rule provider registry with get_rule_providers() accessor. Library startup thread now extracts and forwards config_path from file-based configs through GlobalState for later retrieval.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Poem

🐰 Along winding paths through config's domain,
We rest weary listeners, then start again.
New rule routes bloom like carrots in spring,
Counted and matched—oh, what joy they bring!
Efficient restarts, no wasteful delay,
A more nimble clash for a faster day. 🥕✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately summarizes the main changes: API improvements with a focus on config reload, rule providers, and log stability, which aligns with the changeset across multiple handlers and managers.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/api-improvements

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

ibigbug added a commit that referenced this pull request Apr 27, 2026
… API changes

Move all non-embed Rust API improvements (config reload, rule provider
routes, log lag handling, inbound restart_idle) to feat/api-improvements
PR #1343.

Dashboard branch now contains only:
- Embedded dashboard serving (embedded_dashboard.rs, /ui/* routing)
- build.rs npm ci steps
- Cargo.toml dashboard feature flag
- All clash-dashboard frontend files

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
clash-lib/src/app/api/handlers/config.rs (1)

322-357: ⚠️ Potential issue | 🔴 Critical

Bind-address changes still need a full restart.

If bind_address changed earlier in this handler and any port field is also present, port_changed makes this branch call restart_idle(). That only restarts handles cleared by change_ports(), so listeners whose port did not change keep serving on the old bind address.

🛠️ Proposed fix
     let inbound_manager = state.inbound_manager.clone();
     let mut need_restart = false;
+    let mut full_restart = false;
     if let Some(bind_address) = payload.bind_address.clone() {
         match bind_address.parse::<BindAddress>() {
             Ok(bind_address) => {
                 inbound_manager.set_bind_address(bind_address).await;
                 need_restart = true;
+                full_restart = true;
             }
             Err(_) => {
                 return (
@@
     if let Some(allow_lan) = payload.allow_lan
         && allow_lan != inbound_manager.get_allow_lan().await
     {
         inbound_manager.set_allow_lan(allow_lan).await;
         // TODO: can be done with AtomicBool in each inbound manager, but requires
         // more changes
         need_restart = true;
-        port_changed = false; // force full restart
+        full_restart = true;
     }
@@
-    if need_restart {
-        if port_changed {
-            // Port-only change: restart only the affected listener(s).
-            // Unchanged listeners keep running — no EADDRINUSE.
-            let _ = inbound_manager.restart_idle().await;
-        } else {
-            let _ = inbound_manager.restart().await;
-        }
+    if full_restart {
+        let _ = inbound_manager.restart().await;
+    } else if port_changed {
+        // Port-only change: restart only the affected listener(s).
+        // Unchanged listeners keep running — no EADDRINUSE.
+        let _ = inbound_manager.restart_idle().await;
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clash-lib/src/app/api/handlers/config.rs` around lines 322 - 357, If the
bind_address was changed earlier, a port-only restart is unsafe; detect when
payload.bind_address is Some and differs from
inbound_manager.get_bind_address().await and treat it like a full restart by
setting need_restart = true and port_changed = false (similar to the allow_lan
branch). Update the handler to compare payload.bind_address against
inbound_manager.get_bind_address().await after applying any bind_address change
(or when deciding restarts), and if different set
inbound_manager.set_bind_address(...) as needed, then force a full restart by
clearing port_changed so code uses inbound_manager.restart() instead of
restart_idle().
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@clash-lib/src/app/inbound/manager.rs`:
- Around line 384-425: stop_all_listeners currently aborts and clears
provider-owned listener tasks (entries in provider_handles) but restart() only
re-binds static inbound_handlers via start_all_listeners, so provider listeners
stay down after a full restart; update restart() (and the code path referenced
around the other occurrence at the 470-484 area) to also restart provider-owned
listeners by invoking the same startup logic used for provider_handles (e.g.,
call or extract a function like start_provider_listeners or extend
start_all_listeners to iterate provider_handles and recreate/respawn their tasks
from the provider_handles entries), ensuring you reference provider_handles,
entry.handle, and the existing start_all_listeners/stop_all_listeners functions
so provider inbounds are re-bound after a restart.
- Around line 614-621: The closure passed to extract_if currently treats "option
present" as a change and returns true whenever a port option exists; update it
to compare the new optional port value against the current ports stored in
ports.port / ports.socks_port / ports.mixed_port / ports.tproxy_port /
ports.redir_port and only return true if the option is Some(value) and the value
differs from the existing port. Modify the match arms for InboundOpts::Http,
::Socks, ::Mixed, ::TProxy, and ::Redir in extract_if (and the similar blocks
around lines 626–674) to inspect the inner port (e.g. port.map(|p| Some(p) !=
ports.port).unwrap_or(false) or equivalent) rather than just checking
ports.*_port.is_some().

In `@clash-lib/src/lib.rs`:
- Line 126: GlobalState.config_path is not updated when a file-based reload
occurs; locate where configs are loaded from a path (e.g., the PUT /configs
handler and the reload loop functions that call the file loader) and set
GlobalState.config_path = Some(new_path.clone()) whenever a successful
load-from-file happens. Update the code paths that perform
reload_from_file/load_config_from_path (and any functions around lines 138-142
and 211-240 handling reloads) to mutate the GlobalState instance (the struct
with config_path) after the load succeeds so subsequent empty-path reloads use
the new path.

---

Outside diff comments:
In `@clash-lib/src/app/api/handlers/config.rs`:
- Around line 322-357: If the bind_address was changed earlier, a port-only
restart is unsafe; detect when payload.bind_address is Some and differs from
inbound_manager.get_bind_address().await and treat it like a full restart by
setting need_restart = true and port_changed = false (similar to the allow_lan
branch). Update the handler to compare payload.bind_address against
inbound_manager.get_bind_address().await after applying any bind_address change
(or when deciding restarts), and if different set
inbound_manager.set_bind_address(...) as needed, then force a full restart by
clearing port_changed so code uses inbound_manager.restart() instead of
restart_idle().
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f4a973e1-5ac7-4091-9b6f-28b8528a000a

📥 Commits

Reviewing files that changed from the base of the PR and between 74dd585 and b4fa6df.

📒 Files selected for processing (9)
  • clash-lib/src/app/api/handlers/config.rs
  • clash-lib/src/app/api/handlers/log.rs
  • clash-lib/src/app/api/handlers/provider.rs
  • clash-lib/src/app/api/runner.rs
  • clash-lib/src/app/inbound/manager.rs
  • clash-lib/src/app/remote_content_manager/providers/rule_provider/mrs.rs
  • clash-lib/src/app/remote_content_manager/providers/rule_provider/provider.rs
  • clash-lib/src/app/router/mod.rs
  • clash-lib/src/lib.rs

Comment on lines +384 to 425
async fn stop_all_listeners(&self) {
let mut handles_to_await: Vec<JoinHandle<()>> = Vec::new();

// Abort static inbound handlers, collect handles for awaiting.
{
let mut guard = self.inbound_handlers.write().await;
for (opt, l) in guard.iter_mut() {
if let Some(handler) = l.take() {
warn!(
"Shutting down provider inbound handler: {}",
"Shutting down inbound handler: {}",
opt.common_opts().name
);
h.abort();
handler.abort();
handles_to_await.push(handler);
}
*l = None;
}
}

// Abort provider inbound handlers.
{
let mut provider_guard = self.provider_handles.write().await;
for handles in provider_guard.values_mut() {
for (opt, entry) in handles.iter_mut() {
if let Some(h) = entry.handle.take() {
warn!(
"Shutting down provider inbound handler: {}",
opt.common_opts().name
);
h.abort();
handles_to_await.push(h);
}
}
}
}

// Wait for all aborted tasks to finish so the OS ports are released
// before new listeners bind to the same addresses.
for h in handles_to_await {
let _ = h.await;
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Full restart currently drops provider-owned listeners.

stop_all_listeners() now aborts entries from provider_handles, but restart() only calls start_all_listeners() for inbound_handlers. After any full restart, provider inbounds stay down until their provider happens to emit another update.

Also applies to: 470-484

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clash-lib/src/app/inbound/manager.rs` around lines 384 - 425,
stop_all_listeners currently aborts and clears provider-owned listener tasks
(entries in provider_handles) but restart() only re-binds static
inbound_handlers via start_all_listeners, so provider listeners stay down after
a full restart; update restart() (and the code path referenced around the other
occurrence at the 470-484 area) to also restart provider-owned listeners by
invoking the same startup logic used for provider_handles (e.g., call or extract
a function like start_provider_listeners or extend start_all_listeners to
iterate provider_handles and recreate/respawn their tasks from the
provider_handles entries), ensuring you reference provider_handles,
entry.handle, and the existing start_all_listeners/stop_all_listeners functions
so provider inbounds are re-bound after a restart.

Comment on lines 614 to +621
.extract_if(|opts, _| match &opts {
InboundOpts::Http { common_opts } => {
ports.port.is_some() && Some(common_opts.port) == ports.port
}
InboundOpts::Socks { common_opts, .. } => {
ports.socks_port.is_some()
&& Some(common_opts.port) == ports.socks_port
}
InboundOpts::Mixed { common_opts, .. } => {
ports.mixed_port.is_some()
&& Some(common_opts.port) == ports.mixed_port
}
InboundOpts::Http { .. } => ports.port.is_some(),
InboundOpts::Socks { .. } => ports.socks_port.is_some(),
InboundOpts::Mixed { .. } => ports.mixed_port.is_some(),
#[cfg(feature = "tproxy")]
InboundOpts::TProxy { common_opts, .. } => {
ports.tproxy_port.is_some()
&& Some(common_opts.port) == ports.tproxy_port
}
InboundOpts::TProxy { .. } => ports.tproxy_port.is_some(),
#[cfg(feature = "redir")]
InboundOpts::Redir { common_opts } => {
ports.redir_port.is_some()
&& Some(common_opts.port) == ports.redir_port
}
InboundOpts::Redir { .. } => ports.redir_port.is_some(),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Ignore no-op port patches before aborting listeners.

This now treats “field present” as “port changed”. Sending the same port value still aborts the task, waits for shutdown, and makes the caller believe a restart was required, which causes avoidable listener churn.

♻️ Proposed fix
         let listeners: HashMap<InboundOpts, Option<_>> = guard
             .extract_if(|opts, _| match &opts {
-                InboundOpts::Http { .. } => ports.port.is_some(),
-                InboundOpts::Socks { .. } => ports.socks_port.is_some(),
-                InboundOpts::Mixed { .. } => ports.mixed_port.is_some(),
+                InboundOpts::Http { common_opts } => {
+                    ports.port.is_some_and(|p| p != common_opts.port)
+                }
+                InboundOpts::Socks { common_opts, .. } => {
+                    ports.socks_port.is_some_and(|p| p != common_opts.port)
+                }
+                InboundOpts::Mixed { common_opts, .. } => {
+                    ports.mixed_port.is_some_and(|p| p != common_opts.port)
+                }
                 #[cfg(feature = "tproxy")]
-                InboundOpts::TProxy { .. } => ports.tproxy_port.is_some(),
+                InboundOpts::TProxy { common_opts, .. } => {
+                    ports.tproxy_port.is_some_and(|p| p != common_opts.port)
+                }
                 #[cfg(feature = "redir")]
-                InboundOpts::Redir { .. } => ports.redir_port.is_some(),
+                InboundOpts::Redir { common_opts } => {
+                    ports.redir_port.is_some_and(|p| p != common_opts.port)
+                }
                 _ => false,
             })
             .collect();

Also applies to: 626-674

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clash-lib/src/app/inbound/manager.rs` around lines 614 - 621, The closure
passed to extract_if currently treats "option present" as a change and returns
true whenever a port option exists; update it to compare the new optional port
value against the current ports stored in ports.port / ports.socks_port /
ports.mixed_port / ports.tproxy_port / ports.redir_port and only return true if
the option is Some(value) and the value differs from the existing port. Modify
the match arms for InboundOpts::Http, ::Socks, ::Mixed, ::TProxy, and ::Redir in
extract_if (and the similar blocks around lines 626–674) to inspect the inner
port (e.g. port.map(|p| Some(p) != ports.port).unwrap_or(false) or equivalent)
rather than just checking ports.*_port.is_some().

Comment thread clash-lib/src/lib.rs
dns_listener: ArcRunner,
reload_tx: mpsc::Sender<(Config, oneshot::Sender<()>)>,
cwd: String,
config_path: Option<String>,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Keep GlobalState.config_path in sync after file-based reloads.

This is initialized from the startup input, and GET /configs plus empty-path reloads now rely on it, but the reload loop never updates it when PUT /configs loads a different file. After one successful reload-from-file, the next empty-path reload still points at the old config.

Also applies to: 138-142, 211-240

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clash-lib/src/lib.rs` at line 126, GlobalState.config_path is not updated
when a file-based reload occurs; locate where configs are loaded from a path
(e.g., the PUT /configs handler and the reload loop functions that call the file
loader) and set GlobalState.config_path = Some(new_path.clone()) whenever a
successful load-from-file happens. Update the code paths that perform
reload_from_file/load_config_from_path (and any functions around lines 138-142
and 211-240 handling reloads) to mutate the GlobalState instance (the struct
with config_path) after the load succeeds so subsequent empty-path reloads use
the new path.

@github-actions
Copy link
Copy Markdown
Contributor

📊 Proxy Throughput Results

Transport Payload Upload (Mbps) Download (Mbps)
ss-obfs-http 32 MB 10886.1 6945.1
ss-obfs-tls 32 MB 5202.4 5510.5
ss-plain 32 MB 14633.8 12536.9
ss-shadow-tls-v3 32 MB 10995.4 7496.3
ss-v2ray-plugin-ws-tls 32 MB 9714.7 9070.7

Tests ran 5 variant(s) in parallel; each direction transfers the full payload.

Full test log

Download the throughput-results artifact for the full log.

@ibigbug ibigbug merged commit 241b217 into master Apr 27, 2026
35 of 36 checks passed
@ibigbug ibigbug deleted the feat/api-improvements branch April 27, 2026 10:39
ibigbug added a commit that referenced this pull request Apr 27, 2026
… API changes

Move all non-embed Rust API improvements (config reload, rule provider
routes, log lag handling, inbound restart_idle) to feat/api-improvements
PR #1343.

Dashboard branch now contains only:
- Embedded dashboard serving (embedded_dashboard.rs, /ui/* routing)
- build.rs npm ci steps
- Cargo.toml dashboard feature flag
- All clash-dashboard frontend files

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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.

1 participant