mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
feat(linux): suspend the local gateway across host sleep via logind (#122719)
* feat(linux): suspend the local gateway across host sleep via logind Mirror the macOS sleep cycle in the Tauri companion: a logind PrepareForSleep listener with a delay inhibitor best-effort prepares a local gateway suspension before sleep and resumes it on wake with a reconnect nudge and bounded retries; leases are route-bound, always cleared on wake, and late prepare responses resume immediately. * fix(linux): keep the sleep listener alive when inhibitor re-acquire fails * fix(linux): activate the driver for sleep cycles and bound driver-side sleep RPCs Sleep cycles temporarily unpark the gateway driver so prepare/resume work without the Quick Chat window; suspend RPCs carry a 3s driver-side budget that recycles a hung socket instead of starving wake retries; a mock-logind integration test exercises the Linux listener end to end. * fix(linux): count overlapping sleep cycles instead of a boolean flag An older wake task ending late cleared the shared activation flag and parked the driver mid-cycle; a saturating depth counter keeps the driver active until the last cycle ends. * fix(linux): gate sleep-cycle activation on loopback routes and never block wake on logind Remote or unconfigured routes no longer activate the driver during sleep, the paired end hook runs only for cycles that began, and wake recovery is spawned before the inhibitor re-acquire so a slow logind cannot delay reconnect/resume.
This commit is contained in:
committed by
GitHub
parent
9da43d67e1
commit
a1615cdfcf
Generated
+4
@@ -2817,6 +2817,7 @@ dependencies = [
|
||||
"tokio-tungstenite",
|
||||
"uuid",
|
||||
"webkit2gtk",
|
||||
"zbus",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -4702,8 +4703,10 @@ dependencies = [
|
||||
"libc",
|
||||
"mio",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -6015,6 +6018,7 @@ dependencies = [
|
||||
"rustix",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uds_windows",
|
||||
"uuid",
|
||||
|
||||
@@ -52,6 +52,7 @@ cairo-rs = { version = "0.18.5", features = ["png"] }
|
||||
libc = "0.2.189"
|
||||
tauri-plugin-notifications = { git = "https://github.com/steipete/tauri-plugin-notifications.git", rev = "d20b4ff0e0e327e49fee80903478f825eac5a71a" }
|
||||
webkit2gtk = "2.0.2"
|
||||
zbus = { version = "5", default-features = false, features = ["tokio"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
tauri-plugin-notifications = { git = "https://github.com/steipete/tauri-plugin-notifications.git", rev = "d20b4ff0e0e327e49fee80903478f825eac5a71a", default-features = false }
|
||||
|
||||
@@ -0,0 +1,592 @@
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
const RESUME_ATTEMPTS: usize = 3;
|
||||
const RESUME_RETRY_DELAY: Duration = Duration::from_secs(2);
|
||||
|
||||
type PrepareFuture = Pin<Box<dyn Future<Output = Result<SleepPrepareOutcome, String>> + Send>>;
|
||||
type ResumeFuture = Pin<Box<dyn Future<Output = Result<(), String>> + Send>>;
|
||||
type RefreshFuture = Pin<Box<dyn Future<Output = ()> + Send>>;
|
||||
type DelayFuture = Pin<Box<dyn Future<Output = ()> + Send>>;
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum SleepPrepareOutcome {
|
||||
Ready { suspension_id: String },
|
||||
Busy,
|
||||
}
|
||||
|
||||
struct HeldSuspension {
|
||||
id: String,
|
||||
route: String,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct CycleState {
|
||||
suspension: Option<HeldSuspension>,
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
pub(crate) struct GatewaySleepCycleController {
|
||||
request_id: String,
|
||||
current_route: Arc<dyn Fn() -> Option<String> + Send + Sync>,
|
||||
prepare: Arc<dyn Fn(String) -> PrepareFuture + Send + Sync>,
|
||||
resume: Arc<dyn Fn(String) -> ResumeFuture + Send + Sync>,
|
||||
refresh: Arc<dyn Fn() -> RefreshFuture + Send + Sync>,
|
||||
retry_delay: Arc<dyn Fn(Duration) -> DelayFuture + Send + Sync>,
|
||||
log: Arc<dyn Fn(String) + Send + Sync>,
|
||||
state: Mutex<CycleState>,
|
||||
}
|
||||
|
||||
impl GatewaySleepCycleController {
|
||||
pub(crate) fn new<P, PF, R, RF, F, FF, C, D, DF, L>(
|
||||
request_id: String,
|
||||
current_route: C,
|
||||
prepare: P,
|
||||
resume: R,
|
||||
refresh: F,
|
||||
retry_delay: D,
|
||||
log: L,
|
||||
) -> Self
|
||||
where
|
||||
P: Fn(String) -> PF + Send + Sync + 'static,
|
||||
PF: Future<Output = Result<SleepPrepareOutcome, String>> + Send + 'static,
|
||||
R: Fn(String) -> RF + Send + Sync + 'static,
|
||||
RF: Future<Output = Result<(), String>> + Send + 'static,
|
||||
F: Fn() -> FF + Send + Sync + 'static,
|
||||
FF: Future<Output = ()> + Send + 'static,
|
||||
C: Fn() -> Option<String> + Send + Sync + 'static,
|
||||
D: Fn(Duration) -> DF + Send + Sync + 'static,
|
||||
DF: Future<Output = ()> + Send + 'static,
|
||||
L: Fn(String) + Send + Sync + 'static,
|
||||
{
|
||||
Self {
|
||||
request_id,
|
||||
current_route: Arc::new(current_route),
|
||||
prepare: Arc::new(move |request_id| Box::pin(prepare(request_id))),
|
||||
resume: Arc::new(move |suspension_id| Box::pin(resume(suspension_id))),
|
||||
refresh: Arc::new(move || Box::pin(refresh())),
|
||||
retry_delay: Arc::new(move |delay| Box::pin(retry_delay(delay))),
|
||||
log: Arc::new(log),
|
||||
state: Mutex::new(CycleState::default()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn will_sleep(&self) {
|
||||
// The production route closure exposes only configured loopback gateways.
|
||||
let Some(route) = (self.current_route)() else {
|
||||
return;
|
||||
};
|
||||
let generation = {
|
||||
let mut state = self
|
||||
.state
|
||||
.lock()
|
||||
.expect("gateway sleep state mutex poisoned");
|
||||
state.generation = state.generation.wrapping_add(1);
|
||||
state.generation
|
||||
};
|
||||
match (self.prepare)(self.request_id.clone()).await {
|
||||
Ok(SleepPrepareOutcome::Ready { suspension_id }) => {
|
||||
let late = {
|
||||
let mut state = self
|
||||
.state
|
||||
.lock()
|
||||
.expect("gateway sleep state mutex poisoned");
|
||||
if generation == state.generation {
|
||||
state.suspension = Some(HeldSuspension {
|
||||
id: suspension_id.clone(),
|
||||
route,
|
||||
});
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
};
|
||||
if late {
|
||||
// Wake or a newer cycle won the race; do not leave the late lease active.
|
||||
if let Err(error) = (self.resume)(suspension_id).await {
|
||||
(self.log)(format!("gateway sleep preparation failed: {error}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(SleepPrepareOutcome::Busy) => {
|
||||
(self.log)("gateway sleep preparation skipped because the gateway is busy".into());
|
||||
}
|
||||
Err(error) => {
|
||||
(self.log)(format!("gateway sleep preparation failed: {error}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn did_wake(&self) {
|
||||
// Clear first so a second wake or failed resume cannot reuse this cycle's lease.
|
||||
let (suspension, generation) = {
|
||||
let mut state = self
|
||||
.state
|
||||
.lock()
|
||||
.expect("gateway sleep state mutex poisoned");
|
||||
let suspension = state.suspension.take();
|
||||
state.generation = state.generation.wrapping_add(1);
|
||||
(suspension, state.generation)
|
||||
};
|
||||
if (self.current_route)().is_none() {
|
||||
if suspension.is_some() {
|
||||
(self.log)(
|
||||
"dropping gateway sleep lease: route/mode changed across sleep; lease will self-expire"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// The pre-sleep transport is normally dead; reconnect before attempting resume.
|
||||
(self.refresh)().await;
|
||||
if let Some(suspension) = suspension {
|
||||
if (self.current_route)().as_ref() == Some(&suspension.route) {
|
||||
self.resume_with_retries(suspension.id, generation).await;
|
||||
} else {
|
||||
(self.log)(
|
||||
"dropping gateway sleep lease: route/mode changed across sleep; lease will self-expire"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn resume_with_retries(&self, suspension_id: String, generation: u64) {
|
||||
for attempt in 1..=RESUME_ATTEMPTS {
|
||||
// A new sleep cycle owns the connection; abandoned leases self-expire.
|
||||
if generation
|
||||
!= self
|
||||
.state
|
||||
.lock()
|
||||
.expect("gateway sleep state mutex poisoned")
|
||||
.generation
|
||||
{
|
||||
return;
|
||||
}
|
||||
match (self.resume)(suspension_id.clone()).await {
|
||||
Ok(()) => return,
|
||||
Err(error) => {
|
||||
(self.log)(format!(
|
||||
"gateway wake resume attempt {attempt} failed: {error}"
|
||||
));
|
||||
if attempt < RESUME_ATTEMPTS {
|
||||
(self.retry_delay)(RESUME_RETRY_DELAY).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(self.log)("giving up on gateway wake resume; lease will self-expire".into());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
fn route_state(value: Option<&str>) -> Arc<Mutex<Option<String>>> {
|
||||
Arc::new(Mutex::new(value.map(str::to_string)))
|
||||
}
|
||||
|
||||
fn current_route(
|
||||
route: &Arc<Mutex<Option<String>>>,
|
||||
) -> impl Fn() -> Option<String> + Send + Sync + 'static {
|
||||
let route = Arc::clone(route);
|
||||
move || route.lock().expect("route mutex poisoned").clone()
|
||||
}
|
||||
|
||||
fn no_delay(_: Duration) -> impl Future<Output = ()> + Send {
|
||||
std::future::ready(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ready_preparation_resumes_once_after_refresh() {
|
||||
let route = route_state(Some("ws://127.0.0.1:18789"));
|
||||
let events = Arc::new(Mutex::new(Vec::new()));
|
||||
let prepare_events = Arc::clone(&events);
|
||||
let resume_events = Arc::clone(&events);
|
||||
let refresh_events = Arc::clone(&events);
|
||||
let request_ids = Arc::new(Mutex::new(Vec::new()));
|
||||
let prepared_ids = Arc::clone(&request_ids);
|
||||
let controller = GatewaySleepCycleController::new(
|
||||
"linux-sleep-test-run".into(),
|
||||
current_route(&route),
|
||||
move |request_id| {
|
||||
prepared_ids.lock().unwrap().push(request_id);
|
||||
prepare_events.lock().unwrap().push("prepare");
|
||||
async {
|
||||
Ok(SleepPrepareOutcome::Ready {
|
||||
suspension_id: "suspension-1".into(),
|
||||
})
|
||||
}
|
||||
},
|
||||
move |_| {
|
||||
resume_events.lock().unwrap().push("resume");
|
||||
async { Ok(()) }
|
||||
},
|
||||
move || {
|
||||
refresh_events.lock().unwrap().push("refresh");
|
||||
async {}
|
||||
},
|
||||
no_delay,
|
||||
|_| {},
|
||||
);
|
||||
|
||||
controller.will_sleep().await;
|
||||
controller.did_wake().await;
|
||||
controller.did_wake().await;
|
||||
|
||||
assert_eq!(*request_ids.lock().unwrap(), ["linux-sleep-test-run"]);
|
||||
assert_eq!(
|
||||
*events.lock().unwrap(),
|
||||
["prepare", "refresh", "resume", "refresh"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn busy_preparation_does_not_resume() {
|
||||
let route = route_state(Some("ws://127.0.0.1:18789"));
|
||||
let resumes = Arc::new(AtomicUsize::new(0));
|
||||
let resumed = Arc::clone(&resumes);
|
||||
let refreshes = Arc::new(AtomicUsize::new(0));
|
||||
let refreshed = Arc::clone(&refreshes);
|
||||
let logs = Arc::new(Mutex::new(Vec::new()));
|
||||
let recorded_logs = Arc::clone(&logs);
|
||||
let controller = GatewaySleepCycleController::new(
|
||||
"linux-sleep-test-run".into(),
|
||||
current_route(&route),
|
||||
|_| async { Ok(SleepPrepareOutcome::Busy) },
|
||||
move |_| {
|
||||
resumed.fetch_add(1, Ordering::SeqCst);
|
||||
async { Ok(()) }
|
||||
},
|
||||
move || {
|
||||
refreshed.fetch_add(1, Ordering::SeqCst);
|
||||
async {}
|
||||
},
|
||||
no_delay,
|
||||
move |message| recorded_logs.lock().unwrap().push(message),
|
||||
);
|
||||
|
||||
controller.will_sleep().await;
|
||||
controller.did_wake().await;
|
||||
|
||||
assert_eq!(resumes.load(Ordering::SeqCst), 0);
|
||||
assert_eq!(refreshes.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(
|
||||
*logs.lock().unwrap(),
|
||||
["gateway sleep preparation skipped because the gateway is busy"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_preparation_does_not_resume() {
|
||||
let route = route_state(Some("ws://127.0.0.1:18789"));
|
||||
let resumes = Arc::new(AtomicUsize::new(0));
|
||||
let resumed = Arc::clone(&resumes);
|
||||
let refreshes = Arc::new(AtomicUsize::new(0));
|
||||
let refreshed = Arc::clone(&refreshes);
|
||||
let logs = Arc::new(Mutex::new(Vec::new()));
|
||||
let recorded_logs = Arc::clone(&logs);
|
||||
let controller = GatewaySleepCycleController::new(
|
||||
"linux-sleep-test-run".into(),
|
||||
current_route(&route),
|
||||
|_| async { Err("prepare failed".into()) },
|
||||
move |_| {
|
||||
resumed.fetch_add(1, Ordering::SeqCst);
|
||||
async { Ok(()) }
|
||||
},
|
||||
move || {
|
||||
refreshed.fetch_add(1, Ordering::SeqCst);
|
||||
async {}
|
||||
},
|
||||
no_delay,
|
||||
move |message| recorded_logs.lock().unwrap().push(message),
|
||||
);
|
||||
|
||||
controller.will_sleep().await;
|
||||
controller.did_wake().await;
|
||||
|
||||
assert_eq!(resumes.load(Ordering::SeqCst), 0);
|
||||
assert_eq!(refreshes.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(
|
||||
*logs.lock().unwrap(),
|
||||
["gateway sleep preparation failed: prepare failed"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn changed_route_drops_the_suspension() {
|
||||
let route = route_state(Some("ws://127.0.0.1:18789"));
|
||||
let resumes = Arc::new(AtomicUsize::new(0));
|
||||
let resumed = Arc::clone(&resumes);
|
||||
let logs = Arc::new(Mutex::new(Vec::new()));
|
||||
let recorded_logs = Arc::clone(&logs);
|
||||
let refreshes = Arc::new(AtomicUsize::new(0));
|
||||
let refreshed = Arc::clone(&refreshes);
|
||||
let controller = GatewaySleepCycleController::new(
|
||||
"linux-sleep-test-run".into(),
|
||||
current_route(&route),
|
||||
|_| async {
|
||||
Ok(SleepPrepareOutcome::Ready {
|
||||
suspension_id: "suspension-1".into(),
|
||||
})
|
||||
},
|
||||
move |_| {
|
||||
resumed.fetch_add(1, Ordering::SeqCst);
|
||||
async { Ok(()) }
|
||||
},
|
||||
move || {
|
||||
refreshed.fetch_add(1, Ordering::SeqCst);
|
||||
async {}
|
||||
},
|
||||
no_delay,
|
||||
move |message| recorded_logs.lock().unwrap().push(message),
|
||||
);
|
||||
|
||||
controller.will_sleep().await;
|
||||
*route.lock().unwrap() = Some("ws://127.0.0.1:19001".into());
|
||||
controller.did_wake().await;
|
||||
|
||||
assert_eq!(resumes.load(Ordering::SeqCst), 0);
|
||||
assert_eq!(refreshes.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(
|
||||
*logs.lock().unwrap(),
|
||||
["dropping gateway sleep lease: route/mode changed across sleep; lease will self-expire"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_or_remote_route_drops_a_held_suspension() {
|
||||
let route = route_state(Some("ws://127.0.0.1:18789"));
|
||||
let resumes = Arc::new(AtomicUsize::new(0));
|
||||
let resumed = Arc::clone(&resumes);
|
||||
let refreshes = Arc::new(AtomicUsize::new(0));
|
||||
let refreshed = Arc::clone(&refreshes);
|
||||
let logs = Arc::new(Mutex::new(Vec::new()));
|
||||
let recorded_logs = Arc::clone(&logs);
|
||||
let controller = GatewaySleepCycleController::new(
|
||||
"linux-sleep-test-run".into(),
|
||||
current_route(&route),
|
||||
|_| async {
|
||||
Ok(SleepPrepareOutcome::Ready {
|
||||
suspension_id: "suspension-1".into(),
|
||||
})
|
||||
},
|
||||
move |_| {
|
||||
resumed.fetch_add(1, Ordering::SeqCst);
|
||||
async { Ok(()) }
|
||||
},
|
||||
move || {
|
||||
refreshed.fetch_add(1, Ordering::SeqCst);
|
||||
async {}
|
||||
},
|
||||
no_delay,
|
||||
move |message| recorded_logs.lock().unwrap().push(message),
|
||||
);
|
||||
|
||||
controller.will_sleep().await;
|
||||
*route.lock().unwrap() = None;
|
||||
controller.did_wake().await;
|
||||
*route.lock().unwrap() = Some("ws://127.0.0.1:18789".into());
|
||||
controller.did_wake().await;
|
||||
|
||||
assert_eq!(resumes.load(Ordering::SeqCst), 0);
|
||||
assert_eq!(refreshes.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(logs.lock().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn late_prepare_response_resumes_immediately() {
|
||||
let route = route_state(Some("ws://127.0.0.1:18789"));
|
||||
let (release, receiver) = oneshot::channel();
|
||||
let (started, prepare_started) = oneshot::channel();
|
||||
let receiver = Arc::new(Mutex::new(Some(receiver)));
|
||||
let prepare_receiver = Arc::clone(&receiver);
|
||||
let started = Arc::new(Mutex::new(Some(started)));
|
||||
let prepare_started_sender = Arc::clone(&started);
|
||||
let resumed_ids = Arc::new(Mutex::new(Vec::new()));
|
||||
let resumed = Arc::clone(&resumed_ids);
|
||||
let controller = Arc::new(GatewaySleepCycleController::new(
|
||||
"linux-sleep-test-run".into(),
|
||||
current_route(&route),
|
||||
move |_| {
|
||||
let receiver = prepare_receiver.lock().unwrap().take().unwrap();
|
||||
prepare_started_sender
|
||||
.lock()
|
||||
.unwrap()
|
||||
.take()
|
||||
.unwrap()
|
||||
.send(())
|
||||
.unwrap();
|
||||
async move {
|
||||
let _ = receiver.await;
|
||||
Ok(SleepPrepareOutcome::Ready {
|
||||
suspension_id: "late-suspension".into(),
|
||||
})
|
||||
}
|
||||
},
|
||||
move |id| {
|
||||
resumed.lock().unwrap().push(id);
|
||||
async { Ok(()) }
|
||||
},
|
||||
|| async {},
|
||||
no_delay,
|
||||
|_| {},
|
||||
));
|
||||
|
||||
let sleeping = {
|
||||
let controller = Arc::clone(&controller);
|
||||
tokio::spawn(async move { controller.will_sleep().await })
|
||||
};
|
||||
prepare_started.await.unwrap();
|
||||
controller.did_wake().await;
|
||||
release.send(()).unwrap();
|
||||
sleeping.await.unwrap();
|
||||
controller.did_wake().await;
|
||||
|
||||
assert_eq!(*resumed_ids.lock().unwrap(), ["late-suspension"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resume_retries_then_succeeds() {
|
||||
let route = route_state(Some("ws://127.0.0.1:18789"));
|
||||
let attempts = Arc::new(AtomicUsize::new(0));
|
||||
let attempted = Arc::clone(&attempts);
|
||||
let delays = Arc::new(AtomicUsize::new(0));
|
||||
let delayed = Arc::clone(&delays);
|
||||
let controller = GatewaySleepCycleController::new(
|
||||
"linux-sleep-test-run".into(),
|
||||
current_route(&route),
|
||||
|_| async {
|
||||
Ok(SleepPrepareOutcome::Ready {
|
||||
suspension_id: "suspension-retry".into(),
|
||||
})
|
||||
},
|
||||
move |_| {
|
||||
let attempt = attempted.fetch_add(1, Ordering::SeqCst);
|
||||
async move {
|
||||
if attempt == 0 {
|
||||
Err("transport failed".into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
},
|
||||
|| async {},
|
||||
move |_| {
|
||||
delayed.fetch_add(1, Ordering::SeqCst);
|
||||
async {}
|
||||
},
|
||||
|_| {},
|
||||
);
|
||||
|
||||
controller.will_sleep().await;
|
||||
controller.did_wake().await;
|
||||
|
||||
assert_eq!(attempts.load(Ordering::SeqCst), 2);
|
||||
assert_eq!(delays.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resume_exhausts_three_attempts() {
|
||||
let route = route_state(Some("ws://127.0.0.1:18789"));
|
||||
let attempts = Arc::new(AtomicUsize::new(0));
|
||||
let attempted = Arc::clone(&attempts);
|
||||
let logs = Arc::new(Mutex::new(Vec::new()));
|
||||
let recorded_logs = Arc::clone(&logs);
|
||||
let controller = GatewaySleepCycleController::new(
|
||||
"linux-sleep-test-run".into(),
|
||||
current_route(&route),
|
||||
|_| async {
|
||||
Ok(SleepPrepareOutcome::Ready {
|
||||
suspension_id: "suspension-exhaust".into(),
|
||||
})
|
||||
},
|
||||
move |_| {
|
||||
attempted.fetch_add(1, Ordering::SeqCst);
|
||||
async { Err("transport failed".into()) }
|
||||
},
|
||||
|| async {},
|
||||
no_delay,
|
||||
move |message| recorded_logs.lock().unwrap().push(message),
|
||||
);
|
||||
|
||||
controller.will_sleep().await;
|
||||
controller.did_wake().await;
|
||||
|
||||
assert_eq!(attempts.load(Ordering::SeqCst), 3);
|
||||
assert!(logs
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|log| log.contains("giving up")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn new_sleep_cycle_aborts_in_flight_retries() {
|
||||
let route = route_state(Some("ws://127.0.0.1:18789"));
|
||||
let attempts = Arc::new(AtomicUsize::new(0));
|
||||
let attempted = Arc::clone(&attempts);
|
||||
let controller_slot = Arc::new(Mutex::new(None::<Arc<GatewaySleepCycleController>>));
|
||||
let delay_slot = Arc::clone(&controller_slot);
|
||||
let controller = Arc::new(GatewaySleepCycleController::new(
|
||||
"linux-sleep-test-run".into(),
|
||||
current_route(&route),
|
||||
|_| async {
|
||||
Ok(SleepPrepareOutcome::Ready {
|
||||
suspension_id: "suspension-abort".into(),
|
||||
})
|
||||
},
|
||||
move |_| {
|
||||
attempted.fetch_add(1, Ordering::SeqCst);
|
||||
async { Err("transport failed".into()) }
|
||||
},
|
||||
|| async {},
|
||||
move |_| {
|
||||
let controller = delay_slot.lock().unwrap().as_ref().unwrap().clone();
|
||||
async move { controller.will_sleep().await }
|
||||
},
|
||||
|_| {},
|
||||
));
|
||||
*controller_slot.lock().unwrap() = Some(Arc::clone(&controller));
|
||||
|
||||
controller.will_sleep().await;
|
||||
controller.did_wake().await;
|
||||
|
||||
assert_eq!(attempts.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wake_always_clears_the_held_lease() {
|
||||
let route = route_state(Some("ws://127.0.0.1:18789"));
|
||||
let attempts = Arc::new(AtomicUsize::new(0));
|
||||
let attempted = Arc::clone(&attempts);
|
||||
let controller = GatewaySleepCycleController::new(
|
||||
"linux-sleep-test-run".into(),
|
||||
current_route(&route),
|
||||
|_| async {
|
||||
Ok(SleepPrepareOutcome::Ready {
|
||||
suspension_id: "suspension-failure".into(),
|
||||
})
|
||||
},
|
||||
move |_| {
|
||||
attempted.fetch_add(1, Ordering::SeqCst);
|
||||
async { Err("transport failed".into()) }
|
||||
},
|
||||
|| async {},
|
||||
no_delay,
|
||||
|_| {},
|
||||
);
|
||||
|
||||
controller.will_sleep().await;
|
||||
controller.did_wake().await;
|
||||
controller.did_wake().await;
|
||||
|
||||
assert_eq!(attempts.load(Ordering::SeqCst), 3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
use crate::gateway_sleep::GatewaySleepCycleController;
|
||||
use crate::gateway_sleep_logind_listener::{run_listener, BeginSleepCycleHook, EndSleepCycleHook};
|
||||
use crate::gateway_ws::GatewayClient;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tauri::{AppHandle, Manager};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub(crate) struct SleepBridge {
|
||||
task: Mutex<Option<tauri::async_runtime::JoinHandle<()>>>,
|
||||
}
|
||||
|
||||
impl SleepBridge {
|
||||
pub(crate) fn start(app: AppHandle) -> Self {
|
||||
let gateway = app.state::<GatewayClient>().inner().clone();
|
||||
// The driver task stays parked outside Quick Chat or a sleep cycle, so starting it here
|
||||
// does not widen the companion's normal Gateway connection lifetime.
|
||||
gateway.activate(app.clone());
|
||||
let route_gateway = gateway.clone();
|
||||
let prepare_gateway = gateway.clone();
|
||||
let resume_gateway = gateway.clone();
|
||||
let refresh_gateway = gateway.clone();
|
||||
let begin_gateway = gateway.clone();
|
||||
let end_gateway = gateway;
|
||||
let controller = Arc::new(GatewaySleepCycleController::new(
|
||||
format!("linux-sleep-{}", Uuid::new_v4()),
|
||||
move || route_gateway.loopback_route_token(),
|
||||
move |request_id| {
|
||||
let gateway = prepare_gateway.clone();
|
||||
async move { gateway.suspend_prepare(request_id).await }
|
||||
},
|
||||
move |suspension_id| {
|
||||
let gateway = resume_gateway.clone();
|
||||
async move {
|
||||
gateway.suspend_resume(suspension_id).await?;
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
move || {
|
||||
refresh_gateway.resume_reconnect();
|
||||
async {}
|
||||
},
|
||||
tokio::time::sleep,
|
||||
|message| eprintln!("Gateway sleep: {message}"),
|
||||
));
|
||||
let begin_sleep_cycle: BeginSleepCycleHook = Arc::new(move || {
|
||||
// A remote or unconfigured route must not activate the driver.
|
||||
if begin_gateway.loopback_route_token().is_none() {
|
||||
return false;
|
||||
}
|
||||
begin_gateway.begin_sleep_cycle();
|
||||
true
|
||||
});
|
||||
let end_sleep_cycle: EndSleepCycleHook = Arc::new(move || end_gateway.end_sleep_cycle());
|
||||
let task = tauri::async_runtime::spawn(async move {
|
||||
if let Err(error) = run_listener(controller, begin_sleep_cycle, end_sleep_cycle).await {
|
||||
eprintln!("Gateway sleep listener unavailable: {error}");
|
||||
}
|
||||
});
|
||||
Self {
|
||||
task: Mutex::new(Some(task)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn shutdown(&self) {
|
||||
if let Some(task) = self
|
||||
.task
|
||||
.lock()
|
||||
.expect("sleep bridge mutex poisoned")
|
||||
.take()
|
||||
{
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
use crate::gateway_sleep::GatewaySleepCycleController;
|
||||
use futures_util::StreamExt;
|
||||
use std::sync::Arc;
|
||||
use zbus::zvariant::OwnedFd;
|
||||
|
||||
// Returns whether a cycle actually began (a remote/unconfigured route must not
|
||||
// activate the driver); the paired end hook runs only for cycles that began.
|
||||
pub(crate) type BeginSleepCycleHook = Arc<dyn Fn() -> bool + Send + Sync>;
|
||||
pub(crate) type EndSleepCycleHook = Arc<dyn Fn() + Send + Sync>;
|
||||
|
||||
#[zbus::proxy(
|
||||
default_service = "org.freedesktop.login1",
|
||||
default_path = "/org/freedesktop/login1",
|
||||
interface = "org.freedesktop.login1.Manager"
|
||||
)]
|
||||
trait Login1Manager {
|
||||
fn inhibit(&self, what: &str, who: &str, why: &str, mode: &str) -> zbus::Result<OwnedFd>;
|
||||
|
||||
#[zbus(signal)]
|
||||
fn prepare_for_sleep(&self, sleeping: bool) -> zbus::Result<()>;
|
||||
}
|
||||
|
||||
pub(crate) async fn run_listener(
|
||||
controller: Arc<GatewaySleepCycleController>,
|
||||
begin_sleep_cycle: BeginSleepCycleHook,
|
||||
end_sleep_cycle: EndSleepCycleHook,
|
||||
) -> Result<(), String> {
|
||||
let connection = zbus::Connection::system()
|
||||
.await
|
||||
.map_err(|error| format!("could not connect to the system bus: {error}"))?;
|
||||
run_listener_on_connection(&connection, controller, begin_sleep_cycle, end_sleep_cycle).await
|
||||
}
|
||||
|
||||
async fn run_listener_on_connection(
|
||||
connection: &zbus::Connection,
|
||||
controller: Arc<GatewaySleepCycleController>,
|
||||
begin_sleep_cycle: BeginSleepCycleHook,
|
||||
end_sleep_cycle: EndSleepCycleHook,
|
||||
) -> Result<(), String> {
|
||||
let proxy = Login1ManagerProxy::new(connection)
|
||||
.await
|
||||
.map_err(|error| format!("could not connect to systemd-logind: {error}"))?;
|
||||
let mut signals = proxy
|
||||
.receive_prepare_for_sleep()
|
||||
.await
|
||||
.map_err(|error| format!("could not subscribe to PrepareForSleep: {error}"))?;
|
||||
let mut inhibitor = Some(acquire_inhibitor(&proxy).await?);
|
||||
let mut cycle_began = false;
|
||||
|
||||
while let Some(signal) = signals.next().await {
|
||||
let sleeping = signal
|
||||
.args()
|
||||
.map_err(|error| format!("invalid PrepareForSleep signal: {error}"))?
|
||||
.sleeping;
|
||||
if sleeping {
|
||||
cycle_began = begin_sleep_cycle();
|
||||
controller.will_sleep().await;
|
||||
// Releasing the delay inhibitor lets logind continue into sleep.
|
||||
inhibitor.take();
|
||||
} else {
|
||||
let controller = Arc::clone(&controller);
|
||||
let end_sleep_cycle = Arc::clone(&end_sleep_cycle);
|
||||
let began = cycle_began;
|
||||
cycle_began = false;
|
||||
// Spawn wake recovery before touching logind again: a slow or hung
|
||||
// Inhibit call must not delay reconnect/resume. Spawning also keeps
|
||||
// the signal loop consuming so a new sleep cycle can abort retries.
|
||||
tauri::async_runtime::spawn(async move {
|
||||
controller.did_wake().await;
|
||||
if began {
|
||||
end_sleep_cycle();
|
||||
}
|
||||
});
|
||||
// A failed re-acquire only loses the pre-sleep delay window; keep the
|
||||
// listener alive so later sleep/wake cycles are still handled.
|
||||
inhibitor = match acquire_inhibitor(&proxy).await {
|
||||
Ok(fd) => Some(fd),
|
||||
Err(error) => {
|
||||
eprintln!("Gateway sleep: {error}");
|
||||
None
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
Err("PrepareForSleep signal stream ended".into())
|
||||
}
|
||||
|
||||
async fn acquire_inhibitor(proxy: &Login1ManagerProxy<'_>) -> Result<OwnedFd, String> {
|
||||
proxy
|
||||
.inhibit("sleep", "OpenClaw", "Suspending local gateway", "delay")
|
||||
.await
|
||||
.map_err(|error| format!("could not acquire the logind sleep inhibitor: {error}"))
|
||||
}
|
||||
@@ -2,6 +2,8 @@ use crate::gateway_device_identity::{
|
||||
GatewayAuth, GatewayDeviceIdentity, GatewayDeviceIdentityStore, CLIENT_DEVICE_FAMILY,
|
||||
CLIENT_ID, CLIENT_MODE, CLIENT_PLATFORM, CLIENT_ROLE, CLIENT_SCOPES,
|
||||
};
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
use crate::gateway_sleep::SleepPrepareOutcome;
|
||||
use crate::quickchat::QUICKCHAT_LABEL;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
|
||||
@@ -14,10 +16,14 @@ use sha2::{Digest, Sha256};
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
use std::io::ErrorKind;
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
use std::net::IpAddr;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
use subtle::ConstantTimeEq;
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
use tauri::Url;
|
||||
use tauri::{AppHandle, Emitter, Manager, Webview};
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
use tokio_tungstenite::tungstenite::{Error as TungsteniteError, Message};
|
||||
@@ -35,6 +41,8 @@ const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const COMMAND_TIMEOUT: Duration = Duration::from_secs(35);
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
const SUSPEND_REQUEST_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
const DRIVER_TICK: Duration = Duration::from_secs(1);
|
||||
const MAX_RECONNECT_DELAY: Duration = Duration::from_secs(30);
|
||||
const PAIRING_REQUIRED_DETAIL_CODE: &str = "PAIRING_REQUIRED";
|
||||
@@ -248,21 +256,62 @@ struct PluginSurfaceRefreshResponse {
|
||||
plugin_surface_urls: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SuspendPrepareResponse {
|
||||
status: Option<String>,
|
||||
suspension_id: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
impl SuspendPrepareResponse {
|
||||
fn into_outcome(self) -> SleepPrepareOutcome {
|
||||
match (self.status.as_deref(), self.suspension_id) {
|
||||
(Some("ready"), Some(suspension_id)) if !suspension_id.trim().is_empty() => {
|
||||
SleepPrepareOutcome::Ready { suspension_id }
|
||||
}
|
||||
_ => SleepPrepareOutcome::Busy,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
#[derive(Deserialize)]
|
||||
struct SuspendResumeResponse {
|
||||
resumed: bool,
|
||||
}
|
||||
|
||||
enum GatewayRequest {
|
||||
AgentsList,
|
||||
ChatSend(ChatSendParams),
|
||||
RefreshCanvasSurface { observed_url: Option<String> },
|
||||
RefreshCanvasSurface {
|
||||
observed_url: Option<String>,
|
||||
},
|
||||
#[cfg(target_os = "linux")]
|
||||
SuspendPrepare {
|
||||
request_id: String,
|
||||
},
|
||||
#[cfg(target_os = "linux")]
|
||||
SuspendResume {
|
||||
suspension_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
enum GatewayResponse {
|
||||
AgentsList(AgentsListResult),
|
||||
ChatSend(ChatSendAck),
|
||||
CanvasSurface(Option<String>),
|
||||
#[cfg(target_os = "linux")]
|
||||
SuspendPrepare(SuspendPrepareResponse),
|
||||
#[cfg(target_os = "linux")]
|
||||
SuspendResume(SuspendResumeResponse),
|
||||
}
|
||||
|
||||
enum DriverCommand {
|
||||
Request {
|
||||
request: GatewayRequest,
|
||||
budget: Option<Duration>,
|
||||
reply: oneshot::Sender<Result<GatewayResponse, String>>,
|
||||
},
|
||||
Reconfigure,
|
||||
@@ -385,6 +434,7 @@ struct GatewayClientInner {
|
||||
connection_notice: Mutex<Option<String>>,
|
||||
connection_state: AtomicU64,
|
||||
reconnect_paused: AtomicBool,
|
||||
sleep_cycle_depth: AtomicU64,
|
||||
running: AtomicBool,
|
||||
}
|
||||
|
||||
@@ -406,6 +456,7 @@ impl GatewayClient {
|
||||
connection_notice: Mutex::new(None),
|
||||
connection_state: AtomicU64::new(GatewayConnectionState::Down as u64),
|
||||
reconnect_paused: AtomicBool::new(false),
|
||||
sleep_cycle_depth: AtomicU64::new(0),
|
||||
running: AtomicBool::new(false),
|
||||
}),
|
||||
}
|
||||
@@ -581,10 +632,73 @@ impl GatewayClient {
|
||||
Ok(Some(refreshed))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub async fn suspend_prepare(&self, request_id: String) -> Result<SleepPrepareOutcome, String> {
|
||||
let response = tokio::time::timeout(SUSPEND_REQUEST_TIMEOUT, async {
|
||||
self.wait_for_sleep_connection().await;
|
||||
self.request_with_budget(
|
||||
GatewayRequest::SuspendPrepare { request_id },
|
||||
Some(SUSPEND_REQUEST_TIMEOUT),
|
||||
)
|
||||
.await
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "Gateway sleep preparation timed out.".to_string())??;
|
||||
let GatewayResponse::SuspendPrepare(response) = response else {
|
||||
return Err(
|
||||
"Gateway returned the wrong response for gateway.suspend.prepare.".to_string(),
|
||||
);
|
||||
};
|
||||
Ok(response.into_outcome())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub async fn suspend_resume(&self, suspension_id: String) -> Result<bool, String> {
|
||||
let response = tokio::time::timeout(SUSPEND_REQUEST_TIMEOUT, async {
|
||||
self.wait_for_sleep_connection().await;
|
||||
self.request_with_budget(
|
||||
GatewayRequest::SuspendResume { suspension_id },
|
||||
Some(SUSPEND_REQUEST_TIMEOUT),
|
||||
)
|
||||
.await
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "Gateway sleep resume timed out.".to_string())??;
|
||||
let GatewayResponse::SuspendResume(response) = response else {
|
||||
return Err(
|
||||
"Gateway returned the wrong response for gateway.suspend.resume.".to_string(),
|
||||
);
|
||||
};
|
||||
Ok(response.resumed)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn route_token(&self) -> Option<String> {
|
||||
self.inner
|
||||
.config
|
||||
.lock()
|
||||
.expect("gateway config mutex poisoned")
|
||||
.as_ref()
|
||||
.map(|config| config.ws_url.clone())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn is_loopback_route(&self) -> bool {
|
||||
self.loopback_route_token().is_some()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn loopback_route_token(&self) -> Option<String> {
|
||||
self.inner
|
||||
.config
|
||||
.lock()
|
||||
.expect("gateway config mutex poisoned")
|
||||
.as_ref()
|
||||
.map(|config| config.ws_url.clone())
|
||||
.filter(|route| is_loopback_ws_url(route))
|
||||
}
|
||||
|
||||
pub fn resume_reconnect(&self) {
|
||||
if !self.inner.reconnect_paused.load(Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
if let Some(commands) = self
|
||||
.inner
|
||||
.commands
|
||||
@@ -596,7 +710,45 @@ impl GatewayClient {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resume_paused_reconnect(&self) {
|
||||
if self.inner.reconnect_paused.load(Ordering::SeqCst) {
|
||||
self.resume_reconnect();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
pub(crate) fn begin_sleep_cycle(&self) {
|
||||
self.inner.sleep_cycle_depth.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
pub(crate) fn end_sleep_cycle(&self) {
|
||||
// Depth, not a boolean: an older wake task ending late must not park the
|
||||
// driver while a newer sleep cycle is still active. Saturate at zero so
|
||||
// an unbalanced end can never wrap into a permanently active driver.
|
||||
let _ = self.inner.sleep_cycle_depth.fetch_update(
|
||||
Ordering::SeqCst,
|
||||
Ordering::SeqCst,
|
||||
|depth| depth.checked_sub(1),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
async fn wait_for_sleep_connection(&self) {
|
||||
while !self.is_connected() {
|
||||
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn request(&self, request: GatewayRequest) -> Result<GatewayResponse, String> {
|
||||
self.request_with_budget(request, None).await
|
||||
}
|
||||
|
||||
async fn request_with_budget(
|
||||
&self,
|
||||
request: GatewayRequest,
|
||||
budget: Option<Duration>,
|
||||
) -> Result<GatewayResponse, String> {
|
||||
if !self.is_connected() {
|
||||
return Err("Gateway unreachable — retrying".to_string());
|
||||
}
|
||||
@@ -609,7 +761,11 @@ impl GatewayClient {
|
||||
.ok_or_else(|| "Gateway unreachable — retrying".to_string())?;
|
||||
let (reply, response) = oneshot::channel();
|
||||
commands
|
||||
.send(DriverCommand::Request { request, reply })
|
||||
.send(DriverCommand::Request {
|
||||
request,
|
||||
budget,
|
||||
reply,
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "Gateway unreachable — retrying".to_string())?;
|
||||
tokio::time::timeout(COMMAND_TIMEOUT, response)
|
||||
@@ -621,7 +777,10 @@ impl GatewayClient {
|
||||
async fn run_driver(&self, app: AppHandle, mut receiver: mpsc::Receiver<DriverCommand>) {
|
||||
let mut reconnect_attempt = 0_u32;
|
||||
loop {
|
||||
if app.get_webview_window(QUICKCHAT_LABEL).is_none() {
|
||||
if !driver_should_run(
|
||||
app.get_webview_window(QUICKCHAT_LABEL).is_some(),
|
||||
self.inner.sleep_cycle_depth.load(Ordering::SeqCst) > 0,
|
||||
) {
|
||||
self.inner.reconnect_paused.store(false, Ordering::SeqCst);
|
||||
self.set_connection_state(&app, GatewayConnectionState::Down, None);
|
||||
tokio::time::sleep(DRIVER_TICK).await;
|
||||
@@ -695,7 +854,10 @@ impl GatewayClient {
|
||||
if connection_result.is_ok() {
|
||||
reconnect_attempt = 1;
|
||||
}
|
||||
if app.get_webview_window(QUICKCHAT_LABEL).is_none() {
|
||||
if !driver_should_run(
|
||||
app.get_webview_window(QUICKCHAT_LABEL).is_some(),
|
||||
self.inner.sleep_cycle_depth.load(Ordering::SeqCst) > 0,
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let delay = reconnect_backoff(reconnect_attempt);
|
||||
@@ -736,16 +898,20 @@ impl GatewayClient {
|
||||
inline_widgets_available,
|
||||
)
|
||||
.map_err(RequestFailure::transport)?;
|
||||
let hello = match request_on_socket(app, &mut socket, "connect", params).await {
|
||||
Ok(hello) => hello,
|
||||
Err(failure) => {
|
||||
let failure = failure.classify_connect(&auth);
|
||||
if should_clear_stored_device_token(&failure, &auth) {
|
||||
self.clear_device_token(&config.ws_url)?;
|
||||
let dispatch = |frame: &Value| dispatch_chat_event(app, frame);
|
||||
let hello =
|
||||
match request_on_socket(&mut socket, "connect", params, REQUEST_TIMEOUT, &dispatch)
|
||||
.await
|
||||
{
|
||||
Ok(hello) => hello,
|
||||
Err(failure) => {
|
||||
let failure = failure.classify_connect(&auth);
|
||||
if should_clear_stored_device_token(&failure, &auth) {
|
||||
self.clear_device_token(&config.ws_url)?;
|
||||
}
|
||||
return Err(failure);
|
||||
}
|
||||
return Err(failure);
|
||||
}
|
||||
};
|
||||
};
|
||||
drop(auth);
|
||||
let hello = validate_hello(hello).map_err(RequestFailure::transport)?;
|
||||
if let Some(device_token) = hello.device_token.as_deref() {
|
||||
@@ -756,7 +922,7 @@ impl GatewayClient {
|
||||
gated_canvas_surface_url(hello.canvas_surface_url, inline_widgets_available),
|
||||
);
|
||||
|
||||
let agents = request_agents_list(app, &mut socket).await?;
|
||||
let agents = request_agents_list(&mut socket, REQUEST_TIMEOUT, &dispatch).await?;
|
||||
if self.inner.config_generation.load(Ordering::SeqCst) != generation {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -766,7 +932,10 @@ impl GatewayClient {
|
||||
|
||||
loop {
|
||||
if self.inner.config_generation.load(Ordering::SeqCst) != generation
|
||||
|| app.get_webview_window(QUICKCHAT_LABEL).is_none()
|
||||
|| !driver_should_run(
|
||||
app.get_webview_window(QUICKCHAT_LABEL).is_some(),
|
||||
self.inner.sleep_cycle_depth.load(Ordering::SeqCst) > 0,
|
||||
)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
@@ -777,8 +946,8 @@ impl GatewayClient {
|
||||
};
|
||||
match command {
|
||||
DriverCommand::Reconfigure => return Ok(()),
|
||||
DriverCommand::Request { request, reply } => {
|
||||
let result = perform_request(app, &mut socket, request).await;
|
||||
DriverCommand::Request { request, budget, reply } => {
|
||||
let result = perform_request(&mut socket, request, budget, &dispatch).await;
|
||||
last_gateway_activity = Instant::now();
|
||||
match result {
|
||||
Ok(response) => {
|
||||
@@ -989,6 +1158,12 @@ fn reject_disconnected_command(command: DriverCommand) {
|
||||
}
|
||||
}
|
||||
|
||||
fn driver_should_run(window_exists: bool, sleep_active: bool) -> bool {
|
||||
// Sleep cycles temporarily activate the driver; the companion-wide connection lifetime
|
||||
// remains owned by Quick Chat outside that narrow window.
|
||||
window_exists || sleep_active
|
||||
}
|
||||
|
||||
fn routing_target(scope: &str, selected_agent_id: &str, main_key: &str) -> ChatRoutingTarget {
|
||||
if scope.trim().eq_ignore_ascii_case("global") {
|
||||
ChatRoutingTarget {
|
||||
@@ -1169,12 +1344,16 @@ async fn wait_for_connect_challenge(
|
||||
.map_err(|_| RequestFailure::transport("Gateway connect challenge timed out."))?
|
||||
}
|
||||
|
||||
async fn request_on_socket(
|
||||
app: &AppHandle,
|
||||
async fn request_on_socket<F>(
|
||||
socket: &mut GatewaySocket,
|
||||
method: &str,
|
||||
params: Value,
|
||||
) -> Result<Value, RequestFailure> {
|
||||
budget: Duration,
|
||||
dispatch: &F,
|
||||
) -> Result<Value, RequestFailure>
|
||||
where
|
||||
F: Fn(&Value),
|
||||
{
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let encoded = serde_json::to_string(&request_frame(&id, method, params)).map_err(|error| {
|
||||
RequestFailure::transport(format!("Could not encode {method}: {error}"))
|
||||
@@ -1184,10 +1363,10 @@ async fn request_on_socket(
|
||||
.await
|
||||
.map_err(|error| RequestFailure::transport(format!("Could not send {method}: {error}")))?;
|
||||
|
||||
tokio::time::timeout(REQUEST_TIMEOUT, async {
|
||||
tokio::time::timeout(budget, async {
|
||||
loop {
|
||||
let value = next_json(socket).await?;
|
||||
dispatch_chat_event(app, &value);
|
||||
dispatch(&value);
|
||||
if value.get("type").and_then(Value::as_str) != Some("res")
|
||||
|| value.get("id").and_then(Value::as_str) != Some(id.as_str())
|
||||
{
|
||||
@@ -1212,20 +1391,25 @@ async fn request_on_socket(
|
||||
.map_err(|_| RequestFailure::transport(format!("Gateway {method} request timed out.")))?
|
||||
}
|
||||
|
||||
async fn perform_request(
|
||||
app: &AppHandle,
|
||||
async fn perform_request<F>(
|
||||
socket: &mut GatewaySocket,
|
||||
request: GatewayRequest,
|
||||
) -> Result<GatewayResponse, RequestFailure> {
|
||||
budget: Option<Duration>,
|
||||
dispatch: &F,
|
||||
) -> Result<GatewayResponse, RequestFailure>
|
||||
where
|
||||
F: Fn(&Value),
|
||||
{
|
||||
let budget = budget.unwrap_or(REQUEST_TIMEOUT);
|
||||
match request {
|
||||
GatewayRequest::AgentsList => request_agents_list(app, socket)
|
||||
GatewayRequest::AgentsList => request_agents_list(socket, budget, dispatch)
|
||||
.await
|
||||
.map(GatewayResponse::AgentsList),
|
||||
GatewayRequest::ChatSend(params) => {
|
||||
let params = serde_json::to_value(params).map_err(|error| {
|
||||
RequestFailure::transport(format!("Could not encode chat.send: {error}"))
|
||||
})?;
|
||||
let payload = request_on_socket(app, socket, "chat.send", params).await?;
|
||||
let payload = request_on_socket(socket, "chat.send", params, budget, dispatch).await?;
|
||||
serde_json::from_value(payload)
|
||||
.map(GatewayResponse::ChatSend)
|
||||
.map_err(|error| {
|
||||
@@ -1237,7 +1421,9 @@ async fn perform_request(
|
||||
if let Some(observed_url) = observed_url {
|
||||
params["observedUrl"] = Value::String(observed_url);
|
||||
}
|
||||
let payload = request_on_socket(app, socket, "plugin.surface.refresh", params).await?;
|
||||
let payload =
|
||||
request_on_socket(socket, "plugin.surface.refresh", params, budget, dispatch)
|
||||
.await?;
|
||||
let response: PluginSurfaceRefreshResponse =
|
||||
serde_json::from_value(payload).map_err(|error| {
|
||||
RequestFailure::transport(format!(
|
||||
@@ -1251,14 +1437,71 @@ async fn perform_request(
|
||||
.filter(|url| !url.is_empty());
|
||||
Ok(GatewayResponse::CanvasSurface(canvas))
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
GatewayRequest::SuspendPrepare { request_id } => {
|
||||
let payload = request_on_socket(
|
||||
socket,
|
||||
"gateway.suspend.prepare",
|
||||
json!({ "requestId": request_id }),
|
||||
budget,
|
||||
dispatch,
|
||||
)
|
||||
.await?;
|
||||
serde_json::from_value(payload)
|
||||
.map(GatewayResponse::SuspendPrepare)
|
||||
.map_err(|error| {
|
||||
RequestFailure::transport(format!(
|
||||
"Invalid gateway.suspend.prepare response: {error}"
|
||||
))
|
||||
})
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
GatewayRequest::SuspendResume { suspension_id } => {
|
||||
let payload = request_on_socket(
|
||||
socket,
|
||||
"gateway.suspend.resume",
|
||||
json!({ "suspensionId": suspension_id }),
|
||||
budget,
|
||||
dispatch,
|
||||
)
|
||||
.await?;
|
||||
serde_json::from_value(payload)
|
||||
.map(GatewayResponse::SuspendResume)
|
||||
.map_err(|error| {
|
||||
RequestFailure::transport(format!(
|
||||
"Invalid gateway.suspend.resume response: {error}"
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn request_agents_list(
|
||||
app: &AppHandle,
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
fn is_loopback_ws_url(raw: &str) -> bool {
|
||||
let Ok(url) = Url::parse(raw) else {
|
||||
return false;
|
||||
};
|
||||
if !matches!(url.scheme(), "ws" | "wss") {
|
||||
return false;
|
||||
}
|
||||
url.host_str().is_some_and(|host| {
|
||||
host.eq_ignore_ascii_case("localhost")
|
||||
|| host
|
||||
.trim_matches(['[', ']'])
|
||||
.parse::<IpAddr>()
|
||||
.is_ok_and(|address| address.is_loopback())
|
||||
})
|
||||
}
|
||||
|
||||
async fn request_agents_list<F>(
|
||||
socket: &mut GatewaySocket,
|
||||
) -> Result<AgentsListResult, RequestFailure> {
|
||||
let payload = request_on_socket(app, socket, "agents.list", json!({})).await?;
|
||||
budget: Duration,
|
||||
dispatch: &F,
|
||||
) -> Result<AgentsListResult, RequestFailure>
|
||||
where
|
||||
F: Fn(&Value),
|
||||
{
|
||||
let payload = request_on_socket(socket, "agents.list", json!({}), budget, dispatch).await?;
|
||||
serde_json::from_value(payload).map_err(|error| {
|
||||
RequestFailure::transport(format!("Invalid agents.list response: {error}"))
|
||||
})
|
||||
@@ -1477,7 +1720,7 @@ async fn handle_idle_message(
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_chat_event(app: &AppHandle, frame: &Value) {
|
||||
fn dispatch_chat_event<R: tauri::Runtime>(app: &AppHandle<R>, frame: &Value) {
|
||||
if frame.get("type").and_then(Value::as_str) != Some("event")
|
||||
|| frame.get("event").and_then(Value::as_str) != Some("chat")
|
||||
{
|
||||
@@ -1493,6 +1736,104 @@ fn dispatch_chat_event(app: &AppHandle, frame: &Value) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sleep_cycle_runs_driver_without_quick_chat() {
|
||||
let client = GatewayClient::new();
|
||||
let sleep_active =
|
||||
|client: &GatewayClient| client.inner.sleep_cycle_depth.load(Ordering::SeqCst) > 0;
|
||||
assert!(!driver_should_run(false, false));
|
||||
assert!(driver_should_run(true, false));
|
||||
client.begin_sleep_cycle();
|
||||
assert!(driver_should_run(false, sleep_active(&client)));
|
||||
client.end_sleep_cycle();
|
||||
assert!(!driver_should_run(false, sleep_active(&client)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn late_wake_end_does_not_park_a_newer_sleep_cycle() {
|
||||
let client = GatewayClient::new();
|
||||
let sleep_active =
|
||||
|client: &GatewayClient| client.inner.sleep_cycle_depth.load(Ordering::SeqCst) > 0;
|
||||
client.begin_sleep_cycle(); // cycle 1 sleeps
|
||||
client.begin_sleep_cycle(); // cycle 2 sleeps before cycle 1's wake task ends
|
||||
client.end_sleep_cycle(); // cycle 1's wake ends late
|
||||
assert!(driver_should_run(false, sleep_active(&client)));
|
||||
client.end_sleep_cycle();
|
||||
assert!(!driver_should_run(false, sleep_active(&client)));
|
||||
// An unbalanced extra end saturates at zero instead of wrapping.
|
||||
client.end_sleep_cycle();
|
||||
assert!(!driver_should_run(false, sleep_active(&client)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn budgeted_driver_request_releases_the_serial_queue() {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind websocket fixture");
|
||||
let address = listener.local_addr().expect("fixture address");
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await.expect("accept websocket fixture");
|
||||
let mut socket = tokio_tungstenite::accept_async(stream)
|
||||
.await
|
||||
.expect("accept websocket handshake");
|
||||
let _request = socket.next().await.expect("request frame");
|
||||
std::future::pending::<()>().await;
|
||||
});
|
||||
let (mut socket, _) = tokio_tungstenite::connect_async(format!("ws://{address}"))
|
||||
.await
|
||||
.expect("connect websocket fixture");
|
||||
let (commands, mut receiver) = mpsc::channel(2);
|
||||
let (reply, response) = oneshot::channel();
|
||||
commands
|
||||
.send(DriverCommand::Request {
|
||||
request: GatewayRequest::AgentsList,
|
||||
budget: Some(SUSPEND_REQUEST_TIMEOUT),
|
||||
reply,
|
||||
})
|
||||
.await
|
||||
.expect("queue budgeted request");
|
||||
commands
|
||||
.send(DriverCommand::Reconfigure)
|
||||
.await
|
||||
.expect("queue reconnect");
|
||||
|
||||
let started = Instant::now();
|
||||
let command = receiver.recv().await.expect("budgeted request");
|
||||
let DriverCommand::Request {
|
||||
request,
|
||||
budget,
|
||||
reply,
|
||||
} = command
|
||||
else {
|
||||
panic!("expected request command");
|
||||
};
|
||||
let failure = match perform_request(&mut socket, request, budget, &|_| {}).await {
|
||||
Ok(_) => panic!("hung request should time out"),
|
||||
Err(failure) => failure,
|
||||
};
|
||||
let elapsed = started.elapsed();
|
||||
assert!(failure.disconnect, "timeout must recycle the socket");
|
||||
let _ = reply.send(Err(failure.message));
|
||||
|
||||
assert!(matches!(
|
||||
tokio::time::timeout(Duration::from_millis(250), receiver.recv())
|
||||
.await
|
||||
.expect("serial queue remained blocked"),
|
||||
Some(DriverCommand::Reconfigure)
|
||||
));
|
||||
assert!(
|
||||
elapsed >= Duration::from_millis(2_750),
|
||||
"elapsed: {elapsed:?}"
|
||||
);
|
||||
assert!(elapsed < Duration::from_secs(4), "elapsed: {elapsed:?}");
|
||||
let reply = response.await.expect("driver reply");
|
||||
match reply {
|
||||
Ok(_) => panic!("expected timeout reply"),
|
||||
Err(error) => assert!(error.contains("agents.list request timed out")),
|
||||
}
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn routing_matches_macos_quick_chat_contract() {
|
||||
assert_eq!(
|
||||
@@ -1753,6 +2094,64 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sleep_gateway_routes_are_loopback_only() {
|
||||
for route in [
|
||||
"ws://localhost:18789",
|
||||
"ws://127.0.0.1:18789",
|
||||
"wss://[::1]:18789",
|
||||
] {
|
||||
assert!(
|
||||
is_loopback_ws_url(route),
|
||||
"expected loopback route: {route}"
|
||||
);
|
||||
}
|
||||
for route in [
|
||||
"ws://192.168.1.10:18789",
|
||||
"wss://gateway.example:18789",
|
||||
"https://127.0.0.1:18789",
|
||||
"not a URL",
|
||||
] {
|
||||
assert!(!is_loopback_ws_url(route), "expected remote route: {route}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suspend_wire_results_decode_leniently() {
|
||||
let ready: SuspendPrepareResponse = serde_json::from_value(json!({
|
||||
"status": "ready",
|
||||
"suspensionId": "suspension-1",
|
||||
"expiresAtMs": 1_800_000_000_000_u64,
|
||||
"activeCount": 0,
|
||||
"blockers": []
|
||||
}))
|
||||
.expect("ready suspension response");
|
||||
assert_eq!(
|
||||
ready.into_outcome(),
|
||||
SleepPrepareOutcome::Ready {
|
||||
suspension_id: "suspension-1".into()
|
||||
}
|
||||
);
|
||||
|
||||
let busy: SuspendPrepareResponse = serde_json::from_value(json!({
|
||||
"status": "busy",
|
||||
"reason": "active-work",
|
||||
"retryAfterMs": 1000,
|
||||
"activeCount": 1,
|
||||
"blockers": []
|
||||
}))
|
||||
.expect("busy suspension response");
|
||||
assert_eq!(busy.into_outcome(), SleepPrepareOutcome::Busy);
|
||||
|
||||
let resumed: SuspendResumeResponse = serde_json::from_value(json!({
|
||||
"ok": true,
|
||||
"status": "running",
|
||||
"resumed": false
|
||||
}))
|
||||
.expect("resume response");
|
||||
assert!(!resumed.resumed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_state_event_carries_canvas_surface_in_camel_case() {
|
||||
let event = serde_json::to_value(GatewayStateEvent::new(
|
||||
|
||||
@@ -5,6 +5,12 @@ mod discovery;
|
||||
mod gateway;
|
||||
mod gateway_device_identity;
|
||||
mod gateway_operation_queue;
|
||||
#[cfg_attr(not(any(target_os = "linux", test)), allow(dead_code))]
|
||||
mod gateway_sleep;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod gateway_sleep_logind;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod gateway_sleep_logind_listener;
|
||||
mod gateway_ws;
|
||||
mod installer;
|
||||
mod notify;
|
||||
@@ -743,6 +749,10 @@ fn main() {
|
||||
let state = DesktopState::new(window.url()?);
|
||||
app.manage(state.clone());
|
||||
app.manage(gateway_ws::GatewayClient::new());
|
||||
#[cfg(target_os = "linux")]
|
||||
app.manage(gateway_sleep_logind::SleepBridge::start(
|
||||
app.handle().clone(),
|
||||
));
|
||||
let operation_app = app.handle().clone();
|
||||
let operation_state = state.clone();
|
||||
let error_app = app.handle().clone();
|
||||
@@ -874,6 +884,9 @@ fn main() {
|
||||
app.run(|app, event| {
|
||||
#[cfg(target_os = "linux")]
|
||||
if matches!(event, tauri::RunEvent::Exit) {
|
||||
if let Some(bridge) = app.try_state::<gateway_sleep_logind::SleepBridge>() {
|
||||
bridge.shutdown();
|
||||
}
|
||||
if let Some(bridge) = app.try_state::<canvas::CanvasBridge>() {
|
||||
bridge.shutdown();
|
||||
}
|
||||
|
||||
@@ -550,7 +550,7 @@ pub fn toggle_quickchat(app: &AppHandle) {
|
||||
|
||||
fn show_quickchat(app: &AppHandle) -> Result<(), String> {
|
||||
let window = ensure_quickchat_window(app)?;
|
||||
app.state::<GatewayClient>().resume_reconnect();
|
||||
app.state::<GatewayClient>().resume_paused_reconnect();
|
||||
window
|
||||
.set_size(LogicalSize::new(QUICKCHAT_WIDTH, QUICKCHAT_HEIGHT))
|
||||
.map_err(|error| format!("Could not reset Quick Chat size: {error}"))?;
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
#![cfg(target_os = "linux")]
|
||||
|
||||
#[path = "../src/gateway_sleep.rs"]
|
||||
mod gateway_sleep;
|
||||
#[path = "../src/gateway_sleep_logind_listener.rs"]
|
||||
mod gateway_sleep_logind_listener;
|
||||
|
||||
use gateway_sleep::{GatewaySleepCycleController, SleepPrepareOutcome};
|
||||
use gateway_sleep_logind_listener::{run_listener, BeginSleepCycleHook, EndSleepCycleHook};
|
||||
use std::io::{BufRead, BufReader, Read};
|
||||
use std::os::fd::OwnedFd as StdOwnedFd;
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
use uuid::Uuid;
|
||||
use zbus::object_server::SignalEmitter;
|
||||
use zbus::zvariant::OwnedFd;
|
||||
|
||||
const LOGIN1_PATH: &str = "/org/freedesktop/login1";
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
enum MockEvent {
|
||||
InhibitorAcquired(usize),
|
||||
InhibitorReleased(usize),
|
||||
DriverActivated,
|
||||
Prepare,
|
||||
Refresh,
|
||||
Resume,
|
||||
DriverDeactivated,
|
||||
}
|
||||
|
||||
struct MockLogin1 {
|
||||
events: mpsc::UnboundedSender<MockEvent>,
|
||||
next_inhibitor: std::sync::atomic::AtomicUsize,
|
||||
}
|
||||
|
||||
#[zbus::interface(name = "org.freedesktop.login1.Manager")]
|
||||
impl MockLogin1 {
|
||||
fn inhibit(
|
||||
&self,
|
||||
_what: &str,
|
||||
_who: &str,
|
||||
_why: &str,
|
||||
_mode: &str,
|
||||
) -> zbus::fdo::Result<OwnedFd> {
|
||||
let id = self
|
||||
.next_inhibitor
|
||||
.fetch_add(1, std::sync::atomic::Ordering::SeqCst)
|
||||
+ 1;
|
||||
let (mut release_reader, inhibitor) =
|
||||
UnixStream::pair().map_err(|error| zbus::fdo::Error::Failed(error.to_string()))?;
|
||||
let events = self.events.clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut byte = [0_u8; 1];
|
||||
loop {
|
||||
match release_reader.read(&mut byte) {
|
||||
Ok(0) => {
|
||||
let _ = events.send(MockEvent::InhibitorReleased(id));
|
||||
return;
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(error) => {
|
||||
eprintln!("mock inhibitor {id} release probe failed: {error}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let _ = self.events.send(MockEvent::InhibitorAcquired(id));
|
||||
let inhibitor: StdOwnedFd = inhibitor.into();
|
||||
Ok(inhibitor.into())
|
||||
}
|
||||
|
||||
#[zbus(signal)]
|
||||
async fn prepare_for_sleep(emitter: &SignalEmitter<'_>, sleeping: bool) -> zbus::Result<()>;
|
||||
}
|
||||
|
||||
struct DbusDaemon {
|
||||
child: Child,
|
||||
directory: PathBuf,
|
||||
}
|
||||
|
||||
impl Drop for DbusDaemon {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
let _ = std::fs::remove_dir_all(&self.directory);
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_dbus_daemon() -> Option<(DbusDaemon, String)> {
|
||||
if !Command::new("dbus-daemon")
|
||||
.arg("--version")
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.is_ok_and(|status| status.success())
|
||||
{
|
||||
eprintln!("SKIP logind_sleep: dbus-daemon is unavailable (install the dbus package)");
|
||||
return None;
|
||||
}
|
||||
let directory = std::env::temp_dir().join(format!("openclaw-logind-{}", Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&directory).expect("create private D-Bus directory");
|
||||
let address = format!("unix:path={}", directory.join("bus.sock").display());
|
||||
let mut child = Command::new("dbus-daemon")
|
||||
.args([
|
||||
"--session",
|
||||
"--nofork",
|
||||
"--nopidfile",
|
||||
"--print-address=1",
|
||||
&format!("--address={address}"),
|
||||
])
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.expect("start private dbus-daemon");
|
||||
let mut announced_address = String::new();
|
||||
BufReader::new(child.stdout.take().expect("dbus-daemon stdout"))
|
||||
.read_line(&mut announced_address)
|
||||
.expect("read private D-Bus address");
|
||||
if announced_address.trim().is_empty() {
|
||||
let mut error = String::new();
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
BufReader::new(stderr)
|
||||
.read_to_string(&mut error)
|
||||
.expect("read dbus-daemon failure");
|
||||
}
|
||||
panic!("private dbus-daemon did not announce an address: {error}");
|
||||
}
|
||||
Some((
|
||||
DbusDaemon { child, directory },
|
||||
announced_address.trim().to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
struct SystemBusAddress(Option<String>);
|
||||
|
||||
impl SystemBusAddress {
|
||||
fn set(address: &str) -> Self {
|
||||
let previous = std::env::var("DBUS_SYSTEM_BUS_ADDRESS").ok();
|
||||
std::env::set_var("DBUS_SYSTEM_BUS_ADDRESS", address);
|
||||
Self(previous)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SystemBusAddress {
|
||||
fn drop(&mut self) {
|
||||
if let Some(previous) = self.0.as_deref() {
|
||||
std::env::set_var("DBUS_SYSTEM_BUS_ADDRESS", previous);
|
||||
} else {
|
||||
std::env::remove_var("DBUS_SYSTEM_BUS_ADDRESS");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn next_event(events: &mut mpsc::UnboundedReceiver<MockEvent>) -> MockEvent {
|
||||
tokio::time::timeout(Duration::from_secs(3), events.recv())
|
||||
.await
|
||||
.expect("timed out waiting for mock logind event")
|
||||
.expect("mock logind event channel closed")
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore = "requires Linux and dbus-daemon; run with cargo test -- --ignored logind"]
|
||||
async fn logind_full_sleep_cycle_releases_and_reacquires_inhibitor() {
|
||||
let Some((_daemon, address)) = spawn_dbus_daemon() else {
|
||||
return;
|
||||
};
|
||||
let _system_bus = SystemBusAddress::set(&address);
|
||||
let (event_tx, mut events) = mpsc::unbounded_channel();
|
||||
let service = zbus::connection::Builder::address(address.as_str())
|
||||
.expect("private D-Bus address")
|
||||
.name("org.freedesktop.login1")
|
||||
.expect("request mock login1 name")
|
||||
.serve_at(
|
||||
LOGIN1_PATH,
|
||||
MockLogin1 {
|
||||
events: event_tx.clone(),
|
||||
next_inhibitor: std::sync::atomic::AtomicUsize::new(0),
|
||||
},
|
||||
)
|
||||
.expect("register mock login1 manager")
|
||||
.build()
|
||||
.await
|
||||
.expect("connect mock login1 service");
|
||||
|
||||
let prepare_events = event_tx.clone();
|
||||
let refresh_events = event_tx.clone();
|
||||
let resume_events = event_tx.clone();
|
||||
let controller = Arc::new(GatewaySleepCycleController::new(
|
||||
"logind-proof".into(),
|
||||
|| Some("ws://127.0.0.1:18789".into()),
|
||||
move |_| {
|
||||
let events = prepare_events.clone();
|
||||
async move {
|
||||
let _ = events.send(MockEvent::Prepare);
|
||||
Ok(SleepPrepareOutcome::Ready {
|
||||
suspension_id: "mock-suspension".into(),
|
||||
})
|
||||
}
|
||||
},
|
||||
move |_| {
|
||||
let events = resume_events.clone();
|
||||
async move {
|
||||
let _ = events.send(MockEvent::Resume);
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
move || {
|
||||
let events = refresh_events.clone();
|
||||
async move {
|
||||
let _ = events.send(MockEvent::Refresh);
|
||||
}
|
||||
},
|
||||
|_| std::future::ready(()),
|
||||
|message| eprintln!("mock Gateway sleep: {message}"),
|
||||
));
|
||||
let begin_events = event_tx.clone();
|
||||
let begin_sleep_cycle: BeginSleepCycleHook = Arc::new(move || {
|
||||
let _ = begin_events.send(MockEvent::DriverActivated);
|
||||
true
|
||||
});
|
||||
let end_events = event_tx;
|
||||
let end_sleep_cycle: EndSleepCycleHook = Arc::new(move || {
|
||||
let _ = end_events.send(MockEvent::DriverDeactivated);
|
||||
});
|
||||
let listener = tokio::spawn(run_listener(controller, begin_sleep_cycle, end_sleep_cycle));
|
||||
|
||||
assert_eq!(
|
||||
next_event(&mut events).await,
|
||||
MockEvent::InhibitorAcquired(1)
|
||||
);
|
||||
let interface = service
|
||||
.object_server()
|
||||
.interface::<_, MockLogin1>(LOGIN1_PATH)
|
||||
.await
|
||||
.expect("mock login1 interface");
|
||||
MockLogin1::prepare_for_sleep(interface.signal_emitter(), true)
|
||||
.await
|
||||
.expect("emit sleep signal");
|
||||
assert_eq!(next_event(&mut events).await, MockEvent::DriverActivated);
|
||||
assert_eq!(next_event(&mut events).await, MockEvent::Prepare);
|
||||
assert_eq!(
|
||||
next_event(&mut events).await,
|
||||
MockEvent::InhibitorReleased(1)
|
||||
);
|
||||
|
||||
MockLogin1::prepare_for_sleep(interface.signal_emitter(), false)
|
||||
.await
|
||||
.expect("emit wake signal");
|
||||
// Wake recovery is spawned before the inhibitor re-acquire so a slow logind
|
||||
// cannot delay reconnect/resume; only the relative order of the recovery
|
||||
// chain is guaranteed.
|
||||
let mut wake_events = Vec::new();
|
||||
for _ in 0..4 {
|
||||
wake_events.push(next_event(&mut events).await);
|
||||
}
|
||||
assert!(wake_events.contains(&MockEvent::InhibitorAcquired(2)));
|
||||
let recovery: Vec<_> = wake_events
|
||||
.into_iter()
|
||||
.filter(|event| *event != MockEvent::InhibitorAcquired(2))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
recovery,
|
||||
vec![
|
||||
MockEvent::Refresh,
|
||||
MockEvent::Resume,
|
||||
MockEvent::DriverDeactivated
|
||||
]
|
||||
);
|
||||
|
||||
listener.abort();
|
||||
}
|
||||
@@ -50,11 +50,11 @@ continues after a long pause, the gateway detects the freeze within about 30
|
||||
seconds. It restarts channel connections and refreshes cached health and
|
||||
presence so clients do not wait for stale sockets or snapshots to expire.
|
||||
|
||||
The macOS app cooperates with a local gateway by preparing a short suspension
|
||||
lease before the Mac sleeps and resuming it after wake. Remote gateways are not
|
||||
suspended when the Mac sleeps. A deliberate suspension through
|
||||
`gateway.suspend.*` keeps recovery deferred until the controller resumes the
|
||||
gateway.
|
||||
The macOS app and Linux companion cooperate with a local gateway by preparing a
|
||||
short suspension lease before the host sleeps and resuming it after wake. Remote
|
||||
gateways are not suspended when the app host sleeps. A deliberate suspension
|
||||
through `gateway.suspend.*` keeps recovery deferred until the controller resumes
|
||||
the gateway.
|
||||
|
||||
## How interrupted work is detected
|
||||
|
||||
|
||||
@@ -29,6 +29,14 @@ The OpenClaw Linux companion is a Tauri desktop app for a local Gateway. It:
|
||||
- renders agent-driven Canvas and bundled A2UI content for a colocated CLI node host
|
||||
- remains available from the system tray when its window is closed
|
||||
|
||||
### Host sleep
|
||||
|
||||
On systems with systemd-logind, the companion prepares a suspension lease for
|
||||
its local Gateway before the host sleeps. After wake, it reconnects and resumes
|
||||
the Gateway; remote Gateway routes are left untouched. If logind or the system
|
||||
bus is unavailable, the sleep hook disables itself and the app continues
|
||||
normally.
|
||||
|
||||
Realtime voice Talk inside the companion's embedded WebView is not validated:
|
||||
the shell does not grant microphone capture to the WebKitGTK WebView, so
|
||||
`getUserMedia` is expected to fail there. Until that lands, open the Gateway's
|
||||
|
||||
Reference in New Issue
Block a user