chore: merge declarative effects into frontend contract flow

This commit is contained in:
Tunglies
2026-10-03 18:44:55 +08:00
48 changed files with 497 additions and 420 deletions
+15 -11
View File
@@ -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"));
}
}
+1 -1
View File
@@ -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"];
+74 -53
View File
@@ -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(())
}
+40
View File
@@ -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();
+46 -18
View File
@@ -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(())
}
+1
View File
@@ -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
View File
@@ -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(())
}
}