mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-11-15 15:32:21 +08:00
chore: merge declarative effects into frontend contract flow
This commit is contained in:
@@ -34,7 +34,10 @@ async fn execute_service_operation_sync(status: ServiceStatus, error_code: &str)
|
||||
SERVICE_MANAGER.handle_service_status(status).await
|
||||
};
|
||||
#[cfg(windows)]
|
||||
return finish_service_installation(result, error_code, || manager.continue_with_sidecar()).await;
|
||||
return finish_service_installation(result, error_code, |reason| async move {
|
||||
manager.continue_with_sidecar(Some(&reason)).await
|
||||
})
|
||||
.await;
|
||||
|
||||
#[cfg(not(windows))]
|
||||
result
|
||||
@@ -49,7 +52,7 @@ async fn finish_service_installation<Fallback, FallbackFuture>(
|
||||
fallback: Fallback,
|
||||
) -> CmdResult<ServiceInstallOutcome>
|
||||
where
|
||||
Fallback: FnOnce() -> FallbackFuture,
|
||||
Fallback: FnOnce(String) -> FallbackFuture,
|
||||
FallbackFuture: std::future::Future<Output = anyhow::Result<()>>,
|
||||
{
|
||||
let Err(error) = installation else {
|
||||
@@ -72,12 +75,9 @@ where
|
||||
return Err(proxy_aware_coded_error(&error, error_code));
|
||||
}
|
||||
let reason = reasons.join("\n");
|
||||
fallback().await.map_err(|error| {
|
||||
proxy_aware_coded_error(
|
||||
&error.context(format!("Service core rejected: {reason}; Sidecar fallback failed")),
|
||||
"SERVICE_SIDECAR_FAILED",
|
||||
)
|
||||
})?;
|
||||
fallback(reason.clone())
|
||||
.await
|
||||
.map_err(|error| proxy_aware_coded_error(&error, "SERVICE_SIDECAR_FAILED"))?;
|
||||
Ok(ServiceInstallOutcome::Sidecar { reason })
|
||||
}
|
||||
|
||||
@@ -107,11 +107,16 @@ pub async fn repair_service() -> CmdResult<ServiceInstallOutcome> {
|
||||
#[tauri::command]
|
||||
pub async fn continue_with_sidecar() -> CmdResult {
|
||||
crate::core::CoreManager::global()
|
||||
.continue_with_sidecar()
|
||||
.continue_with_sidecar(None)
|
||||
.await
|
||||
.map_err(|error| proxy_aware_coded_error(&error, "SERVICE_SIDECAR_FAILED"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_sidecar_failure() -> crate::core::notification::SidecarFailureSnapshot {
|
||||
crate::core::notification::sidecar_failure_snapshot()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn take_service_fallback_notice() -> Option<crate::core::service::ServiceFallbackNotice> {
|
||||
crate::core::service::take_service_fallback_notice()
|
||||
@@ -175,7 +180,7 @@ mod tests {
|
||||
None => anyhow::anyhow!("{reason}"),
|
||||
}
|
||||
.context("install service failed");
|
||||
let result = finish_service_installation(Err(error), "SERVICE_INSTALL_FAILED", || async {
|
||||
let result = finish_service_installation(Err(error), "SERVICE_INSTALL_FAILED", |_| async {
|
||||
fallback_called.set(true);
|
||||
anyhow::ensure!(fallback_ready, "another core is running");
|
||||
Ok(())
|
||||
@@ -201,7 +206,6 @@ mod tests {
|
||||
})
|
||||
);
|
||||
if rejected {
|
||||
assert!(error.detail.contains(reason));
|
||||
assert!(error.detail.contains("another core is running"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ pub mod files {
|
||||
}
|
||||
|
||||
pub mod tun {
|
||||
pub const DEFAULT_STACK: &str = "gvisor";
|
||||
pub const DEFAULT_STACK: &str = "mips";
|
||||
|
||||
pub const DNS_HIJACK: &[&str] = &["any:53"];
|
||||
|
||||
|
||||
@@ -334,6 +334,18 @@ enum HandoffOutcome {
|
||||
Failed,
|
||||
}
|
||||
|
||||
fn report_sidecar_failure(error: anyhow::Error, service_rejection: Option<&str>) -> anyhow::Error {
|
||||
let error = match service_rejection {
|
||||
Some(reason) => error.context(format!("Service core rejected: {reason}; Sidecar fallback failed")),
|
||||
None => error,
|
||||
};
|
||||
// Proxy failures have their own pending state and recovery dialog.
|
||||
if SysproxyFailure::from_chain(&error).is_none() {
|
||||
crate::core::notification::record_sidecar_failure(format!("{error:#}").into());
|
||||
}
|
||||
error
|
||||
}
|
||||
|
||||
impl CoreManager {
|
||||
async fn rollback_failed_start(&self) {
|
||||
proxy_control::stop_guard().await;
|
||||
@@ -364,9 +376,12 @@ impl CoreManager {
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all, level = "info", fields(status = tracing::field::Empty, tun_disabled = false, readiness_generation = tracing::field::Empty))]
|
||||
pub async fn continue_with_sidecar(&self) -> Result<()> {
|
||||
pub async fn continue_with_sidecar(&self, service_rejection: Option<&str>) -> Result<()> {
|
||||
if !self.try_start_config_update() {
|
||||
anyhow::bail!("configuration update is already running");
|
||||
return Err(report_sidecar_failure(
|
||||
anyhow::anyhow!("configuration update is already running"),
|
||||
service_rejection,
|
||||
));
|
||||
}
|
||||
defer! {
|
||||
self.finish_config_update();
|
||||
@@ -375,61 +390,66 @@ impl CoreManager {
|
||||
// draft without claiming it, so without this it can commit another transaction's staged patch.
|
||||
let config_write = Config::lock_config_write().await;
|
||||
let _life = self.lifecycle_lock.lock().await;
|
||||
let status = SERVICE_MANAGER.current().await;
|
||||
tracing::Span::current().record("status", tracing::field::debug(&status));
|
||||
let mode = self.get_running_mode();
|
||||
if !can_allow_sidecar_for_session(&mode, &status) {
|
||||
anyhow::bail!("Sidecar continuation is not allowed from {mode:?} / {status:?}");
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
if matches!(*mode, RunningMode::NotRunning) {
|
||||
clash_verge_service_ipc::execution::check_sidecar_available().await?;
|
||||
}
|
||||
if !matches!(status, ServiceStatus::SidecarAllowed) {
|
||||
SERVICE_MANAGER.allow_sidecar_for_session()?;
|
||||
}
|
||||
// Settling on Sidecar is what makes the verdict final, so ask only once it is recorded.
|
||||
// Elevation alone carries TUN on Sidecar; a Sidecar that cannot must write it off.
|
||||
let prepared = async {
|
||||
let tun_enabled = Config::verge().await.latest_arc().enable_tun_mode.unwrap_or(false);
|
||||
if crate::core::runstate::RUN_STATE
|
||||
.state()
|
||||
.tun_should_be_disabled(tun_enabled)
|
||||
{
|
||||
tracing::Span::current().record("tun_disabled", true);
|
||||
Config::disable_tun_and_persist().await?;
|
||||
}
|
||||
Config::generate().await
|
||||
}
|
||||
.await;
|
||||
drop(config_write);
|
||||
if let Err(error) = prepared {
|
||||
SERVICE_MANAGER.withdraw_sidecar_allowance();
|
||||
return Err(error);
|
||||
}
|
||||
// Drop the guard last so an earlier failure cannot leave a running Sidecar unguarded.
|
||||
proxy_control::stop_guard().await;
|
||||
// Record failures before releasing the lifecycle lock, including preparation and rollback errors.
|
||||
let result = async {
|
||||
self.start_core_inner().await?;
|
||||
if !matches!(*self.get_running_mode(), RunningMode::Sidecar)
|
||||
|| self.current_core_readiness_generation().is_none()
|
||||
{
|
||||
anyhow::bail!("Sidecar did not become ready");
|
||||
let status = SERVICE_MANAGER.current().await;
|
||||
tracing::Span::current().record("status", tracing::field::debug(&status));
|
||||
let mode = self.get_running_mode();
|
||||
if !can_allow_sidecar_for_session(&mode, &status) {
|
||||
anyhow::bail!("Sidecar continuation is not allowed from {mode:?} / {status:?}");
|
||||
}
|
||||
tracing::Span::current().record("readiness_generation", self.current_core_readiness_generation());
|
||||
self.apply_proxy_after_start().await
|
||||
#[cfg(target_os = "windows")]
|
||||
if matches!(*mode, RunningMode::NotRunning) {
|
||||
clash_verge_service_ipc::execution::check_sidecar_available().await?;
|
||||
}
|
||||
if !matches!(status, ServiceStatus::SidecarAllowed) {
|
||||
SERVICE_MANAGER.allow_sidecar_for_session()?;
|
||||
}
|
||||
// Settling on Sidecar is what makes the verdict final, so ask only once it is recorded.
|
||||
// Elevation alone carries TUN on Sidecar; a Sidecar that cannot must write it off.
|
||||
let prepared = async {
|
||||
let tun_enabled = Config::verge().await.latest_arc().enable_tun_mode.unwrap_or(false);
|
||||
if crate::core::runstate::RUN_STATE
|
||||
.state()
|
||||
.tun_should_be_disabled(tun_enabled)
|
||||
{
|
||||
tracing::Span::current().record("tun_disabled", true);
|
||||
Config::disable_tun_and_persist().await?;
|
||||
}
|
||||
Config::generate().await
|
||||
}
|
||||
.await;
|
||||
drop(config_write);
|
||||
if let Err(error) = prepared {
|
||||
SERVICE_MANAGER.withdraw_sidecar_allowance();
|
||||
return Err(error);
|
||||
}
|
||||
// Drop the guard last so an earlier failure cannot leave a running Sidecar unguarded.
|
||||
proxy_control::stop_guard().await;
|
||||
let result = async {
|
||||
self.start_core_inner().await?;
|
||||
if !matches!(*self.get_running_mode(), RunningMode::Sidecar)
|
||||
|| self.current_core_readiness_generation().is_none()
|
||||
{
|
||||
anyhow::bail!("Sidecar did not become ready");
|
||||
}
|
||||
tracing::Span::current().record("readiness_generation", self.current_core_readiness_generation());
|
||||
self.apply_proxy_after_start().await
|
||||
}
|
||||
.await;
|
||||
if let Err(error) = result {
|
||||
// Revoke only the failed Sidecar allowance; its startup says nothing about Service health.
|
||||
SERVICE_MANAGER.withdraw_sidecar_allowance();
|
||||
if let Err(cleanup_error) = self.rollback_failed_sidecar_transition().await {
|
||||
return Err(proxy_control::rollback_failure(error, cleanup_error)
|
||||
.context("failed to clear the proxy before rolling back"));
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
if let Err(error) = result {
|
||||
// Revoke only the failed Sidecar allowance; its startup says nothing about Service health.
|
||||
SERVICE_MANAGER.withdraw_sidecar_allowance();
|
||||
if let Err(cleanup_error) = self.rollback_failed_sidecar_transition().await {
|
||||
return Err(proxy_control::rollback_failure(error, cleanup_error)
|
||||
.context("failed to clear the proxy before rolling back"));
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
Ok(())
|
||||
result.map_err(|error| report_sidecar_failure(error, service_rejection))
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all, level = "info", fields(readiness_generation = tracing::field::Empty))]
|
||||
@@ -572,6 +592,7 @@ impl CoreManager {
|
||||
}
|
||||
// tell the window when a background apply lands
|
||||
Handle::refresh_verge();
|
||||
crate::core::notification::retire_sidecar_failure();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -233,6 +233,46 @@ pub fn what_was_asked() -> FailedOperation {
|
||||
|
||||
static PENDING_FAILURES: Lazy<FailureTable> = Lazy::new(FailureTable::default);
|
||||
|
||||
/// A removal has a revision too, so a delayed read cannot restore a resolved failure.
|
||||
#[derive(Debug, Default, Clone, serde::Serialize)]
|
||||
pub struct SidecarFailureSnapshot {
|
||||
pub revision: u64,
|
||||
pub detail: Option<String>,
|
||||
}
|
||||
|
||||
impl SidecarFailureSnapshot {
|
||||
fn record(&mut self, detail: String) {
|
||||
self.revision += 1;
|
||||
self.detail = Some(detail);
|
||||
}
|
||||
|
||||
fn recover(&mut self) -> bool {
|
||||
if self.detail.take().is_none() {
|
||||
return false;
|
||||
}
|
||||
self.revision += 1;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
static SIDECAR_FAILURE: Lazy<Mutex<SidecarFailureSnapshot>> = Lazy::new(Mutex::default);
|
||||
|
||||
pub fn sidecar_failure_snapshot() -> SidecarFailureSnapshot {
|
||||
SIDECAR_FAILURE.lock().clone()
|
||||
}
|
||||
|
||||
pub fn record_sidecar_failure(detail: String) {
|
||||
SIDECAR_FAILURE.lock().record(detail);
|
||||
notify_pending_failures_changed();
|
||||
}
|
||||
|
||||
pub fn retire_sidecar_failure() {
|
||||
let changed = SIDECAR_FAILURE.lock().recover();
|
||||
if changed {
|
||||
notify_pending_failures_changed();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_failure(operation: FailedOperation, code: &str, detail: impl Into<String>) {
|
||||
PENDING_FAILURES.record(operation, code, detail.into());
|
||||
notify_pending_failures_changed();
|
||||
|
||||
@@ -63,8 +63,8 @@ pub(crate) fn open_private_current_user_file(path: &Path) -> Result<std::fs::Fil
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn open_or_create_private_current_user_file(path: &Path) -> Result<std::fs::File> {
|
||||
windows_owner::open_or_create_private_file(path)
|
||||
pub(crate) fn open_or_create_current_user_lock(path: &Path) -> Result<std::fs::File> {
|
||||
windows_owner::open_or_create_lock_file(path)
|
||||
}
|
||||
|
||||
/// Repairs an application data root Windows no longer reports as owned by the current user.
|
||||
@@ -110,7 +110,7 @@ mod windows_owner {
|
||||
BY_HANDLE_FILE_INFORMATION, CREATE_NEW, CreateFileW, FILE_ALL_ACCESS, FILE_ATTRIBUTE_DIRECTORY,
|
||||
FILE_ATTRIBUTE_HIDDEN, FILE_ATTRIBUTE_NORMAL, FILE_ATTRIBUTE_REPARSE_POINT, FILE_FLAG_BACKUP_SEMANTICS,
|
||||
FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_TYPE_DISK,
|
||||
GetFileInformationByHandle, GetFileType, OPEN_EXISTING, READ_CONTROL, WRITE_DAC, WRITE_OWNER,
|
||||
GetFileInformationByHandle, GetFileType, OPEN_ALWAYS, OPEN_EXISTING, READ_CONTROL, WRITE_DAC, WRITE_OWNER,
|
||||
};
|
||||
use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
|
||||
|
||||
@@ -215,22 +215,49 @@ mod windows_owner {
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
pub(super) fn open_or_create_private_file(path: &Path) -> Result<std::fs::File> {
|
||||
pub(super) fn open_or_create_lock_file(path: &Path) -> Result<std::fs::File> {
|
||||
let sid = current_sid()?;
|
||||
let descriptor = LocalSecurityDescriptor::from_sid(&sid)?;
|
||||
match create_file(path, &descriptor) {
|
||||
Ok(file) => Ok(file),
|
||||
Err(error)
|
||||
if error
|
||||
.downcast_ref::<std::io::Error>()
|
||||
.is_some_and(|error| error.raw_os_error() == Some(ERROR_FILE_EXISTS as i32)) =>
|
||||
{
|
||||
let file = open_file(path)?;
|
||||
validate_private_file(&file, descriptor.owner()?)?;
|
||||
Ok(file)
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
let attributes = SECURITY_ATTRIBUTES {
|
||||
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
|
||||
lpSecurityDescriptor: descriptor.0,
|
||||
bInheritHandle: 0,
|
||||
};
|
||||
let wide = wide_path(path)?;
|
||||
let handle = unsafe {
|
||||
CreateFileW(
|
||||
wide.as_ptr(),
|
||||
GENERIC_READ | READ_CONTROL | WRITE_DAC,
|
||||
// Replacing a live lock would let a second instance lock a different file.
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE,
|
||||
&attributes,
|
||||
OPEN_ALWAYS,
|
||||
FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT,
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
};
|
||||
if handle == INVALID_HANDLE_VALUE {
|
||||
return Err(std::io::Error::last_os_error()).context("failed to open current-user lock file");
|
||||
}
|
||||
let file = unsafe { std::fs::File::from_raw_handle(handle) };
|
||||
let mut information = BY_HANDLE_FILE_INFORMATION::default();
|
||||
if unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut information) } == 0 {
|
||||
return Err(std::io::Error::last_os_error()).context("failed to inspect current-user lock file");
|
||||
}
|
||||
if information.dwFileAttributes & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT) != 0
|
||||
|| unsafe { GetFileType(file.as_raw_handle()) } != FILE_TYPE_DISK
|
||||
|| information.nNumberOfLinks != 1
|
||||
{
|
||||
bail!("current-user lock is not an ordinary, single-link file");
|
||||
}
|
||||
let expected_owner = descriptor.owner()?;
|
||||
if !owner_matches(&file, expected_owner)? {
|
||||
bail!("current-user lock belongs to a different Windows user");
|
||||
}
|
||||
// Lock contents are never trusted; records and tokens retain their separate validation.
|
||||
descriptor.apply_dacl(file.as_raw_handle())?;
|
||||
validate_private_file(&file, expected_owner)?;
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
fn create_file(path: &Path, descriptor: &LocalSecurityDescriptor) -> Result<std::fs::File> {
|
||||
@@ -442,7 +469,8 @@ mod windows_owner {
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// A DACL that refuses even the owner read settles it just as well.
|
||||
// Failed inspection does not prove that a live lock belongs to another user.
|
||||
Err(error) if name == crate::utils::server::INSTANCE_LOCK_FILE => return Err(error),
|
||||
Err(_) => false,
|
||||
};
|
||||
if !ours {
|
||||
@@ -712,7 +740,7 @@ mod windows_owner {
|
||||
)
|
||||
};
|
||||
if status != 0 {
|
||||
bail!("failed to restrict owner token DACL: Windows error {status}");
|
||||
bail!("failed to restrict current-user file DACL: Windows error {status}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -146,6 +146,7 @@ mod app_init {
|
||||
cmd::upgrade_clash_core,
|
||||
cmd::get_runtime_state,
|
||||
cmd::get_pending_failures,
|
||||
cmd::get_sidecar_failure,
|
||||
cmd::entry_lightweight_mode,
|
||||
cmd::install_service,
|
||||
cmd::uninstall_service,
|
||||
|
||||
+16
-199
@@ -223,15 +223,20 @@ fn start_embedded_server(listener: tokio::net::TcpListener, token: String) {
|
||||
warp::http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
));
|
||||
}
|
||||
logging!(info, Type::Window, "检测到从单例模式恢复应用窗口");
|
||||
if crate::APP_HANDLE.get().is_some() {
|
||||
if !lightweight::exit_lightweight_mode().await {
|
||||
WindowManager::show_main_window().await;
|
||||
} else {
|
||||
logging!(error, Type::Window, "轻量模式退出失败,无法恢复应用窗口");
|
||||
}
|
||||
}
|
||||
Ok::<_, warp::Rejection>(warp::reply::with_status("ok".to_string(), warp::http::StatusCode::OK))
|
||||
// Keep blocking UI calls off HTTP workers.
|
||||
AsyncHandler::spawn_blocking(|| {
|
||||
AsyncHandler::block_on(async {
|
||||
logging!(
|
||||
info,
|
||||
Type::Window,
|
||||
"Restoring the application window from a secondary instance"
|
||||
);
|
||||
if crate::APP_HANDLE.get().is_some() && !lightweight::exit_lightweight_mode().await {
|
||||
WindowManager::show_main_window().await;
|
||||
}
|
||||
});
|
||||
});
|
||||
Ok(warp::reply::with_status("ok".to_string(), warp::http::StatusCode::OK))
|
||||
});
|
||||
|
||||
let pac = warp::path!("commands" / "pac").and_then(|| async move {
|
||||
@@ -246,10 +251,7 @@ fn start_embedded_server(listener: tokio::net::TcpListener, token: String) {
|
||||
let verge_config = Config::verge().await;
|
||||
let verge_data = verge_config.data_arc();
|
||||
let pac_content = verge_data.pac_file_content.as_deref().unwrap_or(DEFAULT_PAC);
|
||||
// Served per browser request, so this stays a configuration read rather than a
|
||||
// round-trip to the Core. It reads the draft layer, which is only correct because
|
||||
// whoever stages a listener port closes this endpoint across the change — see
|
||||
// `MixedPort::desired`. Reaching here at all means the Core is serving.
|
||||
// Port changes disable PAC, so the draft port avoids per-request Core queries.
|
||||
let pac_port = MixedPort::desired().await;
|
||||
let processed_content = pac_content.replace("%mixed-port%", &format!("{pac_port}"));
|
||||
Ok::<_, warp::Rejection>(
|
||||
@@ -336,7 +338,7 @@ fn open_instance_lock(path: &Path) -> Result<std::fs::File> {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
#[cfg(windows)]
|
||||
{
|
||||
crate::core::owner_identity::open_or_create_private_current_user_file(path)
|
||||
crate::core::owner_identity::open_or_create_current_user_lock(path)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
@@ -449,188 +451,3 @@ fn write_instance_record(path: &Path, record: &InstanceRecord) -> Result<()> {
|
||||
std::fs::rename(&temporary, path).context("failed to replace singleton record")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
InstanceRecord, PAC_INITIAL_AVAILABLE, bind_primary_listener, read_instance_record, write_instance_record,
|
||||
};
|
||||
#[cfg(unix)]
|
||||
use super::{open_instance_lock, try_lock_instance};
|
||||
|
||||
#[cfg(feature = "verge-dev")]
|
||||
use super::{INSTANCE_TOKEN_HEADER, dev_quit_route, release_dev_quit_latch};
|
||||
#[cfg(feature = "verge-dev")]
|
||||
use std::sync::atomic::Ordering;
|
||||
#[cfg(feature = "verge-dev")]
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, AtomicUsize},
|
||||
};
|
||||
#[test]
|
||||
fn pac_is_fail_closed_before_core_readiness() {
|
||||
// Asserts the default rather than the live flag: PAC availability is now derived from
|
||||
// the Running Mode by `core::runstate`, so any test that drives a Core transition
|
||||
// legitimately moves the live flag. That the derivation itself can never disagree with
|
||||
// the Running Mode is covered in `core::runstate` against a fake environment.
|
||||
const { assert!(!PAC_INITIAL_AVAILABLE) }
|
||||
}
|
||||
|
||||
#[cfg(feature = "verge-dev")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn dev_quit_route_requires_private_auth_ready_state_and_dispatches_once() -> anyhow::Result<()> {
|
||||
static READY: AtomicBool = AtomicBool::new(false);
|
||||
static REQUESTED: AtomicBool = AtomicBool::new(false);
|
||||
READY.store(false, Ordering::Release);
|
||||
REQUESTED.store(false, Ordering::Release);
|
||||
let dispatches = Arc::new(AtomicUsize::new(0));
|
||||
let dispatches_for_route = Arc::clone(&dispatches);
|
||||
let token = "ab".repeat(32);
|
||||
let route = dev_quit_route(token.clone(), &READY, &REQUESTED, move || {
|
||||
dispatches_for_route.fetch_add(1, Ordering::AcqRel);
|
||||
});
|
||||
|
||||
let missing = warp::test::request()
|
||||
.method("POST")
|
||||
.path("/commands/dev/quit")
|
||||
.reply(&route)
|
||||
.await;
|
||||
assert_eq!(missing.status(), warp::http::StatusCode::NOT_FOUND);
|
||||
|
||||
let wrong_token = warp::test::request()
|
||||
.method("POST")
|
||||
.path("/commands/dev/quit")
|
||||
.header(INSTANCE_TOKEN_HEADER, "cd".repeat(32))
|
||||
.reply(&route)
|
||||
.await;
|
||||
assert_eq!(wrong_token.status(), warp::http::StatusCode::NOT_FOUND);
|
||||
|
||||
let not_ready = warp::test::request()
|
||||
.method("POST")
|
||||
.path("/commands/dev/quit")
|
||||
.header(INSTANCE_TOKEN_HEADER, &token)
|
||||
.reply(&route)
|
||||
.await;
|
||||
assert_eq!(not_ready.status(), warp::http::StatusCode::SERVICE_UNAVAILABLE);
|
||||
assert!(!REQUESTED.load(Ordering::Acquire));
|
||||
|
||||
let wrong_method = warp::test::request()
|
||||
.method("GET")
|
||||
.path("/commands/dev/quit")
|
||||
.header(INSTANCE_TOKEN_HEADER, &token)
|
||||
.reply(&route)
|
||||
.await;
|
||||
assert_eq!(wrong_method.status(), warp::http::StatusCode::METHOD_NOT_ALLOWED);
|
||||
assert!(!REQUESTED.load(Ordering::Acquire));
|
||||
|
||||
READY.store(true, Ordering::Release);
|
||||
let barrier = Arc::new(tokio::sync::Barrier::new(8));
|
||||
let accepted = futures::future::join_all((0..8).map(|_| {
|
||||
let barrier = Arc::clone(&barrier);
|
||||
let route = route.clone();
|
||||
let token = token.clone();
|
||||
tokio::spawn(async move {
|
||||
barrier.wait().await;
|
||||
warp::test::request()
|
||||
.method("POST")
|
||||
.path("/commands/dev/quit")
|
||||
.header(INSTANCE_TOKEN_HEADER, token)
|
||||
.reply(&route)
|
||||
.await
|
||||
})
|
||||
}))
|
||||
.await;
|
||||
for accepted in accepted {
|
||||
let accepted = accepted?;
|
||||
assert_eq!(accepted.status(), warp::http::StatusCode::ACCEPTED);
|
||||
}
|
||||
assert_eq!(dispatches.load(Ordering::Acquire), 1);
|
||||
|
||||
release_dev_quit_latch(&REQUESTED, clash_verge_signal::ShutdownOutcome::Canceled);
|
||||
assert!(!REQUESTED.load(Ordering::Acquire));
|
||||
let retry = warp::test::request()
|
||||
.method("POST")
|
||||
.path("/commands/dev/quit")
|
||||
.header(INSTANCE_TOKEN_HEADER, &token)
|
||||
.reply(&route)
|
||||
.await;
|
||||
assert_eq!(retry.status(), warp::http::StatusCode::ACCEPTED);
|
||||
assert_eq!(dispatches.load(Ordering::Acquire), 2);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retained_record_supplies_the_next_primarys_preferred_port() -> anyhow::Result<()> {
|
||||
let root = std::env::temp_dir().join(format!("singleton-sticky-port-{}", std::process::id()));
|
||||
let path = root.join("instance.json");
|
||||
let reservation = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)).await?;
|
||||
let preferred_port = reservation.local_addr()?.port();
|
||||
drop(reservation);
|
||||
write_instance_record(
|
||||
&path,
|
||||
&InstanceRecord {
|
||||
port: preferred_port,
|
||||
token: "stale-token-is-not-authority".to_string(),
|
||||
},
|
||||
)?;
|
||||
|
||||
let hint = read_instance_record(&path).ok().map(|record| record.port);
|
||||
let listener = bind_primary_listener(hint).await?;
|
||||
assert_eq!(listener.local_addr()?.port(), preferred_port);
|
||||
drop(listener);
|
||||
super::shutdown_embedded_server();
|
||||
assert!(path.is_file(), "clean shutdown must retain the private port hint");
|
||||
|
||||
std::fs::remove_dir_all(root)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn occupied_preferred_port_falls_back_without_using_stale_authority() -> anyhow::Result<()> {
|
||||
let occupied = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)).await?;
|
||||
let preferred_port = occupied.local_addr()?.port();
|
||||
|
||||
let fallback = bind_primary_listener(Some(preferred_port)).await?;
|
||||
assert_ne!(fallback.local_addr()?.port(), preferred_port);
|
||||
assert!(fallback.local_addr()?.ip().is_loopback());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn instance_record_is_private_and_round_trips() -> anyhow::Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
|
||||
let root = std::env::temp_dir().join(format!("singleton-record-{}", std::process::id()));
|
||||
let path = root.join("instance.json");
|
||||
let record = InstanceRecord {
|
||||
port: 42_123,
|
||||
token: "secret".to_string(),
|
||||
};
|
||||
|
||||
write_instance_record(&path, &record)?;
|
||||
|
||||
assert_eq!(read_instance_record(&path)?, record);
|
||||
assert_eq!(std::fs::metadata(&path)?.permissions().mode() & 0o777, 0o600);
|
||||
std::fs::remove_dir_all(root)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn instance_lock_allows_only_one_primary() -> anyhow::Result<()> {
|
||||
let root = std::env::temp_dir().join(format!("singleton-lock-{}", std::process::id()));
|
||||
let path = root.join("instance.lock");
|
||||
let first = open_instance_lock(&path)?;
|
||||
let second = open_instance_lock(&path)?;
|
||||
|
||||
assert!(try_lock_instance(&first)?);
|
||||
assert!(!try_lock_instance(&second)?);
|
||||
drop(first);
|
||||
assert!(try_lock_instance(&second)?);
|
||||
|
||||
drop(second);
|
||||
std::fs::remove_dir_all(root)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user