1use std::collections::HashMap;
18use std::sync::{LazyLock, Mutex};
19use std::time::{Duration, Instant};
20
21pub(crate) const RENEW_EVERY: Duration = Duration::from_secs(20);
23
24const UNSUPPORTED_BACKOFF: Duration = Duration::from_secs(600);
27
28const HELD_ELSEWHERE_FRESH: Duration = Duration::from_secs(90);
31
32pub(crate) fn instance_id() -> String {
35 format!(
36 "{}/{}",
37 crate::backend::reactive::registry::local_host_label(),
38 crate::backend::reactive::registry::local_channel_id()
39 )
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub(crate) enum Outcome {
44 Held,
46 HeldElsewhere(String),
48 Unknown,
50}
51
52#[derive(Default)]
53struct Entry {
54 held: bool,
55 last_ok: Option<Instant>,
56 elsewhere: Option<(String, Instant)>,
57 forgotten_at: Option<Instant>,
60}
61
62static STATE: LazyLock<Mutex<HashMap<String, Entry>>> = LazyLock::new(Default::default);
63static OPS: LazyLock<Mutex<HashMap<String, std::sync::Arc<tokio::sync::Mutex<()>>>>> = LazyLock::new(Default::default);
68
69fn op_lock(agent_id: &str) -> std::sync::Arc<tokio::sync::Mutex<()>> {
70 OPS.lock().unwrap_or_else(|e| e.into_inner()).entry(key(agent_id)).or_default().clone()
71}
72
73static UNSUPPORTED_UNTIL: LazyLock<Mutex<Option<Instant>>> = LazyLock::new(Default::default);
74
75fn key(agent_id: &str) -> String {
76 agent_id.trim().to_lowercase()
77}
78
79pub(crate) fn held_elsewhere(agent: &str) -> Option<String> {
82 let state = STATE.lock().unwrap_or_else(|e| e.into_inner());
83 let (desc, at) = state.get(&key(agent))?.elsewhere.clone()?;
84 (at.elapsed() < HELD_ELSEWHERE_FRESH).then_some(desc)
85}
86
87pub(crate) async fn forget_elsewhere(agent: &str) {
91 let op = op_lock(agent);
95 let _op = op.lock().await;
96 let mut state = STATE.lock().unwrap_or_else(|e| e.into_inner());
97 let e = state.entry(key(agent)).or_default();
98 e.elsewhere = None;
99 e.forgotten_at = Some(Instant::now());
100}
101
102pub(crate) fn holder_on_this_computer(desc: &str) -> bool {
105 desc == THIS_COMPUTER || desc.starts_with("this computer, ")
106}
107
108const THIS_COMPUTER: &str = "this computer";
109
110pub(crate) fn held_on_this_computer_by(agent: &str) -> Option<String> {
113 let desc = held_elsewhere(agent)?;
114 if !holder_on_this_computer(&desc) {
115 return None;
116 }
117 desc.split(", ").find_map(|part| part.strip_prefix("channel ")).map(str::to_string)
118}
119
120pub(crate) fn held_on_another_computer(agent: &str) -> Option<String> {
123 held_elsewhere(agent).filter(|desc| !desc.is_empty() && !holder_on_this_computer(desc))
124}
125
126pub(crate) fn describe_holder(held_by: &serde_json::Value) -> String {
128 let field = |k: &str| held_by.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string();
129 let mut parts = Vec::new();
130 let host = field("host");
131 if !host.is_empty() {
132 if host.eq_ignore_ascii_case(&crate::backend::reactive::registry::local_host_label()) {
135 parts.push(THIS_COMPUTER.to_string());
136 } else {
137 parts.push(format!("computer {host}"));
138 }
139 }
140 let channel = field("channel");
141 if !channel.is_empty() {
142 parts.push(format!("channel {channel}"));
143 }
144 let version = field("version");
145 if !version.is_empty() {
146 parts.push(format!("v{version}"));
147 }
148 parts.join(", ")
149}
150
151fn record(agent_id: &str, outcome: &Outcome) {
152 let mut state = STATE.lock().unwrap_or_else(|e| e.into_inner());
153 let e = state.entry(key(agent_id)).or_default();
154 match outcome {
155 Outcome::Held => {
156 e.held = true;
157 e.last_ok = Some(Instant::now());
158 e.elsewhere = None;
159 }
160 Outcome::HeldElsewhere(desc) => {
161 e.held = false;
162 e.last_ok = None;
163 e.elsewhere = Some((desc.clone(), Instant::now()));
164 }
165 Outcome::Unknown => {}
166 }
167}
168
169pub(crate) fn note_not_holder_since(agent_id: &str, body: &serde_json::Value, started: Instant) -> Option<String> {
175 let desc = body.get("held_by").map(describe_holder).unwrap_or_default();
176 let mut state = STATE.lock().unwrap_or_else(|e| e.into_inner());
179 let e = state.entry(key(agent_id)).or_default();
180 if e.forgotten_at.is_some_and(|at| at > started) {
181 return None;
182 }
183 e.held = false;
184 e.last_ok = None;
185 e.elsewhere = Some((desc.clone(), Instant::now()));
186 Some(desc)
187}
188
189pub(crate) fn is_held(agent_id: &str) -> bool {
191 STATE.lock().unwrap_or_else(|e| e.into_inner()).get(&key(agent_id)).is_some_and(|e| e.held)
192}
193
194#[cfg(test)]
195pub(crate) fn mark_held_for_test(agent_id: &str) {
196 record(agent_id, &Outcome::Held);
197}
198
199#[cfg(test)]
201pub(crate) fn note_not_holder(agent_id: &str, body: &serde_json::Value) -> String {
202 let desc = body.get("held_by").map(describe_holder).unwrap_or_default();
203 record(agent_id, &Outcome::HeldElsewhere(desc.clone()));
204 desc
205}
206
207pub(crate) async fn ensure(
210 base: &str,
211 agent_id: &str,
212 token: &str,
213 http: &reqwest::Client,
214 still_subscribed: impl Fn() -> bool,
215) -> Outcome {
216 if UNSUPPORTED_UNTIL.lock().unwrap_or_else(|e| e.into_inner()).is_some_and(|t| t > Instant::now()) {
217 return Outcome::Unknown;
218 }
219 let op = op_lock(agent_id);
220 let _op = op.lock().await;
221 if !still_subscribed() {
225 return Outcome::Unknown;
226 }
227 let renew_due = {
228 let state = STATE.lock().unwrap_or_else(|e| e.into_inner());
229 match state.get(&key(agent_id)) {
230 Some(e) if e.held => match e.last_ok {
231 Some(at) if at.elapsed() < RENEW_EVERY => return Outcome::Held,
232 _ => true,
233 },
234 _ => false,
235 }
236 };
237 let outcome = if renew_due {
238 match call(base, agent_id, token, http, "/agents/lease/renew").await {
239 Some((409, body)) if body.get("held_by").is_some_and(|h| h.is_null()) => {
241 claim_outcome(call(base, agent_id, token, http, "/agents/lease").await)
242 }
243 other => claim_outcome(other),
244 }
245 } else {
246 claim_outcome(call(base, agent_id, token, http, "/agents/lease").await)
247 };
248 record(agent_id, &outcome);
249 outcome
250}
251
252fn claim_outcome(resp: Option<(u16, serde_json::Value)>) -> Outcome {
253 match resp {
254 Some((200..=299, _)) => Outcome::Held,
255 Some((409, body)) => Outcome::HeldElsewhere(body.get("held_by").map(describe_holder).unwrap_or_default()),
256 Some((404, _)) | Some((405, _)) => {
257 *UNSUPPORTED_UNTIL.lock().unwrap_or_else(|e| e.into_inner()) = Some(Instant::now() + UNSUPPORTED_BACKOFF);
258 tracing::info!("wan_lease: relay has no agent-lease routes yet — WAN tier inactive for now");
259 Outcome::Unknown
260 }
261 _ => Outcome::Unknown,
262 }
263}
264
265async fn call(base: &str, agent_id: &str, token: &str, http: &reqwest::Client, path: &str) -> Option<(u16, serde_json::Value)> {
267 let body = serde_json::json!({
268 "agent_id": agent_id,
269 "instance": instance_id(),
270 "host": crate::backend::reactive::registry::local_host_label(),
271 "channel": crate::backend::reactive::registry::local_channel_id(),
272 "version": env!("CARGO_PKG_VERSION"),
273 });
274 post(base, path, token, http, Some(agent_id), &body).await
275}
276
277async fn post(
280 base: &str,
281 path: &str,
282 token: &str,
283 http: &reqwest::Client,
284 agent_id: Option<&str>,
285 body: &serde_json::Value,
286) -> Option<(u16, serde_json::Value)> {
287 let mut req = http
288 .post(format!("{base}{path}"))
289 .header("Authorization", format!("Bearer {token}"))
290 .json(body)
291 .timeout(Duration::from_secs(10));
292 if let Some(agent_id) = agent_id {
293 req = req.header("X-Agent-ID", agent_id);
294 }
295 match req.send().await {
296 Ok(r) => {
297 let status = r.status().as_u16();
298 let json = r.json::<serde_json::Value>().await.unwrap_or(serde_json::Value::Null);
299 Some((status, json))
300 }
301 Err(e) => {
302 tracing::debug!(agent_id, path, error = %e, "wan_lease: relay unreachable");
303 None
304 }
305 }
306}
307
308pub(crate) async fn renew_held_batch(
316 base: &str,
317 agents: &[String],
318 token: &str,
319 http: &reqwest::Client,
320 still_subscribed: impl Fn(&str) -> bool,
321) -> HashMap<String, Outcome> {
322 let mut outcomes = HashMap::new();
323 let lease_tier_off = UNSUPPORTED_UNTIL.lock().unwrap_or_else(|e| e.into_inner()).is_some_and(|t| t > Instant::now());
324 if lease_tier_off || super::relay::batch_routes_missing(base) {
325 return outcomes;
326 }
327 let mut due: Vec<String> = {
328 let state = STATE.lock().unwrap_or_else(|e| e.into_inner());
329 agents
330 .iter()
331 .map(|a| key(a))
332 .filter(|k| {
333 state
334 .get(k)
335 .is_some_and(|e| e.held && e.last_ok.is_none_or(|at| at.elapsed() >= RENEW_EVERY))
336 })
337 .collect()
338 };
339 due.sort();
340 due.dedup();
341 if due.len() < 2 {
342 return outcomes;
343 }
344 let locks: Vec<_> = due.iter().map(|a| op_lock(a)).collect();
348 let mut _guards = Vec::with_capacity(locks.len());
349 for lock in &locks {
350 _guards.push(lock.lock().await);
351 }
352 due.retain(|a| still_subscribed(a));
353 for chunk in due.chunks(super::relay::MAX_BATCH_AGENTS) {
354 let body = serde_json::json!({ "instance": instance_id(), "agent_ids": chunk });
355 let results = match post(base, "/agents/lease/renew-batch", token, http, None, &body).await {
356 Some((404, _)) | Some((405, _)) => {
357 super::relay::note_batch_routes_missing(base);
358 return outcomes;
359 }
360 Some((200..=299, body)) => body.get("results").cloned().unwrap_or_default(),
361 _ => continue,
362 };
363 for agent in chunk {
364 let Some(entry) = results.get(agent.as_str()) else { continue };
365 let outcome = if entry.get("ok").and_then(serde_json::Value::as_bool) == Some(true) {
366 Outcome::Held
367 } else if entry.get("error").and_then(serde_json::Value::as_str) != Some("not_holder") {
368 continue; } else if entry.get("held_by").is_some_and(serde_json::Value::is_null) {
370 claim_outcome(call(base, agent, token, http, "/agents/lease").await)
372 } else {
373 Outcome::HeldElsewhere(entry.get("held_by").map(describe_holder).unwrap_or_default())
374 };
375 record(agent, &outcome);
376 outcomes.insert(agent.clone(), outcome);
377 }
378 }
379 outcomes
380}
381
382pub(crate) async fn release(
386 base: &str,
387 agent_id: &str,
388 token: &str,
389 http: &reqwest::Client,
390 still_gone: impl Fn() -> bool,
391) -> bool {
392 let op = op_lock(agent_id);
393 let _op = op.lock().await;
394 if !still_gone() {
397 return false;
398 }
399 let was_held = STATE
400 .lock()
401 .unwrap_or_else(|e| e.into_inner())
402 .remove(&key(agent_id))
403 .is_some_and(|e| e.held);
404 if was_held {
405 let _ = call(base, agent_id, token, http, "/agents/lease/release").await;
406 }
407 true
408}
409
410pub(crate) async fn take_over(base: &str, agent_id: &str, token: &str, http: &reqwest::Client) -> Outcome {
419 let op = op_lock(agent_id);
420 let _op = op.lock().await;
421 let claimed = claim_outcome(call(base, agent_id, token, http, "/agents/lease").await);
422 let outcome = match claimed {
423 Outcome::HeldElsewhere(desc) if holder_on_this_computer(&desc) => {
424 match call(base, agent_id, token, http, "/agents/lease/take").await {
425 Some((200..=299, _)) => {
426 tracing::info!(agent_id, from = %desc, "wan_lease: took the lease over from an instance on this computer");
427 Outcome::Held
428 }
429 Some((409, body)) => claim_outcome(Some((409, body))),
433 other => {
434 tracing::warn!(agent_id, answer = ?other.map(|(s, _)| s), "wan_lease: the relay would not take the lease over");
435 Outcome::HeldElsewhere(desc)
436 }
437 }
438 }
439 other => other,
440 };
441 record(agent_id, &outcome);
442 outcome
443}
444
445#[derive(Debug, Clone, PartialEq, Eq)]
451pub(crate) enum Holder {
452 Free,
454 Yours,
456 Other(String),
458 Unknown,
461 Unsupported,
463 Unreachable,
465}
466
467const HOLDER_FRESH: Duration = Duration::from_secs(20);
470
471static HOLDER_CACHE: LazyLock<Mutex<HashMap<String, (Holder, Instant)>>> = LazyLock::new(Default::default);
472static HOLDER_UNSUPPORTED_UNTIL: LazyLock<Mutex<Option<Instant>>> = LazyLock::new(Default::default);
473
474pub(crate) async fn holder(base: &str, agent: &str, caller: &str, token: &str, http: &reqwest::Client) -> Holder {
478 if HOLDER_UNSUPPORTED_UNTIL.lock().unwrap_or_else(|e| e.into_inner()).is_some_and(|t| t > Instant::now()) {
479 return Holder::Unsupported;
480 }
481 if let Some((answer, at)) = HOLDER_CACHE.lock().unwrap_or_else(|e| e.into_inner()).get(&key(agent)) {
482 if at.elapsed() < HOLDER_FRESH {
483 return answer.clone();
484 }
485 }
486 let Ok(mut url) = url::Url::parse(base) else {
487 return Holder::Unknown;
488 };
489 if let Ok(mut path) = url.path_segments_mut() {
490 path.pop_if_empty().extend(["agents", "lease", agent.trim()]);
491 }
492 let resp = http
493 .get(url)
494 .header("Authorization", format!("Bearer {token}"))
495 .header("X-Agent-ID", caller)
496 .header("X-Agent-Instance", instance_id())
497 .timeout(Duration::from_secs(5))
498 .send()
499 .await;
500 let answer = match resp {
501 Ok(r) => {
502 let status = r.status().as_u16();
503 let body = r.json::<serde_json::Value>().await.unwrap_or(serde_json::Value::Null);
504 holder_from(status, &body)
505 }
506 Err(e) => {
507 tracing::debug!(agent, error = %e, "wan_lease: holder query unreachable");
508 return Holder::Unreachable;
509 }
510 };
511 if answer == Holder::Unsupported {
512 *HOLDER_UNSUPPORTED_UNTIL.lock().unwrap_or_else(|e| e.into_inner()) = Some(Instant::now() + UNSUPPORTED_BACKOFF);
513 tracing::info!("wan_lease: relay has no lease holder query yet — routing as before");
514 }
515 HOLDER_CACHE
516 .lock()
517 .unwrap_or_else(|e| e.into_inner())
518 .insert(key(agent), (answer.clone(), Instant::now()));
519 answer
520}
521
522fn holder_from(status: u16, body: &serde_json::Value) -> Holder {
523 match status {
524 200..=299 => match body.get("state").and_then(|s| s.as_str()) {
525 Some("free") => Holder::Free,
526 Some("yours") => Holder::Yours,
527 Some("other") => Holder::Other(body.get("held_by").map(describe_holder).unwrap_or_default()),
528 _ => Holder::Unknown,
529 },
530 404 | 405 => Holder::Unsupported,
531 _ => Holder::Unknown,
532 }
533}
534
535#[cfg(test)]
536mod tests {
537 use super::*;
538
539 #[tokio::test]
543 async fn the_holder_query_asks_once_and_names_the_caller() {
544 let hits = std::sync::Arc::new(std::sync::Mutex::new(Vec::<(String, Option<String>, Option<String>, Option<String>)>::new()));
545 let sink = hits.clone();
546 let app = axum::Router::new().route(
547 "/agents/lease/:agent",
548 axum::routing::get(
549 move |axum::extract::Path(agent): axum::extract::Path<String>, headers: axum::http::HeaderMap| {
550 let sink = sink.clone();
551 async move {
552 let get = |k: &str| headers.get(k).and_then(|v| v.to_str().ok()).map(str::to_string);
553 sink.lock().unwrap().push((agent, get("authorization"), get("x-agent-id"), get("x-agent-instance")));
554 axum::Json(serde_json::json!({ "state": "other", "held_by": { "host": "area54-holder-test", "channel": "stable" } }))
555 }
556 },
557 ),
558 );
559 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
560 let addr = listener.local_addr().unwrap();
561 tokio::spawn(async move {
562 let _ = axum::serve(listener, app).await;
563 });
564 let base = format!("http://{addr}");
565 let http = reqwest::Client::new();
566 let agent = format!("HolderTest-{}", &uuid::Uuid::new_v4().to_string()[..8]);
567
568 let first = holder(&base, &agent, "lark", "tok", &http).await;
569 assert!(matches!(&first, Holder::Other(d) if d.contains("area54-holder-test")), "{first:?}");
570 let again = holder(&base, &agent, "lark", "tok", &http).await;
571 assert_eq!(again, first);
572 let seen = hits.lock().unwrap().clone();
573 assert_eq!(seen.len(), 1, "the second answer came from the cache: {seen:?}");
574 let (path_agent, auth, caller, instance) = &seen[0];
575 assert_eq!(path_agent, &agent);
576 assert_eq!(auth.as_deref(), Some("Bearer tok"));
577 assert_eq!(caller.as_deref(), Some("lark"));
578 assert_eq!(instance.as_deref(), Some(instance_id().as_str()));
579
580 let closed = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
582 let dead = format!("http://{}", closed.local_addr().unwrap());
583 drop(closed);
584 let lost = format!("HolderLost-{}", &uuid::Uuid::new_v4().to_string()[..8]);
585 assert_eq!(holder(&dead, &lost, "lark", "tok", &http).await, Holder::Unreachable);
586 assert!(HOLDER_CACHE.lock().unwrap().get(&key(&lost)).is_none());
587 }
588
589 #[test]
592 fn holder_answers_map_to_routes() {
593 use serde_json::json;
594 assert_eq!(holder_from(200, &json!({ "state": "free" })), Holder::Free);
595 assert_eq!(holder_from(200, &json!({ "state": "yours" })), Holder::Yours);
596 let other = holder_from(200, &json!({ "state": "other", "held_by": { "host": "area54", "channel": "stable", "version": "0.59.5" } }));
597 assert!(matches!(&other, Holder::Other(d) if d.contains("area54")), "{other:?}");
598 assert_eq!(holder_from(200, &json!({ "state": "unknown" })), Holder::Unknown);
599 assert_eq!(holder_from(200, &json!({ "state": "weird" })), Holder::Unknown);
600 assert_eq!(holder_from(200, &serde_json::Value::Null), Holder::Unknown);
601 assert_eq!(holder_from(404, &serde_json::Value::Null), Holder::Unsupported);
602 assert_eq!(holder_from(405, &serde_json::Value::Null), Holder::Unsupported);
603 for status in [401, 403, 429, 500] {
604 assert_eq!(holder_from(status, &serde_json::Value::Null), Holder::Unknown, "{status}");
605 }
606 }
607
608 #[tokio::test]
611 async fn forgetting_a_refusal_lets_the_next_turn_through() {
612 let agent = format!("agent-{}", uuid::Uuid::new_v4());
613 note_not_holder(&agent, &serde_json::json!({ "held_by": { "host": "desk" } }));
614 assert!(held_elsewhere(&agent).is_some());
615 forget_elsewhere(&agent.to_uppercase()).await;
616 assert_eq!(held_elsewhere(&agent), None);
617 }
618
619 #[tokio::test]
624 async fn a_409_to_a_request_from_before_a_take_over_is_ignored() {
625 let agent = format!("agent-{}", uuid::Uuid::new_v4());
626 let body = serde_json::json!({ "held_by": { "host": "desk" } });
627 let started = Instant::now();
628 tokio::time::sleep(Duration::from_millis(5)).await;
629 forget_elsewhere(&agent).await;
630 assert_eq!(note_not_holder_since(&agent, &body, started), None, "stale: not recorded, not fenced");
631 assert_eq!(held_elsewhere(&agent), None);
632
633 let later = Instant::now();
634 assert!(note_not_holder_since(&agent, &body, later).is_some(), "a fresh 409 still counts");
635 assert!(held_elsewhere(&agent).is_some());
636 }
637
638 #[tokio::test]
642 async fn forgetting_a_refusal_waits_for_a_lease_check_in_flight() {
643 let agent = format!("agent-{}", uuid::Uuid::new_v4());
644 let lock = op_lock(&agent);
645 let check = lock.lock().await;
646 let a = agent.clone();
647 let forget = tokio::spawn(async move { forget_elsewhere(&a).await });
648 tokio::time::sleep(Duration::from_millis(50)).await;
649 assert!(!forget.is_finished(), "forgetting waits for the check");
650 note_not_holder(&agent, &serde_json::json!({ "held_by": { "host": "desk" } }));
652 drop(check);
653 tokio::time::timeout(Duration::from_secs(5), forget).await.unwrap().unwrap();
654 assert_eq!(held_elsewhere(&agent), None, "the stale refusal is gone");
655 }
656
657 #[tokio::test]
660 async fn a_renewal_for_an_agent_no_longer_subscribed_claims_nothing() {
661 let hits = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
662 let counter = hits.clone();
663 let app = axum::Router::new().fallback(move || {
664 let counter = counter.clone();
665 async move {
666 counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
667 axum::Json(serde_json::json!({ "ok": true, "epoch": 1 }))
668 }
669 });
670 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
671 let base = format!("http://{}", listener.local_addr().unwrap());
672 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
673 let agent = format!("agent-{}", uuid::Uuid::new_v4());
674 let http = reqwest::Client::new();
675
676 assert_eq!(ensure(&base, &agent, "", &http, || false).await, Outcome::Unknown);
677 assert_eq!(hits.load(std::sync::atomic::Ordering::SeqCst), 0, "no claim for an unsubscribed agent");
678 assert_eq!(ensure(&base, &agent, "", &http, || true).await, Outcome::Held);
679 assert_eq!(hits.load(std::sync::atomic::Ordering::SeqCst), 1);
680 }
681
682 #[tokio::test]
686 async fn a_release_skips_an_agent_subscribed_again() {
687 let agent = format!("agent-{}", uuid::Uuid::new_v4());
688 record(&agent, &Outcome::Held);
689 assert!(
690 !release("http://127.0.0.1:9", &agent, "", &reqwest::Client::new(), || false).await,
691 "reports that it did not let go"
692 );
693 let held = STATE.lock().unwrap().get(&key(&agent)).is_some_and(|e| e.held);
694 assert!(held, "the fresh claim is left alone");
695 }
696
697 #[tokio::test]
698 async fn a_release_waits_for_a_claim_in_progress_for_the_same_agent() {
699 let agent = format!("Agent-{}", uuid::Uuid::new_v4());
700 let lock = op_lock(&agent.to_lowercase());
701 let claim = lock.lock().await;
702 let a = agent.clone();
703 let release = tokio::spawn(async move {
704 super::release("http://127.0.0.1:9", &a, "", &reqwest::Client::new(), || true).await
705 });
706 tokio::time::sleep(Duration::from_millis(50)).await;
707 assert!(!release.is_finished(), "the release waits for the claim");
708 drop(claim);
709 tokio::time::timeout(Duration::from_secs(5), release).await.unwrap().unwrap();
710 }
711
712 #[test]
713 fn the_holder_is_described_without_account_or_instance() {
714 let held_by = serde_json::json!({ "host": "desk", "channel": "stable", "version": "0.58.0", "acquired_at": 1 });
715 assert_eq!(describe_holder(&held_by), "computer desk, channel stable, v0.58.0");
716 assert_eq!(describe_holder(&serde_json::Value::Null), "");
717 }
718
719 #[test]
722 fn a_holder_on_this_computer_is_described_as_this_computer() {
723 let here = crate::backend::reactive::registry::local_host_label();
724 let held_by = serde_json::json!({ "host": here, "channel": "local-main-x", "version": "0.57.6" });
725 assert_eq!(describe_holder(&held_by), "this computer, channel local-main-x, v0.57.6");
726 assert!(holder_on_this_computer(&describe_holder(&held_by)));
727 assert!(!holder_on_this_computer("computer desk, channel stable, v0.58.0"));
728 }
729
730 #[test]
733 fn a_same_computer_holder_gives_take_over_its_channel() {
734 let here = crate::backend::reactive::registry::local_host_label();
735 let agent = format!("agent-{}", uuid::Uuid::new_v4());
736 note_not_holder(&agent, &serde_json::json!({ "held_by": { "host": here, "channel": "local-main-x", "version": "0.57.6" } }));
737 assert_eq!(held_on_this_computer_by(&agent).as_deref(), Some("local-main-x"));
738 assert_eq!(held_on_another_computer(&agent), None);
739
740 let other = format!("agent-{}", uuid::Uuid::new_v4());
741 note_not_holder(&other, &serde_json::json!({ "held_by": { "host": "desk", "channel": "stable" } }));
742 assert_eq!(held_on_this_computer_by(&other), None);
743 assert_eq!(held_on_another_computer(&other).as_deref(), Some("computer desk, channel stable"));
744
745 assert_eq!(held_on_this_computer_by("never-seen"), None);
746 }
747
748 async fn lease_stub(
751 claim: (u16, serde_json::Value),
752 take: (u16, serde_json::Value),
753 ) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
754 use std::sync::atomic::{AtomicUsize, Ordering};
755 let (claims, takes) = (std::sync::Arc::new(AtomicUsize::new(0)), std::sync::Arc::new(AtomicUsize::new(0)));
756 let (c, t) = (claims.clone(), takes.clone());
757 let answer = |(status, body): (u16, serde_json::Value)| {
758 (axum::http::StatusCode::from_u16(status).unwrap(), axum::Json(body))
759 };
760 let app = axum::Router::new()
761 .route(
762 "/agents/lease",
763 axum::routing::post(move || {
764 c.fetch_add(1, Ordering::SeqCst);
765 let r = answer(claim.clone());
766 async move { r }
767 }),
768 )
769 .route(
770 "/agents/lease/take",
771 axum::routing::post(move || {
772 t.fetch_add(1, Ordering::SeqCst);
773 let r = answer(take.clone());
774 async move { r }
775 }),
776 );
777 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
778 let base = format!("http://{}", listener.local_addr().unwrap());
779 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
780 (base, claims, takes)
781 }
782
783 fn held_here(channel: &str) -> serde_json::Value {
784 let here = crate::backend::reactive::registry::local_host_label();
785 serde_json::json!({ "error": "held_by_other", "held_by": { "host": here, "channel": channel, "version": "0.57.6" } })
786 }
787
788 #[tokio::test]
791 async fn take_over_takes_a_lease_an_instance_on_this_computer_kept() {
792 use std::sync::atomic::Ordering;
793 let (base, claims, takes) =
794 lease_stub((409, held_here("local-main-old")), (200, serde_json::json!({ "ok": true, "epoch": 6 }))).await;
795 let agent = format!("agent-{}", uuid::Uuid::new_v4());
796 assert_eq!(take_over(&base, &agent, "", &reqwest::Client::new()).await, Outcome::Held);
797 assert_eq!((claims.load(Ordering::SeqCst), takes.load(Ordering::SeqCst)), (1, 1));
798 assert!(is_held(&agent), "recorded, so the lease tick renews it");
799 assert_eq!(held_elsewhere(&agent), None);
800 }
801
802 #[tokio::test]
803 async fn take_over_never_takes_from_another_computer() {
804 use std::sync::atomic::Ordering;
805 let other = serde_json::json!({ "error": "held_by_other", "held_by": { "host": "some-other-desk", "channel": "stable" } });
806 let (base, _, takes) = lease_stub((409, other), (200, serde_json::json!({ "ok": true, "epoch": 6 }))).await;
807 let agent = format!("agent-{}", uuid::Uuid::new_v4());
808 assert_eq!(
809 take_over(&base, &agent, "", &reqwest::Client::new()).await,
810 Outcome::HeldElsewhere("computer some-other-desk, channel stable".into())
811 );
812 assert_eq!(takes.load(Ordering::SeqCst), 0);
813 }
814
815 #[tokio::test]
816 async fn take_over_of_a_free_lease_is_a_plain_claim() {
817 use std::sync::atomic::Ordering;
818 let (base, _, takes) = lease_stub((200, serde_json::json!({ "ok": true, "epoch": 1 })), (500, serde_json::Value::Null)).await;
819 let agent = format!("agent-{}", uuid::Uuid::new_v4());
820 assert_eq!(take_over(&base, &agent, "", &reqwest::Client::new()).await, Outcome::Held);
821 assert_eq!(takes.load(Ordering::SeqCst), 0);
822 }
823
824 #[tokio::test]
828 async fn take_over_on_a_relay_without_take_keeps_the_refusal_and_the_lease_tier() {
829 let (base, _, _) = lease_stub((409, held_here("local-main-old")), (404, serde_json::Value::Null)).await;
830 let agent = format!("agent-{}", uuid::Uuid::new_v4());
831 let outcome = take_over(&base, &agent, "", &reqwest::Client::new()).await;
832 assert!(matches!(&outcome, Outcome::HeldElsewhere(d) if holder_on_this_computer(d)), "{outcome:?}");
833 assert!(
834 UNSUPPORTED_UNTIL.lock().unwrap().is_none_or(|t| t <= Instant::now()),
835 "a missing take route is not a missing lease tier"
836 );
837 }
838
839 #[test]
840 fn claim_answers_map_to_outcomes() {
841 assert_eq!(claim_outcome(Some((200, serde_json::json!({ "ok": true, "epoch": 1 })))), Outcome::Held);
842 assert_eq!(
843 claim_outcome(Some((409, serde_json::json!({ "held_by": { "host": "desk" } })))),
844 Outcome::HeldElsewhere("computer desk".into())
845 );
846 assert_eq!(claim_outcome(None), Outcome::Unknown, "unreachable relay: fail open");
847 assert_eq!(claim_outcome(Some((500, serde_json::Value::Null))), Outcome::Unknown);
848 }
849
850 #[test]
851 fn a_not_holder_answer_is_remembered_for_the_early_check_and_cleared_by_a_grant() {
852 let agent = format!("agent-{}", uuid::Uuid::new_v4());
853 assert!(held_elsewhere(&agent).is_none());
854 let desc = note_not_holder(&agent.to_uppercase(), &serde_json::json!({ "held_by": { "host": "desk" } }));
855 assert_eq!(desc, "computer desk");
856 assert_eq!(held_elsewhere(&agent).as_deref(), Some("computer desk"), "keyed case-insensitively, like the relay");
857 record(&agent, &Outcome::Held);
858 assert!(held_elsewhere(&agent).is_none());
859 }
860
861 async fn batch_stub(
864 renew_batch: (u16, serde_json::Value),
865 ) -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
866 let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
867 let log = seen.clone();
868 let app = axum::Router::new().fallback(move |req: axum::extract::Request| {
869 let (log, renew_batch) = (log.clone(), renew_batch.clone());
870 async move {
871 let line = format!("{} {}", req.method(), req.uri().path());
872 log.lock().unwrap().push(line.clone());
873 let (status, body) = match line.as_str() {
874 "POST /agents/lease/renew-batch" => renew_batch,
875 "POST /agents/lease" => (200, serde_json::json!({ "ok": true, "epoch": 2 })),
876 _ => (404, serde_json::Value::Null),
877 };
878 (axum::http::StatusCode::from_u16(status).unwrap(), axum::Json(body))
879 }
880 });
881 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
882 let base = format!("http://{}", listener.local_addr().unwrap());
883 tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
884 (base, seen)
885 }
886
887 fn held_and_due(agent: &str) {
889 STATE.lock().unwrap().insert(key(agent), Entry { held: true, ..Default::default() });
890 }
891
892 #[tokio::test]
893 async fn one_request_renews_every_held_lease_that_is_due() {
894 let [a, b, c, d] = std::array::from_fn(|_| format!("agent-{}", uuid::Uuid::new_v4()));
895 for agent in [&a, &b, &c, &d] {
896 held_and_due(agent);
897 }
898 let results = serde_json::json!({ "results": {
899 a.clone(): { "ok": true, "epoch": 1, "ttl_ms": 60000 },
900 b.clone(): { "ok": false, "error": "not_holder", "held_by": { "host": "desk", "channel": "stable" } },
901 c.clone(): { "ok": false, "error": "not_holder", "held_by": null },
902 d.clone(): { "ok": false, "error": "unavailable" },
903 }});
904 let (base, seen) = batch_stub((200, results)).await;
905 let agents = vec![a.to_uppercase(), b.clone(), c.clone(), d.clone()];
906
907 let got = renew_held_batch(&base, &agents, "tok", &reqwest::Client::new(), |_| true).await;
908
909 assert_eq!(got.get(&a), Some(&Outcome::Held), "keyed by the lowercased name");
910 assert_eq!(got.get(&b), Some(&Outcome::HeldElsewhere("computer desk, channel stable".into())));
911 assert_eq!(got.get(&c), Some(&Outcome::Held), "nobody held it: claimed again");
912 assert_eq!(got.get(&d), None, "unavailable: left to `ensure` on its own");
913 assert_eq!(*seen.lock().unwrap(), ["POST /agents/lease/renew-batch", "POST /agents/lease"]);
914 assert_eq!(held_elsewhere(&b).as_deref(), Some("computer desk, channel stable"));
915 assert!(is_held(&a) && is_held(&c));
916 }
917
918 #[tokio::test]
919 async fn a_relay_without_the_batch_route_is_left_to_renew_one_by_one() {
920 let [a, b] = std::array::from_fn(|_| format!("agent-{}", uuid::Uuid::new_v4()));
921 held_and_due(&a);
922 held_and_due(&b);
923 let (base, seen) = batch_stub((404, serde_json::Value::Null)).await;
924 let agents = vec![a.clone(), b.clone()];
925
926 assert!(renew_held_batch(&base, &agents, "tok", &reqwest::Client::new(), |_| true).await.is_empty());
927 assert!(super::super::relay::batch_routes_missing(&base));
928 assert!(
929 UNSUPPORTED_UNTIL.lock().unwrap().is_none_or(|t| t <= Instant::now()),
930 "a missing batch route is not a missing lease tier"
931 );
932 assert!(renew_held_batch(&base, &agents, "tok", &reqwest::Client::new(), |_| true).await.is_empty());
934 assert_eq!(seen.lock().unwrap().len(), 1);
935 }
936
937 #[tokio::test]
938 async fn the_batch_skips_a_single_due_agent_and_agents_no_longer_subscribed() {
939 let [a, b, fresh] = std::array::from_fn(|_| format!("agent-{}", uuid::Uuid::new_v4()));
940 held_and_due(&a);
941 held_and_due(&b);
942 record(&fresh, &Outcome::Held); let (base, seen) = batch_stub((200, serde_json::json!({ "results": {} }))).await;
944
945 let one_due = vec![a.clone(), fresh.clone()];
946 assert!(renew_held_batch(&base, &one_due, "tok", &reqwest::Client::new(), |_| true).await.is_empty());
947 assert!(seen.lock().unwrap().is_empty(), "one due agent: the single route costs the same");
948
949 let both = vec![a.clone(), b.clone()];
950 renew_held_batch(&base, &both, "tok", &reqwest::Client::new(), |agent| agent == a).await;
951 assert_eq!(*seen.lock().unwrap(), ["POST /agents/lease/renew-batch"], "asked, but only for {a}");
952 }
953
954 #[test]
955 fn the_instance_id_is_host_and_channel() {
956 let id = instance_id();
957 assert_eq!(id.matches('/').count(), 1, "{id}");
958 assert!(id.ends_with(&crate::backend::reactive::registry::local_channel_id()));
959 }
960}