mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-08-10 05:00:43 +02:00
refactor: cleanup
This commit is contained in:
@@ -0,0 +1,543 @@
|
||||
//! What a remote session owes this machine, and the gate that collects it.
|
||||
//!
|
||||
//! A profile that runs on the leased fleet is written by the host, not here.
|
||||
//! The host pushes it back to cloud storage when the session ends, and until
|
||||
//! this machine has pulled that push, the local profile directory is a stale
|
||||
//! copy of something that has moved on.
|
||||
//!
|
||||
//! Opening that stale copy is not a cosmetic problem, it is destructive. The
|
||||
//! local browser writes, every local mtime jumps past the host's push, and the
|
||||
//! next ordinary sync therefore reads local as the newer side: it uploads the
|
||||
//! pre-session files and puts everything the host wrote into
|
||||
//! `files_to_delete_remote`. A night of cookie warming is deleted with no error
|
||||
//! anywhere. Nothing in the manifest can prevent this, because by then the local
|
||||
//! clock genuinely IS later.
|
||||
//!
|
||||
//! So the gate is here instead, and it is deliberately a LOCAL, per-machine
|
||||
//! fact rather than a synced one. "This computer has not yet pulled" is true of
|
||||
//! one computer at a time; putting it in the profile's synced metadata would let
|
||||
//! a second device that had already pulled clear it for a first device that had
|
||||
//! not.
|
||||
//!
|
||||
//! Two states, and the difference matters to the user:
|
||||
//!
|
||||
//! - [`HandoffState::Running`]: a session is live on the fleet. The profile lock
|
||||
//! is held server-side, so a launch would be refused anyway; this makes the
|
||||
//! refusal instant and legible instead of a round trip and a raw string.
|
||||
//! - [`HandoffState::PendingSync`]: the session is over, the lock is released,
|
||||
//! and the work is sitting in cloud storage. This is the window that used to
|
||||
//! be wide open.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::RwLock;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Emitted whenever the set of gated profiles changes.
|
||||
pub const EVENT_REMOTE_HANDOFF: &str = "remote-handoff-changed";
|
||||
|
||||
/// Attempts at pulling a finished session's work before giving up for now.
|
||||
///
|
||||
/// The entry survives a failure, so "giving up" only means this burst stops;
|
||||
/// the next stream event, app start or manual sync tries again. What the retries
|
||||
/// buy is the common case: the profile lock is released server-side a moment
|
||||
/// before this machine's cached copy of it expires, and a single attempt would
|
||||
/// hit `Skipped("profile is locked elsewhere")` and leave the user blocked for
|
||||
/// no reason.
|
||||
const PULL_ATTEMPTS: u32 = 5;
|
||||
|
||||
/// Delay before the second pull attempt. Doubles, capped by [`PULL_RETRY_MAX`].
|
||||
const PULL_RETRY_BASE: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Ceiling on the pull backoff. Above the 30s profile-lock refresh, so a run of
|
||||
/// attempts is guaranteed to span at least one refresh of the lock cache.
|
||||
const PULL_RETRY_MAX: Duration = Duration::from_secs(45);
|
||||
|
||||
/// Where a profile stands with respect to the fleet.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum HandoffState {
|
||||
/// A session is live on the fleet right now.
|
||||
Running,
|
||||
/// A session has finished and its work has not been pulled down yet.
|
||||
PendingSync,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct HandoffEntry {
|
||||
session_id: String,
|
||||
state: HandoffState,
|
||||
/// When this entry last changed, unix seconds. Diagnostics only; the gate
|
||||
/// never expires on its own, because an entry that timed out would reopen
|
||||
/// exactly the window it exists to close.
|
||||
observed_at: u64,
|
||||
}
|
||||
|
||||
type Store = HashMap<String, HandoffEntry>;
|
||||
|
||||
static STORE: RwLock<Option<Store>> = RwLock::new(None);
|
||||
|
||||
fn store_path() -> std::path::PathBuf {
|
||||
crate::app_dirs::settings_dir().join("remote_handoff.json")
|
||||
}
|
||||
|
||||
fn load_from_disk() -> Store {
|
||||
let path = store_path();
|
||||
let Ok(bytes) = std::fs::read(&path) else {
|
||||
return Store::new();
|
||||
};
|
||||
match serde_json::from_slice::<Store>(&bytes) {
|
||||
Ok(store) => store,
|
||||
Err(e) => {
|
||||
// Losing the file means losing the gate, so say so loudly rather than
|
||||
// starting empty and quietly permitting a launch over pending work.
|
||||
log::error!(
|
||||
"Could not read {}: {e}. Profiles with unsynced remote work will not be gated until \
|
||||
the next session event.",
|
||||
path.display()
|
||||
);
|
||||
Store::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn persist(store: &Store) {
|
||||
let path = store_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
if let Err(e) = std::fs::create_dir_all(parent) {
|
||||
log::warn!("Could not create {}: {e}", parent.display());
|
||||
return;
|
||||
}
|
||||
}
|
||||
match serde_json::to_vec_pretty(store) {
|
||||
Ok(bytes) => {
|
||||
if let Err(e) = crate::app_dirs::write_owner_only(&path, &bytes) {
|
||||
log::warn!("Could not write {}: {e}", path.display());
|
||||
}
|
||||
}
|
||||
Err(e) => log::warn!("Could not encode the remote handoff store: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn with_store<T>(f: impl FnOnce(&mut Store) -> T) -> T {
|
||||
let mut guard = STORE
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let store = guard.get_or_insert_with(load_from_disk);
|
||||
f(store)
|
||||
}
|
||||
|
||||
/// Apply a mutation, and persist plus announce it only if it changed anything.
|
||||
fn mutate(f: impl FnOnce(&mut Store) -> bool) {
|
||||
let changed = with_store(|store| {
|
||||
let changed = f(store);
|
||||
if changed {
|
||||
persist(store);
|
||||
}
|
||||
changed
|
||||
});
|
||||
if changed {
|
||||
announce();
|
||||
}
|
||||
}
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn announce() {
|
||||
let _ = crate::events::emit(EVENT_REMOTE_HANDOFF, states());
|
||||
}
|
||||
|
||||
/// Every gated profile, for the UI and for one-shot reads.
|
||||
pub fn states() -> HashMap<String, HandoffState> {
|
||||
with_store(|store| {
|
||||
store
|
||||
.iter()
|
||||
.map(|(profile_id, entry)| (profile_id.clone(), entry.state))
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// Where this profile stands, if it is gated at all.
|
||||
pub fn state_for(profile_id: &str) -> Option<HandoffState> {
|
||||
with_store(|store| store.get(profile_id).map(|entry| entry.state))
|
||||
}
|
||||
|
||||
/// The session currently holding this profile on the fleet, if any.
|
||||
///
|
||||
/// Answers for a `provisioning` session too, which the drivable-session index
|
||||
/// deliberately does not. Stopping a session that has not finished coming up is
|
||||
/// the single most common thing a user does after starting one by mistake, and
|
||||
/// an index built for "where do I attach a CDP client" cannot serve it.
|
||||
pub fn running_session_for_profile(profile_id: &str) -> Option<String> {
|
||||
with_store(|store| {
|
||||
store
|
||||
.get(profile_id)
|
||||
.filter(|entry| entry.state == HandoffState::Running)
|
||||
.map(|entry| entry.session_id.clone())
|
||||
})
|
||||
}
|
||||
|
||||
/// Which profile a session belongs to, as this machine last recorded it.
|
||||
///
|
||||
/// The backend's stop reply carries a session id and a duration but no profile,
|
||||
/// and the caller that pressed stop needs to know whose work to pull. Reading it
|
||||
/// back from the gate avoids a second round trip for something already known.
|
||||
pub fn profile_for_session(session_id: &str) -> Option<String> {
|
||||
with_store(|store| {
|
||||
store
|
||||
.iter()
|
||||
.find(|(_, entry)| entry.session_id == session_id)
|
||||
.map(|(profile_id, _)| profile_id.clone())
|
||||
})
|
||||
}
|
||||
|
||||
/// Record that a session is live on the fleet for this profile.
|
||||
///
|
||||
/// Written to disk immediately, and this is the point of the whole store: if the
|
||||
/// app is closed while a session runs, nothing on restart would otherwise
|
||||
/// distinguish "this profile is fine" from "a host has been writing to this
|
||||
/// profile for the last hour".
|
||||
pub fn note_running(profile_id: &str, session_id: &str) {
|
||||
mutate(|store| {
|
||||
let entry = store.get(profile_id);
|
||||
if entry
|
||||
.is_some_and(|held| held.state == HandoffState::Running && held.session_id == session_id)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
store.insert(
|
||||
profile_id.to_string(),
|
||||
HandoffEntry {
|
||||
session_id: session_id.to_string(),
|
||||
state: HandoffState::Running,
|
||||
observed_at: now_secs(),
|
||||
},
|
||||
);
|
||||
true
|
||||
});
|
||||
}
|
||||
|
||||
/// Record that a session has finished and its work is waiting in cloud storage.
|
||||
///
|
||||
/// Returns whether this call is the one that moved the profile into
|
||||
/// `PendingSync`, so the caller starts exactly one pull for a transition that
|
||||
/// the stream may well deliver more than once.
|
||||
pub fn note_ended(profile_id: &str, session_id: &str) -> bool {
|
||||
let mut transitioned = false;
|
||||
mutate(|store| {
|
||||
// Only a session this machine was watching can hand work over to it.
|
||||
//
|
||||
// No entry means one of two things and both say "do nothing": the pull for
|
||||
// this session already completed and cleared the gate, or this machine
|
||||
// never held the profile. The backend's listing returns closed sessions
|
||||
// alongside live ones, so the snapshot on every reconnect replays each
|
||||
// finished session — treating those as fresh handoffs would gate a
|
||||
// perfectly current profile on every app start, and keep it blocked for as
|
||||
// long as the machine happened to be offline.
|
||||
let Some(entry) = store.get(profile_id) else {
|
||||
return false;
|
||||
};
|
||||
// A late `closed` for a session that has already been replaced by a newer
|
||||
// one must not mark the newer one's profile as finished.
|
||||
if entry.session_id != session_id {
|
||||
return false;
|
||||
}
|
||||
if entry.state == HandoffState::PendingSync {
|
||||
return false;
|
||||
}
|
||||
transitioned = true;
|
||||
store.insert(
|
||||
profile_id.to_string(),
|
||||
HandoffEntry {
|
||||
session_id: session_id.to_string(),
|
||||
state: HandoffState::PendingSync,
|
||||
observed_at: now_secs(),
|
||||
},
|
||||
);
|
||||
true
|
||||
});
|
||||
transitioned
|
||||
}
|
||||
|
||||
/// Drop the gate. Called only after a pull has actually completed.
|
||||
pub fn clear(profile_id: &str) {
|
||||
mutate(|store| store.remove(profile_id).is_some());
|
||||
}
|
||||
|
||||
/// Bring stored `Running` entries back in line with what the backend reports.
|
||||
///
|
||||
/// The stream is how a transition normally arrives, and it cannot deliver one
|
||||
/// that happened while the app was shut. Any profile this machine last saw
|
||||
/// running, whose session the backend no longer reports as live, finished
|
||||
/// without being observed — and its work is sitting in cloud storage unpulled.
|
||||
/// Returns the profiles that just moved into `PendingSync`.
|
||||
pub fn reconcile(live_session_ids: &std::collections::HashSet<String>) -> Vec<String> {
|
||||
let mut ended = Vec::new();
|
||||
mutate(|store| {
|
||||
let stale: Vec<(String, String)> = store
|
||||
.iter()
|
||||
.filter(|(_, entry)| entry.state == HandoffState::Running)
|
||||
.filter(|(_, entry)| !live_session_ids.contains(&entry.session_id))
|
||||
.map(|(profile_id, entry)| (profile_id.clone(), entry.session_id.clone()))
|
||||
.collect();
|
||||
for (profile_id, session_id) in stale {
|
||||
log::info!(
|
||||
"Remote session {session_id} for profile {profile_id} ended while this machine was not \
|
||||
watching; its work is still in cloud storage"
|
||||
);
|
||||
store.insert(
|
||||
profile_id.clone(),
|
||||
HandoffEntry {
|
||||
session_id,
|
||||
state: HandoffState::PendingSync,
|
||||
observed_at: now_secs(),
|
||||
},
|
||||
);
|
||||
ended.push(profile_id);
|
||||
}
|
||||
!ended.is_empty()
|
||||
});
|
||||
ended
|
||||
}
|
||||
|
||||
/// Refuse a local launch that would run over unsynced remote work.
|
||||
///
|
||||
/// Returns the `{"code":…}` string a Tauri command and the REST layer both
|
||||
/// surface. Every local launch path calls this: the two that did not are how a
|
||||
/// profile could be opened locally while a host was still writing to it.
|
||||
pub fn ensure_local_launch_allowed(profile_id: &str) -> Result<(), String> {
|
||||
match state_for(profile_id) {
|
||||
None => Ok(()),
|
||||
Some(HandoffState::Running) => Err(crate::backend_error("PROFILE_RUNNING_REMOTELY")),
|
||||
Some(HandoffState::PendingSync) => Err(crate::backend_error("PROFILE_REMOTE_SYNC_PENDING")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Restart the pull for every profile still waiting on one.
|
||||
///
|
||||
/// A pull can fail for as long as the machine is offline, and its retries are
|
||||
/// bounded, so without this a profile could stay blocked from launching until
|
||||
/// the user found the manual sync button. Called whenever the app has a cloud
|
||||
/// session again, which is exactly when a previously impossible pull becomes
|
||||
/// possible.
|
||||
pub fn resume_pending_pulls(app_handle: &tauri::AppHandle) {
|
||||
let pending: Vec<String> = with_store(|store| {
|
||||
store
|
||||
.iter()
|
||||
.filter(|(_, entry)| entry.state == HandoffState::PendingSync)
|
||||
.map(|(profile_id, _)| profile_id.clone())
|
||||
.collect()
|
||||
});
|
||||
for profile_id in pending {
|
||||
log::info!("Resuming the post-session pull for profile {profile_id}");
|
||||
schedule_pull(app_handle.clone(), profile_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull one profile's finished session down, then lift its gate.
|
||||
///
|
||||
/// Spawned rather than awaited by its callers: a stream frame and a stop button
|
||||
/// must not block on a transfer that can take minutes. The gate stays up for the
|
||||
/// whole attempt, so there is no window in which the user can open the stale
|
||||
/// copy while this is in flight.
|
||||
pub fn schedule_pull(app_handle: tauri::AppHandle, profile_id: String) {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
for attempt in 0..PULL_ATTEMPTS {
|
||||
if state_for(&profile_id) != Some(HandoffState::PendingSync) {
|
||||
// A new session started, or another pull got there first.
|
||||
return;
|
||||
}
|
||||
if attempt > 0 {
|
||||
let delay = PULL_RETRY_BASE
|
||||
.saturating_mul(1u32 << (attempt - 1).min(16))
|
||||
.min(PULL_RETRY_MAX);
|
||||
tokio::time::sleep(delay).await;
|
||||
}
|
||||
|
||||
match crate::sync::pull_profile_after_remote_session(&app_handle, &profile_id).await {
|
||||
Ok(outcome) if outcome.is_completed() => {
|
||||
log::info!("Pulled remote session work for profile {profile_id}");
|
||||
clear(&profile_id);
|
||||
return;
|
||||
}
|
||||
Ok(crate::sync::ProfileSyncOutcome::Skipped(reason)) => {
|
||||
log::info!("Post-session pull for profile {profile_id} did nothing ({reason}); retrying");
|
||||
}
|
||||
Ok(_) => unreachable!("is_completed covers every completed outcome"),
|
||||
Err(e) => {
|
||||
log::warn!("Post-session pull for profile {profile_id} failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log::warn!(
|
||||
"Could not pull remote session work for profile {profile_id} yet; it stays blocked from \
|
||||
launching locally until the pull succeeds"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// Serialises the tests.
|
||||
///
|
||||
/// `TEST_DATA_DIR` is thread-local but [`STORE`] is process-global, so two
|
||||
/// tests running at once would share one store while pointing at different
|
||||
/// directories. That fails intermittently, which is the worst way for a test
|
||||
/// guarding a data-loss bug to fail.
|
||||
static TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// Point the store at a scratch directory and start it empty.
|
||||
///
|
||||
/// Everything returned must outlive the test body: dropping the guard
|
||||
/// restores the real data directory, and a test that let it drop early would
|
||||
/// write a gate file into the developer's own app data.
|
||||
fn isolated() -> (
|
||||
tempfile::TempDir,
|
||||
crate::app_dirs::TestDirGuard,
|
||||
std::sync::MutexGuard<'static, ()>,
|
||||
) {
|
||||
let lock = TEST_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let dir = tempfile::TempDir::new().expect("a scratch directory");
|
||||
let guard = crate::app_dirs::set_test_data_dir(dir.path().to_path_buf());
|
||||
*STORE
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Store::new());
|
||||
(dir, guard, lock)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_live_session_blocks_a_local_launch() {
|
||||
let _iso = isolated();
|
||||
note_running("p1", "s1");
|
||||
let err = ensure_local_launch_allowed("p1").expect_err("a live session must block a launch");
|
||||
assert!(err.contains("PROFILE_RUNNING_REMOTELY"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_finished_session_still_blocks_until_the_work_is_pulled() {
|
||||
// The whole point. The profile lock is released the moment the session
|
||||
// closes, so without this the user can open the stale copy and the next
|
||||
// sync deletes everything the host wrote.
|
||||
let _iso = isolated();
|
||||
note_running("p1", "s1");
|
||||
assert!(note_ended("p1", "s1"));
|
||||
let err = ensure_local_launch_allowed("p1").expect_err("pending work must block a launch");
|
||||
assert!(err.contains("PROFILE_REMOTE_SYNC_PENDING"));
|
||||
|
||||
clear("p1");
|
||||
assert!(ensure_local_launch_allowed("p1").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ungated_profile_is_not_blocked() {
|
||||
let _iso = isolated();
|
||||
note_running("p1", "s1");
|
||||
assert!(ensure_local_launch_allowed("p2").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_end_transition_is_reported_once_however_often_the_frame_arrives() {
|
||||
// The stream re-delivers a snapshot on every reconnect, and `closed` can
|
||||
// arrive alongside it. Starting a pull per frame would run several
|
||||
// concurrent transfers of the same profile.
|
||||
let _iso = isolated();
|
||||
note_running("p1", "s1");
|
||||
assert!(note_ended("p1", "s1"));
|
||||
assert!(!note_ended("p1", "s1"));
|
||||
assert!(!note_ended("p1", "s1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_closed_session_this_machine_never_watched_does_not_gate_anything() {
|
||||
// `listForUser` returns closed sessions next to live ones, so the snapshot
|
||||
// on every reconnect replays every session that ever finished. Treating
|
||||
// those as fresh handoffs would block the Run button on a perfectly current
|
||||
// profile at each app start, and block it indefinitely while offline.
|
||||
let _iso = isolated();
|
||||
assert!(!note_ended("p1", "s-finished-last-week"));
|
||||
assert_eq!(state_for("p1"), None);
|
||||
assert!(ensure_local_launch_allowed("p1").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pulled_profile_is_not_re_gated_by_a_replayed_close() {
|
||||
// Same frame, one step later: the pull completed and cleared the gate. The
|
||||
// next reconnect must not put it back.
|
||||
let _iso = isolated();
|
||||
note_running("p1", "s1");
|
||||
note_ended("p1", "s1");
|
||||
clear("p1");
|
||||
assert!(!note_ended("p1", "s1"));
|
||||
assert!(ensure_local_launch_allowed("p1").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_late_close_for_a_replaced_session_does_not_gate_the_new_one() {
|
||||
// Session s1 finished and was pulled; s2 is now live on the same profile. A
|
||||
// straggling `closed` for s1 must not declare s2's profile finished, or the
|
||||
// gate lifts while a host is still writing.
|
||||
let _iso = isolated();
|
||||
note_running("p1", "s2");
|
||||
assert!(!note_ended("p1", "s1"));
|
||||
assert_eq!(state_for("p1"), Some(HandoffState::Running));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_session_that_ended_while_the_app_was_shut_is_recovered() {
|
||||
// Nothing streams a transition to a process that is not running. Without
|
||||
// this the profile reads as still-running for ever and can never be
|
||||
// launched again, and its work is never pulled.
|
||||
let _iso = isolated();
|
||||
note_running("p1", "s1");
|
||||
let live: HashSet<String> = HashSet::new();
|
||||
assert_eq!(reconcile(&live), vec!["p1".to_string()]);
|
||||
assert_eq!(state_for("p1"), Some(HandoffState::PendingSync));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconcile_leaves_a_session_that_is_genuinely_still_live() {
|
||||
let _iso = isolated();
|
||||
note_running("p1", "s1");
|
||||
let live: HashSet<String> = ["s1".to_string()].into_iter().collect();
|
||||
assert!(reconcile(&live).is_empty());
|
||||
assert_eq!(state_for("p1"), Some(HandoffState::Running));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconcile_does_not_reopen_a_pending_profile() {
|
||||
// `PendingSync` is not a session state and no listing will ever contain it.
|
||||
// Re-deriving it from the snapshot would report the same handoff as new on
|
||||
// every reconnect and start a pull each time.
|
||||
let _iso = isolated();
|
||||
note_running("p1", "s1");
|
||||
note_ended("p1", "s1");
|
||||
let live: HashSet<String> = HashSet::new();
|
||||
assert!(reconcile(&live).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_gate_survives_a_restart() {
|
||||
// Held on disk precisely because the dangerous window outlives the process:
|
||||
// an app killed mid-session comes back with no memory of it.
|
||||
let (_dir, _guard, _lock) = isolated();
|
||||
note_running("p1", "s1");
|
||||
note_ended("p1", "s1");
|
||||
|
||||
*STORE
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = None;
|
||||
|
||||
assert_eq!(state_for("p1"), Some(HandoffState::PendingSync));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user