fix: node selection persistence (#7620)

* fix(core): put the user's node choices back when the core starts

mihomo restores the node picked in each group from the `cache.db` it keeps
in the directory it was started against. The app relied on that entirely,
and it does not hold: `profile.store-selected` comes from a merge template
a user is free to replace, and a service older than the durable runtime
generation starts every core in a directory nothing has ever run in. Both
end the same way — every `select` group falls back to the first entry in
its `proxies:` list.

The app already knows better. It records each selection in the profile,
and `activate_selected_nodes` already reconciles that record against the
running core — it was only ever called when switching profiles, never when
a core came up. Call it from the three places a core process becomes
ready. Repeat calls supersede each other, so no coordination is needed
between the start paths.

That makes the profile authoritative, which turns any selection it does
not know about into one the next start silently undoes. The chain proxy
moved a group without recording it, so recording is now one shared hook
that both it and the proxy list go through.

Restoration is spawned, not awaited: it polls the core until its groups
are readable, and a start must not wait for that.

* fix(tray): record the node the tray switched to

Restoring the profile's selections when a core starts made the profile
authoritative, which turns any selection it never learned about into one
the next start silently undoes. The proxy list and the chain proxy both
record; the tray did not.

So switching node from the tray survived only until the next restart, and
then reverted to whatever the profile last knew — a regression the
restoration itself introduced, and only for the tray.

`record_selected_node` is the backend counterpart of the frontend's
`useRecordSelection`. Failing to record does not fail the switch: the core
is already on the new node by then, and reporting the switch as failed
would be wrong. It is logged instead.

* fix(core): restoring selections must not prune what the core has not loaded

Putting the profile's selections back when a core starts reused the
reconciliation written for profile switches, and that one also *repairs*:
a record whose group is absent from two snapshots a second apart is
dropped, and the reduced list is written to profiles.yaml.

After a profile switch the configuration is loaded, so absence is
evidence. After a core start it is not — a provider-backed group can be
empty or missing for seconds — and a record pruned on that evidence is
gone for good, because the provider finishing later cannot bring it back.
Restoring now keeps the records and only applies what it can match;
pruning stays with the caller entitled to conclude it.

The restore is also asynchronous: it reads the profile, polls the core
until its groups are readable, then puts its selections. A choice the user
makes inside that window is newer than what it captured, so letting it
finish pushed the core back to the older node while the profile already
held the newer one. Recording a selection now supersedes any activation in
flight — from the tray, and from the UI and chain proxy through
`patch_profile`.

* fix(core): activate the selections of the profile that is actually running

The activation worker read the profiles draft. A profile switch stages its
target there before validating it, so a switch that fails — leaving the
previous profile running — could still have handed an activation the
rejected profile's selections, which it would then apply to whichever
same-named groups the running profile has.

Reading the committed state removes the window. The switch that succeeds
commits before it activates, so nothing changes for that caller; what
changes is that a restore running at core start can no longer observe a
switch that never happened.

* fix(core): stop a restoration from undoing a choice made while it runs

Two gaps in making the profile authoritative, both found by adversarial
review of the commits that introduced it.

A restoration reads the profile's selections once, then polls the core
until its groups can be read — seconds, on a cold start. A selection made
inside that window is newer than what the restoration is carrying, and
nothing told the restoration so: it went on to apply the older choice over
the user's. The UI accepted the new node and traffic snapped back to the
old one, with the profile and the running core then disagreeing. Recording
a selection now abandons any restoration still in flight, through the same
generation counter every other superseding caller uses.

The other gap is the home card in global mode, which passed
`skipConfigSave` and so recorded nothing. That was harmless while nothing
re-applied the profile; now it means the next start undoes the choice. It
was the only caller that ever set the flag, so the flag is gone rather than
flipped — a parameter whose one use was a bug is not worth keeping.

* fix(core): wait for the selections that can be put back, keep trying for the rest

Two gaps in restoring the profile's selections when a core starts, both
found by adversarial review.

Restoring re-checked once, a second later, and stopped. That is enough
after a profile switch, which happens with the configuration already
loaded. A core start is the opposite case: a provider-backed group is
present but empty until its provider finishes loading, and a `select` can
fail while the core is still settling. Neither produces an activation, so
the restore reported success having applied nothing and the group stayed
on the first entry of its `proxies:` list — the outcome restoring exists
to prevent. It now keeps looking, on a deadline, at what the core is
actually on rather than at what it managed to send.

Restoring was also spawned, and the caller enables the system proxy the
moment the start returns — so traffic could be pointed at a core still on
the wrong node. The first pass is now awaited, bounded, and the rest keeps
settling in the background: a core that will not answer delays a start
rather than blocking it, because being proxied through the wrong node
beats not being proxied at all.

TUN is deliberately not covered, and cannot be by anything here: it is
configured in the core's own file, so it carries traffic from the moment
the process starts. What puts a TUN user on the right node from the first
packet is the core's own `cache.db`, which is why the service keeps an
owner's runtime generation across restarts.

`supersede_selected_activation` and `cancel_selected_node_activation` were
the same function under two names, added independently for the same
reason, and `patch_profile` called both. One survives, called once, after
the profile is written — the only placement that needs to exist, since an
activation that read the older profile is cancelled by it and one starting
afterwards reads the newer one.

* fix(core): record a selection by naming it, not by resending the list

Recording a node selection sent the whole selection list, built from the
profile the frontend had rendered. Two selections made before that list
refreshed were therefore built from the same stale snapshot, and the later
write dropped the earlier group's choice. Since a core start re-applies
whatever the profile holds, the dropped one came back on the next restart
— the profile and the running core disagreeing, in the direction the user
would least expect.

The tray already avoided this, because it went through
`record_selected_node`, which merges one group into the profile inside
`with_data_modify`. Exposing that as a command and pointing the frontend
at it leaves one way to record a selection instead of two, and removes the
list from the wire: what a caller knows is the group and the node, and
that is now all it says.

`useRecordSelection` no longer reads the profile at all, which also makes
it stable across renders rather than changing identity whenever the
profile does.

* test(core): make every way of declining to stage answer for itself

`CoreRestarted` was added to `StageRejection` upstream and reached
production without a test. It inherited the right verdict — every
`RestartRequired` is matched together — but nothing said so, and nothing
would have noticed if it had inherited the wrong one.

The list of rejections now goes through a wrapper whose `match` is
exhaustive, so the next variant added upstream fails to compile here. That
is the part worth having: it does not prove the list is complete, but it
does mean nobody can add a way to decline without being sent to this test
to say what it should lead to.

* build(deps): pin the service protocol to v2.6.1

The pin tracked `feat/stage-runtime` because the protocol change it
carried had no tag yet. It has one now, and a branch pin makes the build
irreproducible: an upstream force-push silently changes what this
compiles against.

v2.6.1 carries the runtime generation an owner keeps across restarts,
which is what lets the core hold on to the `cache.db` its node selections
and fake-ip leases live in. The protocol itself is untouched — epoch,
revision and both compatibility floors are byte-identical to v2.6.0 — so
this is a fix release in both directions: an older service still works
with this app, and this app still works with an older service, only
without the durable generation.

The lockfile change is the two lines that name it. Reaching for
`cargo update -p` instead re-resolved a subtree and quietly downgraded
`windows-sys`, `windows-core` and `getrandom` along the way.
This commit is contained in:
wonfen
2026-07-29 07:47:52 +08:00
committed by GitHub
parent 5590f6ab15
commit 3d8d7731e1
15 changed files with 564 additions and 58 deletions
Generated
+2 -2
View File
@@ -1241,8 +1241,8 @@ dependencies = [
[[package]]
name = "clash_verge_service_ipc"
version = "2.6.0"
source = "git+https://github.com/clash-verge-rev/clash-verge-service-ipc.git?branch=feat%2Fstage-runtime#9fae5797ad65b8a3a2ba16eacee1e606770ae57b"
version = "2.6.1"
source = "git+https://github.com/clash-verge-rev/clash-verge-service-ipc.git?tag=v2.6.1#10edb52ec071520f14ce78654e401e4246127d75"
dependencies = [
"anyhow",
"compact_str",
+1 -2
View File
@@ -94,8 +94,7 @@ tauri-plugin-devtools = { version = "2.1.0" }
tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", branch = "refactor-use-reqwest-ipc" }
clash_verge_logger = { git = "https://github.com/clash-verge-rev/clash-verge-logger" }
async-trait = "0.1.91"
# Tracks a branch until the protocol change it carries is tagged; move this to v2.6.0 then.
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", branch = "feat/stage-runtime", version = "2.6.0", features = [
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.6.1", version = "2.6.1", features = [
"client",
] }
arc-swap = "1.9.2"
+8
View File
@@ -361,10 +361,18 @@ pub async fn patch_profile(index: String, profile: PrfItem) -> CmdResult {
false
};
// A selection written from the UI or the chain proxy is newer than anything a restore still
// in flight captured; without this it would be pushed back to the older node moments later.
let records_a_selection = profile.selected.is_some();
profiles_patch_item_safe(&index, &profile)
.await
.with_error_code("PROFILE_UPDATE_FAILED")?;
if records_a_selection {
profiles::supersede_selected_activation();
}
// 如果更新间隔或允许自动更新变更,异步刷新定时器
if should_refresh_timer {
crate::process::AsyncHandler::spawn(move || async move {
+16
View File
@@ -16,6 +16,22 @@ use std::{
sync::atomic::{AtomicBool, Ordering},
};
/// Record which node a group is on, in the current profile.
///
/// Takes the group and the node rather than the whole selection list, and merges them into the
/// profile on this side. The frontend used to send the list it had rendered, which made two
/// selections made before that list refreshed into one overwriting the other: both were built
/// from the same stale array, and the later write dropped the earlier group's choice. Since a
/// core start re-applies whatever the profile holds, the dropped one then came back on restart.
///
/// The tray already recorded this way. Now there is one way.
#[tauri::command]
pub async fn record_selected_node(group_name: String, node: String) -> CmdResult<()> {
crate::config::profiles::record_selected_node(&group_name, &node)
.await
.stringify_err()
}
static TRAY_SYNC_RUNNING: AtomicBool = AtomicBool::new(false);
static TRAY_SYNC_PENDING: AtomicBool = AtomicBool::new(false);
+354 -4
View File
@@ -22,7 +22,7 @@ use std::{
LazyLock,
atomic::{AtomicU64, Ordering},
},
time::Duration,
time::{Duration, Instant},
};
use tauri_plugin_mihomo::models::{Proxies, ProxyType};
use tokio::{fs, task::JoinHandle};
@@ -47,6 +47,21 @@ static ACTIVATE_SELECTED_GENERATION: AtomicU64 = AtomicU64::new(0);
// lock acquisition, connection-pool waiting, and local-socket connection establishment.
const MIHOMO_OPERATION_TIMEOUT: Duration = Duration::from_secs(10);
const SELECTED_NODES_RECHECK_DELAY: Duration = Duration::from_secs(1);
/// How long a restore keeps trying to put back selections the core has not loaded yet.
///
/// A provider-backed group is present but empty until its provider finishes loading, which on a
/// cold start is exactly when restoring runs. Bounded rather than indefinite because a record
/// naming a node the profile genuinely no longer has looks identical from here, and would
/// otherwise be retried for the life of the process.
const SELECTED_NODES_SETTLE_DEADLINE: Duration = Duration::from_secs(30);
/// How often a restore looks again while waiting for those groups.
const SELECTED_NODES_SETTLE_INTERVAL: Duration = Duration::from_secs(1);
/// How long a start waits for the selections that *can* be put back before carrying on.
///
/// Covers a healthy core answering one query and a handful of selects. Past that the start
/// proceeds: the remaining groups need the core to finish loading, which is not something a
/// start can usefully wait for.
const SELECTED_NODES_FIRST_PASS_BUDGET: Duration = Duration::from_secs(3);
/// Define the `profiles.yaml` schema
#[derive(Default, Debug, Clone, Deserialize, Serialize)]
@@ -751,6 +766,21 @@ fn reconcile_selected_nodes(
plan
}
/// Abandon any activation still in flight.
///
/// An activation reads the profile, then polls the core until its groups are readable, then puts
/// its selections. A choice the user makes inside that window is newer than what the activation
/// captured, so letting it finish would push the core back to the older node — and the profile,
/// already holding the newer one, would then disagree with the core. Bumping the generation is
/// how an activation is told it has been overtaken.
///
/// Called *after* the profile is written, which is the only placement that needs to exist: an
/// activation that read the older profile is cancelled by this, and one starting afterwards reads
/// the newer one and needs no cancelling.
pub fn supersede_selected_activation() {
ACTIVATE_SELECTED_GENERATION.fetch_add(1, Ordering::AcqRel);
}
fn is_activation_current(generation: u64) -> bool {
ACTIVATE_SELECTED_GENERATION.load(Ordering::Acquire) == generation
}
@@ -847,6 +877,59 @@ async fn update_tray_after_activation(generation: u64) {
}
}
/// Record which node a group is on, in the current profile.
///
/// The counterpart of the frontend's `useRecordSelection`, for the selections the backend makes
/// on the user's behalf — the tray is the one that does. What the profile holds is what
/// [`activate_selected_nodes`] re-applies when a core starts, so a selection it never learned
/// about is one the next start silently undoes.
pub async fn record_selected_node(group_name: &str, node: &str) -> Result<()> {
let group_name = String::from(group_name);
let node = String::from(node);
let recorded = Config::profiles()
.await
.with_data_modify(move |mut profiles| async move {
let Some(current) = profiles.current.clone() else {
return Ok((profiles, false));
};
let Some(item) = profiles
.items
.as_mut()
.and_then(|items| items.iter_mut().find(|item| item.uid.as_ref() == Some(&current)))
else {
return Ok((profiles, false));
};
let mut selected = item.selected.clone().unwrap_or_default();
match selected
.iter_mut()
.find(|entry| entry.name.as_ref() == Some(&group_name))
{
Some(entry) => {
if entry.now.as_ref() == Some(&node) {
return Ok((profiles, false));
}
entry.now = Some(node);
}
None => selected.push(PrfSelected {
name: Some(group_name),
now: Some(node),
}),
}
item.selected = Some(selected);
profiles.save_file().await?;
Ok((profiles, true))
})
.await?;
if recorded {
// Newer than anything an activation still in flight captured.
supersede_selected_activation();
handle::Handle::refresh_profiles();
}
Ok(())
}
async fn persist_reconciled_selected(
profile_uid: &String,
original_selected: &[PrfSelected],
@@ -887,10 +970,104 @@ async fn persist_reconciled_selected(
Ok(())
}
/// The recorded selections the core is not currently on.
///
/// Deliberately "is the core on it" rather than "did we send a select": a group whose provider
/// has not loaded is present but empty, a `select` can fail while the core is still settling, and
/// a group that was already correct never produces an activation at all. Only the running state
/// tells those apart.
fn unsettled_selections(selected: &[PrfSelected], proxies: &Proxies) -> Vec<String> {
selected
.iter()
.filter_map(|item| {
let (Some(group_name), Some(node)) = (&item.name, &item.now) else {
return None;
};
match proxies.proxies.get(group_name.as_str()) {
Some(group) if group.now.as_deref() == Some(node.as_str()) => None,
_ => Some(group_name.clone()),
}
})
.collect()
}
/// Keep putting back the selections the core could not be moved to yet.
///
/// The single re-check a profile switch needs is enough there, because a switch happens once the
/// configuration is loaded. A core start is the opposite case: the groups a restore is trying to
/// put back may not exist yet. Without this the restore reported success having applied nothing,
/// and the group stayed on the first entry of its `proxies:` list — the outcome restoring exists
/// to prevent.
///
/// Only ever reached with [`SelectionRepair::KeepRecords`]: a profile switch is entitled to
/// conclude that a group it cannot see is gone, so it has nothing to wait for.
async fn settle_pending_selections(selected: &[PrfSelected], completed: &mut HashMap<String, String>, generation: u64) {
let deadline = Instant::now() + SELECTED_NODES_SETTLE_DEADLINE;
loop {
tokio::time::sleep(SELECTED_NODES_SETTLE_INTERVAL).await;
if !is_activation_current(generation) {
return;
}
let Ok(snapshot) = fetch_proxies_with_timeout().await else {
// Unreachable core: the deadline still applies, so this cannot spin forever.
if Instant::now() >= deadline {
return;
}
continue;
};
if !is_activation_current(generation) {
return;
}
let pending = unsettled_selections(selected, &snapshot);
if pending.is_empty() {
return;
}
if Instant::now() >= deadline {
logging!(
warn,
Type::Config,
"gave up putting back {} selected node(s) the core never loaded: {}",
pending.len(),
pending.iter().map(String::as_str).collect::<Vec<_>>().join(", ")
);
return;
}
let plan = reconcile_selected_nodes(selected, None, &snapshot);
if apply_activations(&plan.activations, completed, generation)
.await
.is_none()
{
return;
}
if is_activation_current(generation) {
handle::Handle::refresh_clash();
}
}
}
/// Fires once the selections that could be applied have been.
///
/// Dropping it without firing releases the waiter too: every way the worker can end early is a
/// way of saying "nothing more is coming", and a start that waits forever for one of them would
/// be a worse failure than the one this exists to prevent.
struct FirstPassSignal(Option<tokio::sync::oneshot::Sender<()>>);
impl FirstPassSignal {
fn notify(&mut self) {
if let Some(sender) = self.0.take() {
let _ = sender.send(());
}
}
}
async fn activate_selected_nodes_worker(
profile_uid: String,
selected: Vec<PrfSelected>,
generation: u64,
repair: SelectionRepair,
mut first_pass_done: FirstPassSignal,
) -> Result<()> {
let first_snapshot = fetch_proxies_with_timeout().await?;
if !is_activation_current(generation) {
@@ -948,6 +1125,14 @@ async fn activate_selected_nodes_worker(
return Ok(());
}
if repair == SelectionRepair::KeepRecords {
// Everything the core could be moved to has been. Whoever is waiting to point traffic
// here may go; what is left needs the core to finish loading and cannot be waited on.
first_pass_done.notify();
settle_pending_selections(&selected, &mut completed_activations, generation).await;
return Ok(());
}
if plan.repaired_count > 0 && is_activation_current(generation) {
logging!(
info,
@@ -961,13 +1146,64 @@ async fn activate_selected_nodes_worker(
Ok(())
}
/// Whether an activation may also prune the records it cannot match.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum SelectionRepair {
/// Drop records whose group or node the core does not have. Only safe once the configuration
/// they belong to is known to be fully loaded, which is true right after a profile switch.
Prune,
/// Apply what can be applied and leave the records alone.
///
/// What a core start needs. A provider-backed group can be empty or absent for seconds after
/// the core comes up, and a record pruned on that evidence is gone from `profiles.yaml` for
/// good — the provider finishing later cannot bring it back.
KeepRecords,
}
pub fn activate_selected_nodes() -> Result<()> {
// The first-pass signal is for callers that wait; a profile switch does not, and dropping
// the receiver simply makes the send a no-op.
drop(activate_selected_nodes_with(SelectionRepair::Prune));
Ok(())
}
/// Put the profile's selections back before anything is pointed at the core.
///
/// Awaited, unlike a profile switch's activation, because the caller enables the system proxy as
/// soon as this returns: a proxy pointed at a core still sitting on the first entry of every
/// group is the outcome restoring exists to prevent. Bounded so that a core which will not answer
/// delays a start rather than blocking it — the selections keep being retried either way, and
/// being proxied through the wrong node beats not being proxied at all.
///
/// TUN is deliberately not covered, because it cannot be: it is configured in the core's own
/// file, so it is carrying traffic from the moment the process starts, before anything here could
/// run. What puts a TUN user on the right node from the first packet is the core's own
/// `cache.db`, which is why the service keeps an owner's runtime generation across restarts.
pub async fn restore_selected_nodes() {
let first_pass = activate_selected_nodes_with(SelectionRepair::KeepRecords);
if tokio::time::timeout(SELECTED_NODES_FIRST_PASS_BUDGET, first_pass)
.await
.is_err()
{
logging!(
warn,
Type::Config,
"starting without having put the selected nodes back yet; still trying"
);
}
}
fn activate_selected_nodes_with(repair: SelectionRepair) -> tokio::sync::oneshot::Receiver<()> {
logging!(info, Type::Config, "starting activating selected nodes");
let mut active_task = ACTIVATE_SELECTED_TASK.lock();
let generation = ACTIVATE_SELECTED_GENERATION.fetch_add(1, Ordering::AcqRel) + 1;
let previous_task = active_task.take();
let (first_pass_sender, first_pass_done) = tokio::sync::oneshot::channel();
let handle = tokio::spawn(async move {
// Held for the whole task so that every exit — superseded, no profile, an error — drops
// it and releases anyone waiting. Only the worker fires it deliberately.
let first_pass = FirstPassSignal(Some(first_pass_sender));
if let Some(previous_task) = previous_task {
let _ = previous_task.await;
}
@@ -976,7 +1212,12 @@ pub fn activate_selected_nodes() -> Result<()> {
}
let result = async {
let profiles = Config::profiles().await.latest_arc();
// Committed, not the draft. A profile switch stages its target before validating
// it, and a switch that then fails discards that draft — but an activation which had
// already read it would go on to apply the rejected profile's selections to the one
// still running. The switch that succeeds commits before it activates, so this is the
// same value there.
let profiles = Config::profiles().await.data_arc();
let current = profiles.get_current().context("no current profile running")?.clone();
let selected = profiles
.get_item(&current)
@@ -991,7 +1232,7 @@ pub fn activate_selected_nodes() -> Result<()> {
}
return Ok(());
}
activate_selected_nodes_worker(current, selected, generation).await
activate_selected_nodes_worker(current, selected, generation, repair, first_pass).await
}
.await;
@@ -1007,7 +1248,7 @@ pub fn activate_selected_nodes() -> Result<()> {
});
*active_task = Some(handle);
drop(active_task);
Ok(())
first_pass_done
}
#[cfg(test)]
@@ -1042,6 +1283,115 @@ mod tests {
}
}
#[test]
fn a_group_the_core_has_not_loaded_is_still_unsettled() {
// The regression this pins. A provider-backed group is present but empty until its
// provider loads, which on a cold start is exactly when restoring runs. Reconciling
// produces no activation for it, so a restore that stopped after one re-check reported
// success having left the group on the first entry of its `proxies:` list.
let saved = vec![selected("provider-group", "saved")];
let unloaded = proxies(vec![("provider-group", &[], None)]);
assert!(
reconcile_selected_nodes(&saved, Some(&unloaded), &unloaded)
.activations
.is_empty(),
"nothing can be activated while the group is empty"
);
assert_eq!(
unsettled_selections(&saved, &unloaded),
vec![String::from("provider-group")],
"so the restore must keep looking rather than call it done"
);
}
#[test]
fn a_group_the_core_is_already_on_is_settled() {
let saved = vec![selected("Proxy", "Node A")];
let loaded = proxies(vec![("Proxy", &["Node A", "Node B"], Some("Node A"))]);
assert!(
unsettled_selections(&saved, &loaded).is_empty(),
"a group already on its node needs nothing, even though it produces no activation"
);
}
#[test]
fn a_group_on_the_wrong_node_is_unsettled() {
// Covers a `select` that failed transiently: the group is loaded, the node exists, and
// the core is simply not on it. Retrying is the only thing that fixes that.
let saved = vec![selected("Proxy", "Node A")];
let wrong = proxies(vec![("Proxy", &["Node A", "Node B"], Some("Node B"))]);
assert_eq!(unsettled_selections(&saved, &wrong), vec![String::from("Proxy")]);
}
#[test]
fn a_record_without_a_group_or_node_is_not_waited_on() {
// A malformed record cannot be satisfied by waiting, and holding the settle loop open
// for it would delay giving up on everything else.
let malformed = vec![
PrfSelected {
name: Some("Proxy".into()),
now: None,
},
PrfSelected {
name: None,
now: Some("Node A".into()),
},
];
assert!(unsettled_selections(&malformed, &proxies(vec![])).is_empty());
}
#[test]
fn a_group_missing_from_both_snapshots_is_dropped_from_the_records() {
// This is the pruning a profile switch wants and a core start must not do: the record is
// gone from the plan, and `persist_reconciled_selected` writes the plan back. Pinned here
// because it is why `SelectionRepair` exists — the predicate is right, the question is
// only who is entitled to act on it.
let saved = vec![selected("provider-group", "saved")];
let empty = proxies(vec![]);
let plan = reconcile_selected_nodes(&saved, Some(&empty), &empty);
assert!(
plan.selected.is_empty(),
"a group absent from both looks invalid, so the record is dropped"
);
assert_eq!(plan.repaired_count, 1, "and dropping it counts as a repair");
}
#[test]
fn a_group_missing_from_only_the_second_snapshot_is_kept() {
// Absent once is not evidence: only a group that was already absent when the first
// snapshot was taken is treated as gone.
let saved = vec![selected("provider-group", "saved")];
let had_it = proxies(vec![("provider-group", &["saved"], Some("saved"))]);
let lost_it = proxies(vec![]);
let plan = reconcile_selected_nodes(&saved, Some(&had_it), &lost_it);
assert_eq!(plan.selected, saved);
assert_eq!(plan.repaired_count, 0);
}
#[test]
fn superseding_an_activation_makes_the_one_in_flight_stand_down() {
// What a selection made during a start relies on: the restore that is still polling the
// core has to notice it has been overtaken, or it will push the older node back.
let generation = ACTIVATE_SELECTED_GENERATION.load(Ordering::Acquire) + 1;
ACTIVATE_SELECTED_GENERATION.store(generation, Ordering::Release);
assert!(is_activation_current(generation));
supersede_selected_activation();
assert!(
!is_activation_current(generation),
"an activation that has been overtaken must stop before it puts anything"
);
}
#[test]
fn keeps_valid_selection_and_activates_when_needed() {
let saved = vec![selected("group", "saved")];
+21 -3
View File
@@ -404,6 +404,22 @@ mod tests {
);
}
/// Wrap a rejection as the answer the Service would have sent.
///
/// The `match` is the point, not the wrapping: it is exhaustive, so a new way for the Service
/// to decline cannot be added upstream without this failing to compile — which is what sends
/// whoever adds it to the test below to say what it should lead to. `CoreRestarted` reached
/// production without one, and inherited its verdict silently.
fn declined(reason: StageRejection) -> StageAttempt {
match &reason {
StageRejection::CoreNotRunning
| StageRejection::CorePathChanged
| StageRejection::RuntimeUnwritable { .. }
| StageRejection::CoreRestarted => {}
}
StageAttempt::Answered(StageRuntimeOutcome::RestartRequired { reason })
}
#[test]
fn a_service_that_declined_makes_the_core_be_replaced() {
for reason in [
@@ -412,11 +428,13 @@ mod tests {
StageRejection::RuntimeUnwritable {
detail: "held open".to_owned(),
},
// The service's own watchdog can restart the core mid-staging, which leaves the
// generation holding a mixture no manifest describes. Rebuilding it from nothing is
// the only answer that does not guess at what is in there.
StageRejection::CoreRestarted,
] {
let attempt = StageAttempt::Answered(StageRuntimeOutcome::RestartRequired { reason: reason.clone() });
assert_eq!(
plan_config_application(&attempt),
plan_config_application(&declined(reason.clone())),
ConfigApplication::ReplaceCore,
"declining is an outcome, not an error: {reason:?} must fall back, not fail"
);
+26
View File
@@ -18,6 +18,29 @@ use tauri_plugin_mihomo::MihomoExt as _;
use tauri_plugin_shell::ShellExt as _;
const SIDECAR_READINESS_ATTEMPTS: usize = 30;
impl CoreManager {
/// A core process is up: put back the node selections the user made.
///
/// mihomo restores these itself, from the `cache.db` it keeps in the directory it was started
/// against — but only when `profile.store-selected` is on and that file survived, and neither
/// is something the app can assume. The setting comes from a merge template a user is free to
/// replace, and a service older than the durable runtime generation hands every start a
/// directory nothing has ever run in. The app holds the same selections in the profile, so it
/// is the one that can say for certain.
///
/// Awaited, and deliberately here rather than beside the proxy: the caller enables the system
/// proxy as soon as the start returns, and pointing it at a core still on the first entry of
/// every group is what this exists to prevent. The wait is bounded — what cannot be put back
/// yet keeps being retried in the background — so a core that will not answer delays a start
/// instead of blocking it.
///
/// Repeat calls supersede each other, so every start path may call this without coordinating.
async fn restore_selected_nodes(&self) {
crate::config::profiles::restore_selected_nodes().await;
}
}
const SIDECAR_READINESS_INTERVAL: std::time::Duration = std::time::Duration::from_millis(100);
const SIDECAR_READINESS_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(400);
@@ -160,6 +183,7 @@ impl CoreManager {
self.set_running_child_sidecar(child);
let core_readiness_generation = self.mark_core_ready();
self.core_started(RunningMode::Sidecar);
self.restore_selected_nodes().await;
AsyncHandler::spawn(move || async move {
while let Some(event) = rx.recv().await {
@@ -250,6 +274,7 @@ impl CoreManager {
Ok(()) => {
self.mark_core_ready();
self.core_started(RunningMode::Service);
self.restore_selected_nodes().await;
return Ok(());
}
Err(e) => {
@@ -274,6 +299,7 @@ impl CoreManager {
service::run_core_by_service(config_file).await?;
self.mark_core_ready();
self.core_started(RunningMode::Service);
self.restore_selected_nodes().await;
Ok(())
}
}
+19
View File
@@ -17,6 +17,23 @@ pub async fn toggle_proxy_profile(profile_index: String) {
);
}
/// Tell the profile which node this group is on now.
///
/// The core is already switched by the time this runs, so failing to record is not a reason to
/// report the switch as failed — but it is worth a log line, because what the profile holds is
/// what gets re-applied the next time a core starts.
async fn record_switched_node(group_name: &str, proxy_name: &str) {
if let Err(error) = crate::config::profiles::record_selected_node(group_name, proxy_name).await {
logging!(
warn,
Type::Tray,
"切换代理成功但未能记录到配置: {} -> {}, 错误: {error:#}",
group_name,
proxy_name
);
}
}
pub async fn switch_proxy_node(group_name: &str, proxy_name: &str) {
match handle::Handle::mihomo()
.select_node_for_group(group_name, proxy_name)
@@ -24,6 +41,7 @@ pub async fn switch_proxy_node(group_name: &str, proxy_name: &str) {
{
Ok(_) => {
logging!(info, Type::Tray, "切换代理成功: {} -> {}", group_name, proxy_name);
record_switched_node(group_name, proxy_name).await;
let _ = handle::Handle::app_handle().emit("verge://refresh-proxy-config", ());
let _ = tray::Tray::global().update_menu().await;
return;
@@ -46,6 +64,7 @@ pub async fn switch_proxy_node(group_name: &str, proxy_name: &str) {
{
Ok(_) => {
logging!(info, Type::Tray, "代理切换回退成功: {} -> {}", group_name, proxy_name);
record_switched_node(group_name, proxy_name).await;
let _ = tray::Tray::global().update_menu().await;
}
Err(err) => {
+1
View File
@@ -164,6 +164,7 @@ mod app_init {
cmd::invoke_uwp_tool,
cmd::copy_clash_env,
cmd::sync_tray_proxy_selection,
cmd::record_selected_node,
cmd::save_dns_config,
cmd::apply_dns_config,
cmd::check_dns_config_exists,
+3 -8
View File
@@ -382,21 +382,16 @@ export const CurrentProxyCard = () => {
}
const previousProxy = selectedGroup.now
const nextName = option.member.ref.name
// Recorded like any other selection, global mode included: what the profile holds is
// what gets re-applied when a core starts, so a choice it never learns about is undone.
handleSelectChange(
selectedGroup.name,
previousProxy,
isGlobalMode,
)({
target: { value: nextName },
})
},
[
handleSelectChange,
isDirectMode,
isGlobalMode,
selectedGroup,
unsortedProxyOptions,
],
[handleSelectChange, isDirectMode, selectedGroup, unsortedProxyOptions],
)
// 导航到代理页面
+8
View File
@@ -43,6 +43,7 @@ import {
import { TooltipIcon } from '@/components/base'
import { useRuntimeConfig } from '@/hooks/use-clash'
import { useRecordSelection } from '@/hooks/use-record-selection'
import { useAppRefreshers, useProxiesData } from '@/providers/app-data-context'
import { updateProxyChainConfigInRuntime } from '@/services/cmds'
import {
@@ -259,6 +260,7 @@ export const ProxyChain = ({
const { refreshProxy } = useAppRefreshers()
const { data: runtimeConfig } = useRuntimeConfig(true)
const [isConnecting, setIsConnecting] = useState(false)
const recordSelection = useRecordSelection()
const markUnsavedChanges = useCallback(() => {
onMarkUnsavedChanges?.()
}, [onMarkUnsavedChanges])
@@ -379,10 +381,12 @@ export const ProxyChain = ({
if (targetGroup) {
try {
await selectNodeForGroup(targetGroup, 'DIRECT')
recordSelection(targetGroup, 'DIRECT')
} catch {
if (currentProxyChain.length >= 1) {
try {
await selectNodeForGroup(targetGroup, currentProxyChain[0].name)
recordSelection(targetGroup, currentProxyChain[0].name)
} catch {
// ignore
}
@@ -440,6 +444,9 @@ export const ProxyChain = ({
const targetGroup = mode === 'global' ? 'GLOBAL' : selectedGroup
await selectNodeForGroup(targetGroup || 'GLOBAL', lastNode.name)
// The chain moves the group like any other selection, so the profile has to learn about
// it: what the profile holds is what gets re-applied the next time the core starts.
recordSelection(targetGroup || 'GLOBAL', lastNode.name)
localStorage.setItem('proxy-chain-group', targetGroup || 'GLOBAL')
localStorage.setItem('proxy-chain-exit-node', lastNode.name)
@@ -461,6 +468,7 @@ export const ProxyChain = ({
proxyView,
selectedGroup,
onUpdateChain,
recordSelection,
])
// 处理链式代理配置数据
+8 -39
View File
@@ -5,7 +5,7 @@ import {
selectNodeForGroup,
} from 'tauri-plugin-mihomo-api'
import { useProfiles } from '@/hooks/use-profiles'
import { useRecordSelection } from '@/hooks/use-record-selection'
import { useVerge } from '@/hooks/use-verge'
import { syncTrayProxySelection } from '@/services/cmds'
import { debugLog } from '@/utils/debug'
@@ -37,12 +37,11 @@ interface ProxyChangeRequest {
groupName: string
proxyName: string
previousProxy?: string
skipConfigSave: boolean
}
// 代理选择 Hook
export const useProxySelection = (options: ProxySelectionOptions = {}) => {
const { current, patchCurrent } = useProfiles()
const recordSelection = useRecordSelection()
const { verge } = useVerge()
const pendingRequestRef = useRef<ProxyChangeRequest | null>(null)
const isProcessingRef = useRef(false)
@@ -65,36 +64,16 @@ export const useProxySelection = (options: ProxySelectionOptions = {}) => {
})
}, [])
const persistSelection = useCallback(
(groupName: string, proxyName: string, skipConfigSave: boolean) => {
if (!current || skipConfigSave) return
const selected = current.selected ? [...current.selected] : []
const index = selected.findIndex((item) => item.name === groupName)
if (index < 0) {
selected.push({ name: groupName, now: proxyName })
} else {
selected[index] = { name: groupName, now: proxyName }
}
patchCurrent({ selected }).catch((error) => {
console.error('[ProxySelection] 保存代理选择失败:', error)
})
},
[current, patchCurrent],
)
const executeChange = useCallback(
async (request: ProxyChangeRequest) => {
const { groupName, proxyName, previousProxy, skipConfigSave } = request
const { groupName, proxyName, previousProxy } = request
debugLog(`[ProxySelection] 代理切换: ${groupName} -> ${proxyName}`)
try {
await selectNodeForGroup(groupName, proxyName)
onSuccess?.()
syncTraySelection()
persistSelection(groupName, proxyName, skipConfigSave)
recordSelection(groupName, proxyName)
debugLog(
`[ProxySelection] 代理和状态同步完成: ${groupName} -> ${proxyName}`,
)
@@ -114,7 +93,7 @@ export const useProxySelection = (options: ProxySelectionOptions = {}) => {
onError?.(error)
}
},
[config, onError, onSuccess, persistSelection, syncTraySelection],
[config, onError, onSuccess, recordSelection, syncTraySelection],
)
const flushChangeQueue = useCallback(async () => {
@@ -136,17 +115,11 @@ export const useProxySelection = (options: ProxySelectionOptions = {}) => {
}, [executeChange])
const changeProxy = useCallback(
(
groupName: string,
proxyName: string,
previousProxy?: string,
skipConfigSave: boolean = false,
) => {
(groupName: string, proxyName: string, previousProxy?: string) => {
pendingRequestRef.current = {
groupName,
proxyName,
previousProxy,
skipConfigSave,
}
void flushChangeQueue()
},
@@ -154,14 +127,10 @@ export const useProxySelection = (options: ProxySelectionOptions = {}) => {
)
const handleSelectChange = useCallback(
(
groupName: string,
previousProxy?: string,
skipConfigSave: boolean = false,
) =>
(groupName: string, previousProxy?: string) =>
(event: { target: { value: string } }) => {
const newProxy = event.target.value
changeProxy(groupName, newProxy, previousProxy, skipConfigSave)
changeProxy(groupName, newProxy, previousProxy)
},
[changeProxy],
)
+59
View File
@@ -0,0 +1,59 @@
// @vitest-environment jsdom
import { renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useRecordSelection } from './use-record-selection'
const recordSelectedNode = vi.hoisted(() => vi.fn(() => Promise.resolve()))
vi.mock('@/services/cmds', () => ({ recordSelectedNode }))
const record = (groupName: string, proxyName: string) => {
const { result } = renderHook(() => useRecordSelection())
result.current(groupName, proxyName)
}
beforeEach(() => {
recordSelectedNode.mockClear()
})
describe('useRecordSelection', () => {
it('sends only the group and the node', () => {
record('Proxy', 'Node A')
expect(recordSelectedNode).toHaveBeenCalledWith('Proxy', 'Node A')
})
it('does not derive anything from a rendered selection list', () => {
// The regression this pins. It used to build the whole list from the profile it had
// rendered, so two selections made before that list refreshed were both derived from the
// same stale snapshot and the later one dropped the earlier group. Since a core start
// re-applies whatever the profile holds, the dropped choice came back on the next restart.
// Each call now carries one group, and the backend merges against the current profile.
record('Proxy', 'Node A')
record('Fallback', 'Node C')
expect(recordSelectedNode).toHaveBeenNthCalledWith(1, 'Proxy', 'Node A')
expect(recordSelectedNode).toHaveBeenNthCalledWith(2, 'Fallback', 'Node C')
})
it('is stable across renders, so callers may hold it in a dependency array', () => {
const { result, rerender } = renderHook(() => useRecordSelection())
const first = result.current
rerender()
expect(result.current).toBe(first)
})
it('reports a failed write without throwing at the caller', async () => {
// Recording runs after the core has already been switched; failing the switch over it would
// be wrong, so the caller is never made to handle it.
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
recordSelectedNode.mockRejectedValueOnce(new Error('no profile'))
expect(() => record('Proxy', 'Node A')).not.toThrow()
await vi.waitFor(() => expect(consoleError).toHaveBeenCalled())
})
})
+28
View File
@@ -0,0 +1,28 @@
import { useCallback } from 'react'
import { recordSelectedNode } from '@/services/cmds'
/**
* Record which node a group is on, in the profile.
*
* The core keeps its own copy in `cache.db` and restores it when it starts, but neither half of
* that is something the app can rely on: `profile.store-selected` comes from a merge template a
* user is free to replace, and a service older than the durable runtime generation starts every
* core in a directory nothing has ever run in. So the app re-applies what the profile says once
* the core is up.
*
* Which makes this the rule: a selection the profile does not know about is one the next start
* will undo. Every path that moves a group has to come through here.
*
* Only the group and the node are sent. Sending the whole selection list — which is what this
* did while it built the array from `useProfiles().current` — made two selections made before
* that list refreshed into one overwriting the other, because both were derived from the same
* stale snapshot. The merge happens on the backend, against whatever the profile holds by then.
*/
export const useRecordSelection = () => {
return useCallback((groupName: string, proxyName: string) => {
recordSelectedNode(groupName, proxyName).catch((error) => {
console.error('[Selection] 保存代理选择失败:', error)
})
}, [])
}
+10
View File
@@ -125,6 +125,16 @@ export async function syncTrayProxySelection() {
return invoke<void>('sync_tray_proxy_selection')
}
/**
* Record which node a group is on, in the current profile.
*
* Group and node, not the whole selection list: the merge happens in the backend against the
* profile as it stands, so two selections made in quick succession cannot overwrite each other.
*/
export async function recordSelectedNode(groupName: string, node: string) {
return invoke<void>('record_selected_node', { groupName, node })
}
export async function getProxyView(): Promise<ProxyViewV1> {
const view = await invoke<ProxyViewV1>('get_proxy_view')
if (view.schemaVersion !== 1) {