use std::path::PathBuf;
use std::time::Duration;
use rand::RngExt;
use serde::de::DeserializeOwned;
use tokio::sync::watch;
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use crate::daemon::client::DaemonClient;
use crate::daemon::protocol::DaemonEnvelope;
#[derive(Debug, Clone)]
pub enum FeedFrame<T> {
Connecting,
Live(T),
Reconnecting { attempt: u32, retry_in: Duration },
Polling,
}
pub fn spawn_subscription<T>(
socket: PathBuf,
subscribe_envelope: DaemonEnvelope,
poll_envelope: DaemonEnvelope,
poll_interval: Duration,
cancel: CancellationToken,
) -> (watch::Receiver<FeedFrame<T>>, JoinHandle<()>)
where
T: DeserializeOwned + Send + Sync + 'static,
{
let (tx, rx) = watch::channel(FeedFrame::Connecting);
let handle = tokio::spawn(run_supervisor(
socket,
subscribe_envelope,
poll_envelope,
poll_interval,
tx,
cancel,
));
(rx, handle)
}
async fn run_supervisor<T>(
socket: PathBuf,
subscribe_envelope: DaemonEnvelope,
poll_envelope: DaemonEnvelope,
poll_interval: Duration,
tx: watch::Sender<FeedFrame<T>>,
cancel: CancellationToken,
) where
T: DeserializeOwned + Send + Sync + 'static,
{
let client = DaemonClient::new(socket);
let mut attempt: u32 = 0;
loop {
if cancel.is_cancelled() {
return;
}
let subscribe_result = tokio::select! {
result = client.subscribe(subscribe_envelope.clone()) => result,
() = cancel.cancelled() => return,
};
match subscribe_result {
Ok(mut sub) => {
let first = tokio::select! {
frame = sub.next() => frame,
() = cancel.cancelled() => return,
};
match first {
Some(Ok(reply)) if !reply.ok => {
tracing::info!(
"worktrees ui: daemon does not support subscribe; falling back to polling"
);
drop(sub);
return run_polling_fallback(
client,
poll_envelope,
poll_interval,
tx,
cancel,
)
.await;
}
Some(Ok(reply)) => {
match serde_json::from_value(reply.payload) {
Ok(value) => {
attempt = 0;
let _ = tx.send(FeedFrame::Live(value));
}
Err(e) => {
tracing::warn!(
"worktrees ui: failed to parse subscribe payload: {e:#}"
);
}
}
loop {
let next = tokio::select! {
frame = sub.next() => frame,
() = cancel.cancelled() => return,
};
match next {
Some(Ok(reply)) if reply.ok => {
match serde_json::from_value(reply.payload) {
Ok(value) => {
let _ = tx.send(FeedFrame::Live(value));
}
Err(e) => tracing::warn!(
"worktrees ui: failed to parse pushed payload: {e:#}"
),
}
}
Some(Ok(_)) => {
tracing::warn!(
"worktrees ui: daemon sent an unexpected ok:false frame after the initial subscribe; reconnecting"
);
break;
}
Some(Err(e)) => {
tracing::warn!(
"worktrees ui: subscription stream error: {e:#}; reconnecting"
);
break;
}
None => {
tracing::info!(
"worktrees ui: daemon closed the subscription; reconnecting"
);
break;
}
}
}
}
Some(Err(e)) => {
tracing::warn!(
"worktrees ui: failed to read the first subscribe frame: {e:#}"
);
}
None => {
tracing::warn!(
"worktrees ui: daemon closed the connection before sending a subscribe frame"
);
}
}
}
Err(e) => {
tracing::warn!("worktrees ui: failed to connect to the daemon: {e:#}");
}
}
let delay = full_jitter_backoff(attempt);
let _ = tx.send(FeedFrame::Reconnecting {
attempt,
retry_in: delay,
});
tokio::select! {
() = tokio::time::sleep(delay) => {}
() = cancel.cancelled() => return,
}
attempt = attempt.saturating_add(1);
}
}
async fn run_polling_fallback<T>(
client: DaemonClient,
poll_envelope: DaemonEnvelope,
poll_interval: Duration,
tx: watch::Sender<FeedFrame<T>>,
cancel: CancellationToken,
) where
T: DeserializeOwned + Send + Sync + 'static,
{
let _ = tx.send(FeedFrame::Polling);
let mut ticker = tokio::time::interval(poll_interval);
loop {
tokio::select! {
_ = ticker.tick() => {
match client.request(poll_envelope.clone()).await {
Ok(reply) if reply.ok => match serde_json::from_value(reply.payload) {
Ok(value) => {
let _ = tx.send(FeedFrame::Live(value));
}
Err(e) => tracing::warn!(
"worktrees ui: failed to parse polled payload: {e:#}"
),
},
Ok(reply) => tracing::warn!(
"worktrees ui: poll request rejected: {:?}", reply.error
),
Err(e) => tracing::warn!("worktrees ui: poll request failed: {e:#}"),
}
}
() = cancel.cancelled() => return,
}
}
}
fn full_jitter_backoff(attempt: u32) -> Duration {
const BASE_MS: u64 = 500;
const CAP_MS: u64 = 10_000;
let exp_ms = BASE_MS.saturating_mul(1u64 << attempt.min(20)).min(CAP_MS);
let jittered_ms = rand::rng().random_range(0..=exp_ms);
Duration::from_millis(jittered_ms)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn full_jitter_backoff_is_bounded_by_the_cap() {
for attempt in 0..40 {
let delay = full_jitter_backoff(attempt);
assert!(
delay <= Duration::from_millis(10_000),
"attempt {attempt}: {delay:?}"
);
}
}
#[test]
fn full_jitter_backoff_grows_with_attempt_number() {
let late = full_jitter_backoff(10);
assert!(late <= Duration::from_millis(10_000));
}
#[tokio::test]
async fn subscription_delivers_live_frames_from_a_fake_daemon() {
use crate::daemon::testutil::fake_daemon_stream_hold_open;
use serde_json::json;
let (_dir, sock, close_tx, server) = fake_daemon_stream_hold_open(vec![json!({
"ok": true,
"payload": { "repos": [], "show_closed": false },
})]);
let cancel = CancellationToken::new();
let (mut rx, _task) = spawn_subscription::<super::super::wire::TreeSnapshotWire>(
sock,
DaemonEnvelope::service("worktrees", "subscribe", serde_json::Value::Null),
DaemonEnvelope::service("worktrees", "tree", serde_json::Value::Null),
Duration::from_millis(50),
cancel.clone(),
);
loop {
if matches!(&*rx.borrow(), FeedFrame::Live(_)) {
break;
}
rx.changed().await.unwrap();
}
assert!(matches!(&*rx.borrow(), FeedFrame::Live(snapshot) if snapshot.repos.is_empty()));
drop(close_tx);
cancel.cancel();
server.await.unwrap();
}
#[tokio::test]
async fn subscription_refused_by_an_old_daemon_falls_back_to_polling() {
use crate::daemon::testutil::fake_daemon_reply;
use serde_json::json;
let (_dir, sock, _server) =
fake_daemon_reply(json!({ "ok": false, "error": "unknown worktrees op: subscribe" }));
let cancel = CancellationToken::new();
let (mut rx, _task) = spawn_subscription::<super::super::wire::TreeSnapshotWire>(
sock,
DaemonEnvelope::service("worktrees", "subscribe", serde_json::Value::Null),
DaemonEnvelope::service("worktrees", "tree", serde_json::Value::Null),
Duration::from_secs(3600),
cancel.clone(),
);
loop {
if matches!(&*rx.borrow(), FeedFrame::Polling) {
break;
}
rx.changed().await.unwrap();
}
cancel.cancel();
}
}