1use crate::store::TaskStore;
5use futures::{SinkExt, StreamExt};
6use std::sync::Arc;
7use std::time::{Duration, Instant};
8use tokio::sync::Notify;
9use tokio::time;
10use tokio_tungstenite::tungstenite::{Message, client::IntoClientRequest};
11
12const CURSOR_KEY: &str = "relay_cursor";
13const PING_EVERY: Duration = Duration::from_secs(300);
14const CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
15const MAX_BACKOFF_SECS: u64 = 300;
16const STABLE_SESSION: Duration = Duration::from_secs(60);
19
20#[derive(serde::Deserialize)]
21struct Incoming {
22 #[serde(rename = "type")]
23 kind: String,
24 #[serde(default)]
25 seq: i64,
26 #[serde(default)]
27 event: String,
28 #[serde(default)]
29 action: String,
30 #[serde(default)]
31 repo: String,
32 #[serde(default)]
33 payload: Option<serde_json::Value>,
34}
35
36pub async fn run(url: String, token: String, store: Arc<TaskStore>, notify: Arc<Notify>) {
38 let mut backoff = 1;
39
40 loop {
41 let started = Instant::now();
42 match session(&url, &token, &store, ¬ify, PING_EVERY).await {
43 Ok(()) => tracing::warn!("relay stream closed, reconnecting"),
44 Err(e) => tracing::warn!(err = %e, "relay stream failed, reconnecting"),
45 }
46
47 let wait = retry_after(backoff, started.elapsed());
48 time::sleep(Duration::from_secs(wait)).await;
49 backoff = (wait * 2).min(MAX_BACKOFF_SECS);
50 }
51}
52
53fn retry_after(backoff: u64, lasted: Duration) -> u64 {
55 if lasted >= STABLE_SESSION { 1 } else { backoff }
56}
57
58async fn session(
59 url: &str,
60 token: &str,
61 store: &TaskStore,
62 notify: &Notify,
63 ping_every: Duration,
64) -> anyhow::Result<()> {
65 let ws_url = format!("{}/ws", url.trim_end_matches('/'))
66 .replacen("https://", "wss://", 1)
67 .replacen("http://", "ws://", 1);
68 let mut request = ws_url.into_client_request()?;
69 request
70 .headers_mut()
71 .insert("authorization", format!("Bearer {token}").parse()?);
72 let (mut ws, _) = time::timeout(CONNECT_TIMEOUT, tokio_tungstenite::connect_async(request))
73 .await
74 .map_err(|_| anyhow::anyhow!("relay connect timed out"))??;
75
76 let after = cursor(store).await;
77 let hello = serde_json::json!({ "v": 1, "type": "hello", "after": after });
78 ws.send(Message::Text(hello.to_string())).await?;
79 tracing::info!(after, "relay stream connected");
80
81 let mut ping = time::interval(ping_every);
82 ping.tick().await;
83 let mut awaiting = false;
86
87 loop {
88 tokio::select! {
89 _ = ping.tick() => {
90 if awaiting {
91 anyhow::bail!("relay stream idle, no pong");
92 }
93
94 ws.send(Message::Text("ping".to_string())).await?;
95 awaiting = true;
96 }
97 msg = ws.next() => match msg {
98 None => return Ok(()),
99 Some(msg) => {
100 awaiting = false;
101 match msg? {
102 Message::Text(text) => handle(&text, store, notify).await,
103 Message::Close(_) => return Ok(()),
104 _ => {}
105 }
106 }
107 },
108 }
109 }
110}
111
112async fn cursor(store: &TaskStore) -> i64 {
113 store
114 .kv_get(CURSOR_KEY)
115 .await
116 .ok()
117 .flatten()
118 .and_then(|v| v.parse().ok())
119 .unwrap_or(0)
120}
121
122pub(crate) async fn handle(text: &str, store: &TaskStore, notify: &Notify) {
124 if text == "pong" {
125 return;
126 }
127
128 let msg: Incoming = match serde_json::from_str(text) {
129 Ok(msg) => msg,
130 Err(e) => {
131 tracing::warn!(err = %e, "unreadable relay message");
132 return;
133 }
134 };
135
136 match msg.kind.as_str() {
137 "gap" => {
138 tracing::warn!("relay deleted events this client never read, syncing every project");
139 notify.notify_one();
140 }
141 "event" => {
142 if msg.event == "issues" && msg.action == "closed" {
144 let number = msg
145 .payload
146 .as_ref()
147 .and_then(|p| p.pointer("/issue/number"))
148 .and_then(serde_json::Value::as_i64);
149
150 if let Some(number) = number {
151 let key = format!("dep_blocker_read_at:{}#{number}", msg.repo);
152
153 if let Err(e) = store.kv_delete(&key).await {
154 tracing::warn!(err = %e, key, "failed to release a closed blocker");
155 }
156 }
157 }
158
159 if let Err(e) = store.kv_set(CURSOR_KEY, &msg.seq.to_string()).await {
160 tracing::warn!(err = %e, seq = msg.seq, "failed to store the relay cursor");
161 }
162
163 notify.notify_one();
164 }
165 _ => {}
166 }
167}
168
169#[cfg(test)]
170mod tests {
171 use tokio::net::TcpListener;
172 use tokio_tungstenite::tungstenite::handshake::server::Request;
173 use tokio_tungstenite::tungstenite::handshake::server::Response;
174
175 use super::*;
176
177 async fn woke(notify: &Notify) -> bool {
178 time::timeout(Duration::from_millis(200), notify.notified())
179 .await
180 .is_ok()
181 }
182
183 #[tokio::test]
184 async fn an_event_stores_the_cursor_and_wakes_the_sync() {
185 let store = TaskStore::open_memory().await.unwrap();
186 let notify = Notify::new();
187 handle(
188 r#"{"type":"event","seq":7,"event":"pull_request","action":"opened","repo":"o/r","payload":{}}"#,
189 &store,
190 ¬ify,
191 )
192 .await;
193
194 assert_eq!(cursor(&store).await, 7);
195 assert!(woke(¬ify).await);
196 }
197
198 #[tokio::test]
199 async fn a_closed_issue_releases_its_blocker_cache_entry() {
200 let store = TaskStore::open_memory().await.unwrap();
201 store
202 .kv_set("dep_blocker_read_at:o/r#12", "1")
203 .await
204 .unwrap();
205 store
206 .kv_set("dep_blocker_read_at:o/r#13", "1")
207 .await
208 .unwrap();
209 handle(
210 r#"{"type":"event","seq":1,"event":"issues","action":"closed","repo":"o/r","payload":{"issue":{"number":12}}}"#,
211 &store,
212 &Notify::new(),
213 )
214 .await;
215
216 assert_eq!(
217 store.kv_get("dep_blocker_read_at:o/r#12").await.unwrap(),
218 None
219 );
220 assert!(
221 store
222 .kv_get("dep_blocker_read_at:o/r#13")
223 .await
224 .unwrap()
225 .is_some()
226 );
227 }
228
229 #[tokio::test]
230 async fn a_gap_wakes_the_sync_and_keeps_the_cursor() {
231 let store = TaskStore::open_memory().await.unwrap();
232 store.kv_set(CURSOR_KEY, "3").await.unwrap();
233 let notify = Notify::new();
234 handle(r#"{"type":"gap","oldest":50}"#, &store, ¬ify).await;
235
236 assert_eq!(cursor(&store).await, 3);
237 assert!(woke(¬ify).await);
238 }
239
240 #[tokio::test]
241 async fn pong_and_unknown_messages_change_nothing() {
242 let store = TaskStore::open_memory().await.unwrap();
243 let notify = Notify::new();
244 handle("pong", &store, ¬ify).await;
245 handle("not json", &store, ¬ify).await;
246
247 assert_eq!(cursor(&store).await, 0);
248 assert!(!woke(¬ify).await);
249 }
250
251 #[expect(
252 clippy::result_large_err,
253 reason = "the handshake callback returns tungstenite's large error type"
254 )]
255 #[tokio::test]
256 async fn a_session_says_hello_with_the_cursor_and_applies_events() {
257 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
258 let addr = listener.local_addr().unwrap();
259 let server = tokio::spawn(async move {
260 let (tcp, _) = listener.accept().await.unwrap();
261 let mut auth = String::new();
262 let mut ws =
263 tokio_tungstenite::accept_hdr_async(tcp, |req: &Request, res: Response| {
264 auth = req
265 .headers()
266 .get("authorization")
267 .and_then(|v| v.to_str().ok())
268 .unwrap_or_default()
269 .to_string();
270 Ok(res)
271 })
272 .await
273 .unwrap();
274 let hello = ws.next().await.unwrap().unwrap().into_text().unwrap();
275 let event = r#"{"type":"event","seq":9,"event":"issues","action":"opened","repo":"o/r","payload":{}}"#;
276 ws.send(Message::Text(event.to_string())).await.unwrap();
277 ws.close(None).await.unwrap();
278 (auth, hello)
279 });
280
281 let store = TaskStore::open_memory().await.unwrap();
282 store.kv_set(CURSOR_KEY, "4").await.unwrap();
283 let notify = Notify::new();
284 session(
285 &format!("http://{addr}"),
286 "tok",
287 &store,
288 ¬ify,
289 PING_EVERY,
290 )
291 .await
292 .unwrap();
293
294 let (auth, hello) = server.await.unwrap();
295 assert_eq!(auth, "Bearer tok");
296 let hello: serde_json::Value = serde_json::from_str(&hello).unwrap();
297 assert_eq!(
298 hello,
299 serde_json::json!({ "v": 1, "type": "hello", "after": 4 })
300 );
301 assert_eq!(cursor(&store).await, 9);
302 assert!(woke(¬ify).await);
303 }
304
305 #[tokio::test]
306 async fn a_silent_server_ends_the_session_with_an_error() {
307 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
308 let addr = listener.local_addr().unwrap();
309 let server = tokio::spawn(async move {
310 let (tcp, _) = listener.accept().await.unwrap();
311 let mut ws = tokio_tungstenite::accept_async(tcp).await.unwrap();
312 while ws.next().await.is_some() {}
313 });
314
315 let store = TaskStore::open_memory().await.unwrap();
316 let err = time::timeout(
317 Duration::from_secs(5),
318 session(
319 &format!("http://{addr}"),
320 "tok",
321 &store,
322 &Notify::new(),
323 Duration::from_millis(50),
324 ),
325 )
326 .await
327 .expect("session should end on its own")
328 .unwrap_err();
329
330 assert!(err.to_string().contains("no pong"));
331 server.abort();
332 }
333
334 #[test]
335 fn a_socket_closed_at_once_keeps_the_backoff_growing() {
336 let quick = Duration::from_secs(1);
337 let mut backoff = 1;
338 let mut waits = Vec::new();
339
340 for _ in 0..12 {
341 let wait = retry_after(backoff, quick);
342 waits.push(wait);
343 backoff = (wait * 2).min(MAX_BACKOFF_SECS);
344 }
345
346 assert_eq!(waits[..9], [1, 2, 4, 8, 16, 32, 64, 128, 256]);
347 assert_eq!(waits[9..], [300, 300, 300]);
348 assert_eq!(retry_after(300, STABLE_SESSION), 1);
349 }
350}