didbot_serve/operator_poll.rs
1//! Drives `didbot_pds::OperatorRelation`'s poll against a real operator repository,
2//! and reacts to what it finds. See `plan/handshake.md`.
3//!
4//! `didbot-pds` holds no HTTP client and no async runtime, so
5//! [`didbot_pds::ClaimSource`] is a plain synchronous trait; the fetch that
6//! actually reaches the operator's repository lives here instead, where both
7//! are already dependencies. The bridge is `Prefetched`, a private
8//! `ClaimSource` below: this module does the one HTTP round trip a poll needs
9//! *before* calling
10//! [`didbot_pds::OperatorRelation::poll`], then hands the already-fetched result to
11//! it through a [`didbot_pds::ClaimSource`] that does no I/O of its own. That
12//! keeps the decision logic in `didbot-pds`, testable with no network at all,
13//! while the network reaches this crate exactly once per tick.
14//!
15//! # What each poll actually does
16//!
17//! 1. Resolve the operator's DID document through
18//! [`didbot_identity::document_url`], which dispatches by method, to find
19//! its `#atproto_pds` service endpoint.
20//! 2. `com.atproto.repo.getRecord` at that endpoint, for
21//! `bot.did.operator` at the record key `didbot_pds::OperatorRelation::hostname`
22//! — a single direct lookup, not a list-and-filter, because that is what
23//! keying the record by hostname buys.
24//! 3. Hand the result to [`didbot_pds::OperatorRelation::poll`], and act on the
25//! [`didbot_pds::OperatorTransition`] it reports: throw or release the
26//! self-triggered pause, and republish the server's own registration
27//! record once claimed.
28//! 4. Look for the same record once per root account — one the operator
29//! created from their own repository — keyed by *that* hostname, and
30//! report to the registry whether it still stands. A record missing for
31//! longer than the grace window quarantines the account and everything
32//! beneath it; see [`refresh_root_standing`].
33//!
34//! # What is unverified
35//!
36//! This has never been run against a real operator PDS — there is no such
37//! server reachable from where this was built. The `getRecord` shape, the
38//! DID resolution and the error handling for "no such record" versus "the
39//! server is actually down" are implemented against atproto's documented
40//! XRPC conventions, not exercised against a live implementation. The
41//! `did:plc` path reaches `https://plc.directory` in production and is
42//! exercised here against a loopback stand-in for it.
43
44use std::collections::BTreeMap;
45use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
46use std::sync::{Arc, Mutex};
47use std::time::Duration as StdDuration;
48
49use didbot_http::PrivateAddresses;
50use didbot_identity::{DidDocument, ResolveError};
51use didbot_pds::allowance::Allowances;
52use didbot_pds::{
53 AccountState, ClaimFetchError, ClaimSource, Estop, EstopCause, EstopMode, HostedAccount, Lock,
54 OperatorClaim, OperatorRelation, OperatorTransition, Party, Registry, ServerLifecycle, Tag,
55};
56use serde::Deserialize;
57use time::Duration;
58use time::OffsetDateTime;
59use tokio::sync::{watch, Notify};
60use tokio::task::JoinHandle;
61use tokio::time::MissedTickBehavior;
62use tracing::{info, warn};
63
64use crate::operator_handle::OperatorHandle;
65use crate::policy_poll::PolicyPoll;
66
67/// The collection this poll reads. `didbot-pds` holds no schema for a
68/// collection defined by someone else's repository convention here, so this
69/// is a plain string rather than a `didbot_lexicon::nsid` constant — the
70/// constant lives with the lexicon, in `didbot_lexicon::nsid::OPERATOR`.
71const COLLECTION: &str = didbot_lexicon::nsid::OPERATOR;
72
73/// How often this deployment ships polling for an operator record, absent
74/// configuration. Independent of [`didbot_pds::DEFAULT_GRACE_WINDOW`]: the
75/// interval is how often the network is asked, the grace window is how long
76/// a missed answer is tolerated before it means something. Five minutes
77/// against a six-hour grace window is 72 chances to succeed before a
78/// transient outage could ever matter.
79///
80/// The two compose into the bound an operator actually cares about: a
81/// revocation is *acted on* — the self-triggered pause thrown — no later
82/// than the grace window plus one interval after the record stops being
83/// readable, because the poll that notices can be a full interval away from
84/// the instant the window elapsed. Six hours and five minutes, as this
85/// deployment ships, and
86/// `a_revocation_is_acted_on_within_the_grace_window_plus_one_interval`
87/// below holds the two constants to it.
88///
89/// That is the bound for *this server's own* behaviour. It says nothing
90/// about a third party, who is not polling anything: a reader learns of a
91/// revocation when they next read the operator's record.
92pub const DEFAULT_POLL_INTERVAL: StdDuration = StdDuration::from_secs(5 * 60);
93
94/// A [`ClaimSource`] holding a result already fetched, so
95/// [`didbot_pds::OperatorRelation::poll`] — synchronous by design — can be called
96/// with no I/O of its own. Not `pub`: the whole point is that nothing
97/// outside this module's own poll tick constructs one.
98struct Prefetched(Result<Option<OperatorClaim>, ClaimFetchError>);
99
100impl ClaimSource for Prefetched {
101 fn get_claim(
102 &self,
103 _operator_did: &str,
104 _hostname: &str,
105 ) -> Result<Option<OperatorClaim>, ClaimFetchError> {
106 self.0.clone()
107 }
108}
109
110/// What `com.atproto.repo.getRecord` answers with on success.
111#[derive(Debug, Deserialize)]
112struct GetRecordResponse {
113 value: serde_json::Value,
114}
115
116/// The atproto error name a repository uses for "there is no record at that
117/// key" -- the one 400 this poll reads as absence rather than as a failure.
118const RECORD_NOT_FOUND: &str = "RecordNotFound";
119
120/// Resolves `operator_did`'s `#atproto_pds` service endpoint, then fetches
121/// the `bot.did.operator` record at `hostname` from it.
122///
123/// The document URL comes from [`didbot_identity::document_url`], so every
124/// method that resolver speaks — `did:web` against the hostname the DID
125/// names, `did:plc` against the PLC directory — is a method an operator can
126/// be configured with here, and a method it does not speak is refused by
127/// name rather than by failing to parse as something else.
128///
129/// `Ok(None)` for "reached the operator's PDS and it has no record at that
130/// key" -- a 404, or a 400 whose body names [`RECORD_NOT_FOUND`].
131/// [`ClaimFetchError::Unresolvable`] for a DID with no document URL at all.
132/// [`ClaimFetchError::Unreachable`] for everything this module cannot tell
133/// apart from an outage: connection failure, a document that does not parse
134/// or claims a different DID, a 400 that means something other than absence,
135/// or any other status this module does not recognise.
136#[cfg(test)]
137async fn fetch_claim(
138 client: &reqwest::Client,
139 operator_did: &str,
140 hostname: &str,
141 private: PrivateAddresses,
142) -> Result<Option<OperatorClaim>, ClaimFetchError> {
143 let (endpoint, pinned) = resolve_endpoint(client, operator_did, private).await?;
144 fetch_claim_from(&pinned, &endpoint, operator_did, hostname).await
145}
146
147/// Resolves `operator_did`'s document and reads its `#atproto_pds`
148/// endpoint out of it, on its own so one tick resolves the operator once
149/// and reads every record it needs from the same endpoint.
150///
151/// Answers with the endpoint and the client to read it through: one
152/// pinned to what the endpoint's host resolved to and following no
153/// redirect. Reading through anything else would let a name that answers
154/// differently the second time, or a 3xx from the operator's PDS, land the
155/// request at an address the check never saw.
156pub(crate) async fn resolve_endpoint(
157 client: &reqwest::Client,
158 operator_did: &str,
159 private: PrivateAddresses,
160) -> Result<(String, reqwest::Client), ClaimFetchError> {
161 let (_, endpoint, pinned) = resolve_operator(client, operator_did, private).await?;
162 Ok((endpoint, pinned))
163}
164
165/// [`resolve_endpoint`], with the document it read the endpoint from.
166async fn resolve_operator(
167 client: &reqwest::Client,
168 operator_did: &str,
169 private: PrivateAddresses,
170) -> Result<(DidDocument, String, reqwest::Client), ClaimFetchError> {
171 let document_url =
172 didbot_identity::document_url(operator_did).map_err(|error| match error {
173 ResolveError::UnsupportedMethod { .. } | ResolveError::InvalidDid { .. } => {
174 ClaimFetchError::Unresolvable(format!("operator did {operator_did}: {error}"))
175 }
176 other => ClaimFetchError::Unreachable(format!("operator did {operator_did}: {other}")),
177 })?;
178 resolve_operator_at(client, &document_url, operator_did, private).await
179}
180
181/// [`fetch_claim`] from `document_url` onward: fetch the document there,
182/// verify it belongs to `operator_did`, and read the claim out of the PDS it
183/// names.
184///
185/// Split from [`fetch_claim`] so this crate's tests can point the whole
186/// exchange at a loopback stand-in for a directory that is otherwise only
187/// reachable over the public internet — `https://plc.directory`, which
188/// [`didbot_identity::document_url`] is the sole source of. Which URL a DID
189/// maps to is `didbot-identity`'s to decide and to test; what this module
190/// does with the document there is tested here.
191#[cfg(test)]
192async fn fetch_claim_at(
193 client: &reqwest::Client,
194 document_url: &str,
195 operator_did: &str,
196 hostname: &str,
197 private: PrivateAddresses,
198) -> Result<Option<OperatorClaim>, ClaimFetchError> {
199 let (_, endpoint, pinned) =
200 resolve_operator_at(client, document_url, operator_did, private).await?;
201 fetch_claim_from(&pinned, &endpoint, operator_did, hostname).await
202}
203
204/// [`resolve_operator`] from `document_url` onward.
205///
206/// The endpoint the document names is checked against
207/// [`didbot_http::REFUSED_BLOCKS`] before anything fetches from it. The
208/// operator's DID is configuration, but the `#atproto_pds` endpoint inside
209/// their document is not: it is a URL this server is told to fetch from by
210/// a document it just read, which is the same kind of URL a `client_id` is.
211async fn resolve_operator_at(
212 client: &reqwest::Client,
213 document_url: &str,
214 operator_did: &str,
215 private: PrivateAddresses,
216) -> Result<(DidDocument, String, reqwest::Client), ClaimFetchError> {
217 let response = client
218 .get(document_url)
219 .send()
220 .await
221 .map_err(|error| ClaimFetchError::Unreachable(format!("fetching {document_url}: {error}")))?
222 .error_for_status()
223 .map_err(|error| {
224 ClaimFetchError::Unreachable(format!("fetching {document_url}: {error}"))
225 })?;
226 let body = didbot_http::read_bounded(response, didbot_http::MAX_JSON_BODY)
227 .await
228 .map_err(|error| {
229 ClaimFetchError::Unreachable(format!("reading {document_url}: {error}"))
230 })?;
231
232 let document: DidDocument = serde_json::from_slice(&body).map_err(|error| {
233 ClaimFetchError::Unreachable(format!("parsing {document_url}: {error}"))
234 })?;
235 // The same check `didbot_identity::resolve` makes, for the same reason
236 // the DID spec gives: "the DID declared in the document (in the `id`
237 // field) should always be verified against what was expected." A
238 // document served at the right URL claiming a different subject is a
239 // document about somebody else, and reading a claim out of the PDS it
240 // names would be reading somebody else's repository.
241 if document.id != operator_did {
242 return Err(ClaimFetchError::Unreachable(format!(
243 "the did document at {document_url} claims {:?}, expected {operator_did:?}",
244 document.id
245 )));
246 }
247
248 let endpoint = document.pds_endpoint().ok_or_else(|| {
249 ClaimFetchError::Unreachable(format!(
250 "{operator_did}'s did document names no #atproto_pds service"
251 ))
252 })?;
253 let url = url::Url::parse(endpoint).map_err(|error| {
254 ClaimFetchError::Unreachable(format!(
255 "the did document at {document_url} names {endpoint} as its #atproto_pds \
256 service, which is not a URL: {error}"
257 ))
258 })?;
259 let pinned = didbot_http::pinned_client(&url, private)
260 .await
261 .map_err(|error| {
262 ClaimFetchError::Unreachable(format!(
263 "the did document at {document_url} names {endpoint} as its #atproto_pds \
264 service: {error}"
265 ))
266 })?;
267 let endpoint = endpoint.to_owned();
268 Ok((document, endpoint, pinned))
269}
270
271/// Reads the `bot.did.operator` record at `hostname` from the operator's
272/// repository at `endpoint`.
273pub(crate) async fn fetch_claim_from(
274 client: &reqwest::Client,
275 endpoint: &str,
276 operator_did: &str,
277 hostname: &str,
278) -> Result<Option<OperatorClaim>, ClaimFetchError> {
279 let record_url = format!("{endpoint}/xrpc/com.atproto.repo.getRecord");
280 let response = client
281 .get(&record_url)
282 .query(&[
283 ("repo", operator_did),
284 ("collection", COLLECTION),
285 ("rkey", hostname),
286 ])
287 .send()
288 .await
289 .map_err(|error| ClaimFetchError::Unreachable(format!("fetching {record_url}: {error}")))?;
290
291 // atproto answers "no such record" as a 400 naming `RecordNotFound` in
292 // the error body, not a 404 -- but a well-behaved implementation may
293 // reasonably choose either, so both can mean "no claim."
294 //
295 // The status alone does not say which. A 400 is also what a PDS answers
296 // for a collection it does not know, an rkey it will not accept, or a
297 // proxy in front of it rewriting the query, and reading those as "there
298 // is no operator claim" pauses provisioning for a reason nothing names.
299 // So a 400 only means absence when the body says `RecordNotFound`; every
300 // other 400 is a repository this poll could not get an answer out of.
301 let status = response.status().as_u16();
302 if status == 404 {
303 return Ok(None);
304 }
305 if status == 400 {
306 // Under the bound the record itself is read under: a body past it
307 // names no error, and the poll says it could not read the answer.
308 let body = didbot_http::read_bounded(response, didbot_http::MAX_JSON_BODY)
309 .await
310 .map(didbot_http::body_text)
311 .unwrap_or_default();
312 let named = serde_json::from_str::<serde_json::Value>(&body)
313 .ok()
314 .and_then(|value| {
315 value
316 .get("error")
317 .and_then(serde_json::Value::as_str)
318 .map(str::to_owned)
319 });
320 return match named.as_deref() {
321 Some(RECORD_NOT_FOUND) => Ok(None),
322 Some(other) => Err(ClaimFetchError::Unreachable(format!(
323 "{record_url} answered 400 {other}, which is not {RECORD_NOT_FOUND}: \
324 the repository was reached and refused the request"
325 ))),
326 None => Err(ClaimFetchError::Unreachable(format!(
327 "{record_url} answered 400 with no atproto error name: {body}"
328 ))),
329 };
330 }
331 let response = response
332 .error_for_status()
333 .map_err(|error| ClaimFetchError::Unreachable(format!("{record_url} answered: {error}")))?;
334 let body = didbot_http::read_bounded(response, didbot_http::MAX_JSON_BODY)
335 .await
336 .map_err(|error| ClaimFetchError::Unreachable(format!("reading {record_url}: {error}")))?;
337 let body: GetRecordResponse = serde_json::from_slice(&body)
338 .map_err(|error| ClaimFetchError::Unreachable(format!("parsing {record_url}: {error}")))?;
339
340 Ok(OperatorClaim::from_json(&body.value))
341}
342
343/// Builds the [`OperatorRelation`] a poll needs.
344///
345/// The operator is configuration — a server is launched knowing who runs
346/// it, and never discovers that from the network. The claim is read at the
347/// server's own authority as its DID spells it — a development server's
348/// port included — which is the key `didbot operate` writes and the key
349/// [`refresh_root_standing`] reads every root's record at.
350pub fn configure(
351 registry: &dyn Registry,
352 operator_did: &str,
353 grace: time::Duration,
354) -> Arc<OperatorRelation> {
355 let server_did = registry.service_did();
356 let hostname = didbot_identity::did::AccountDid::parse(&server_did)
357 .expect("the server's own DID is a did:web")
358 .authority();
359 Arc::new(OperatorRelation::new(
360 operator_did.to_owned(),
361 hostname,
362 server_did,
363 grace,
364 ))
365}
366
367/// The shortest interval between two polls, however many nudges arrive.
368///
369/// This is the whole anti-amplification property, and it is a floor on
370/// *outbound* work rather than a limit on inbound requests. A token bucket
371/// on the route would bound how many nudges are accepted while still letting
372/// an accepted burst turn into a burst of polls at somebody else's PDS; a
373/// floor bounds the thing that must not amplify directly, so the worst an
374/// abusive caller achieves is one poll per this interval — which is what the
375/// timer was going to do anyway.
376///
377/// Ten seconds. Short enough that an operator who has just run
378/// `didbot operate` sees the server flip within one breath rather than waiting
379/// out [`DEFAULT_POLL_INTERVAL`], and long enough that the operator's PDS is
380/// never asked more than six times a minute by this server no matter what
381/// arrives at the socket.
382pub const MIN_POLL_INTERVAL: StdDuration = StdDuration::from_secs(10);
383
384/// The most one poll tick -- the claim's up to two sequential HTTP
385/// round trips, then the local decision -- may take before the tick is
386/// abandoned.
387///
388/// A backstop on top of the client's own budget, not a substitute for it:
389/// [`didbot_http::client`] falls back to a client with no timeout at all if
390/// its builder ever fails to construct one, and this is what still bounds a
391/// tick if that happens. Thirty seconds is triple
392/// [`didbot_http::DEFAULT_TIMEOUT`], which is the most either HTTP call
393/// normally takes.
394const POLL_TICK_TIMEOUT: StdDuration = StdDuration::from_secs(30);
395
396/// The most root accounts one tick re-reads the operator's record for.
397///
398/// Sized the way [`crate::stale_sweep::DEFAULT_SWEEP_LIMIT`] is, against the
399/// round trips behind it rather than anything in this process: a hundred
400/// `getRecord`s at the operator's PDS finish well inside
401/// `POLL_TICK_TIMEOUT` against a healthy one. A deployment with more roots
402/// than this reads the rest on the ticks that follow, each resuming where
403/// the last stopped.
404pub const VOUCH_REREAD_LIMIT: usize = 100;
405
406/// What [`refresh_root_standing`] carries from one tick to the next.
407#[derive(Debug, Default)]
408pub struct RootStanding {
409 /// The last root read, so the next tick resumes past it.
410 resume_after: Option<String>,
411 /// When each root's record was last read present — or first read
412 /// missing, for one this poll has never seen present — so a lapse is
413 /// measured against the same grace window the server's own claim gets.
414 last_seen: BTreeMap<String, OffsetDateTime>,
415}
416
417/// A request that this server look for its operator's claim sooner than its
418/// timer would.
419///
420/// **Urgency, never authority.** Nothing here asserts a claim, carries a
421/// credential, or names an operator; the only thing a caller can influence
422/// is *when* this server next reads a repository it was going to read
423/// anyway, and the answer still comes from that read. A server that took
424/// anybody's word for a claim would be a server that could be claimed by
425/// whoever asked, which is the whole reason the flip is observation-only.
426///
427/// Nudges **coalesce**: many arriving inside one [`MIN_POLL_INTERVAL`]
428/// window set one flag and produce one poll, not one poll each. See
429/// [`OperatorPoll::spawn`].
430#[derive(Debug)]
431pub struct PollNudge {
432 notify: Notify,
433 /// Whether an [`OperatorPoll`] is listening, so a wait on a channel
434 /// nothing drains answers at once.
435 served: AtomicBool,
436 /// How many polls have begun.
437 begun: AtomicU64,
438 /// The last poll to finish: its number, and whether it read the
439 /// operator's records.
440 finished: watch::Sender<(u64, bool)>,
441}
442
443impl Default for PollNudge {
444 fn default() -> Self {
445 Self {
446 notify: Notify::new(),
447 served: AtomicBool::new(false),
448 begun: AtomicU64::new(0),
449 finished: watch::Sender::new((0, false)),
450 }
451 }
452}
453
454impl PollNudge {
455 /// A fresh nudge channel.
456 pub fn new() -> Self {
457 Self::default()
458 }
459
460 /// Nudges, then waits up to `within` for a poll that began after the
461 /// nudge to finish. Answers whether that poll read the operator's
462 /// records, so a caller refused for want of an allowance can check
463 /// again against what the operator's repository holds now.
464 ///
465 /// The nudge is still bound by the floor, so a poll that ran moments
466 /// ago makes this wait out the rest of [`MIN_POLL_INTERVAL`].
467 pub async fn nudge_and_wait(&self, within: StdDuration) -> bool {
468 if !self.served.load(Ordering::Acquire) {
469 return false;
470 }
471 let mut finished = self.finished.subscribe();
472 let before = self.begun.load(Ordering::Acquire);
473 self.nudge();
474 let waited =
475 tokio::time::timeout(within, finished.wait_for(|(poll, _)| *poll > before)).await;
476 matches!(waited, Ok(Ok(done)) if done.1)
477 }
478
479 /// Asks the poll task to look now, if it is not already about to.
480 ///
481 /// Cheap and infallible on purpose: the cost of an inbound nudge has to
482 /// stay a wake-up on an existing task, or the route in front of it
483 /// becomes a way to make this server do work.
484 pub fn nudge(&self) {
485 self.notify.notify_one();
486 }
487}
488
489/// When an [`OperatorPoll`] polls: its timer, and the floor a nudge
490/// cannot get under.
491///
492/// The two together, rather than two parameters, because they are one
493/// decision: how often this server reads somebody else's repository. Reading
494/// them apart is how a deployment ends up with a tight floor and a loose
495/// timer, or the reverse, and no idea which one it meant.
496#[derive(Debug, Clone, Copy)]
497pub struct PollSchedule {
498 /// How often the poll fires on its own.
499 pub interval: StdDuration,
500 /// The shortest gap between two polls, however many nudges arrive. See
501 /// [`MIN_POLL_INTERVAL`], which is what every deployment passes; a field
502 /// only so this module's own tests can exercise the bound without
503 /// sleeping through it.
504 pub floor: StdDuration,
505}
506
507impl Default for PollSchedule {
508 /// [`DEFAULT_POLL_INTERVAL`] and [`MIN_POLL_INTERVAL`].
509 fn default() -> Self {
510 Self {
511 interval: DEFAULT_POLL_INTERVAL,
512 floor: MIN_POLL_INTERVAL,
513 }
514 }
515}
516
517/// A running operator poll, stopped by [`OperatorPoll::stop`] or by
518/// dropping it.
519pub struct OperatorPoll {
520 handle: JoinHandle<()>,
521 stop: Arc<Notify>,
522}
523
524impl OperatorPoll {
525 /// Starts polling `relation` against the real network on `schedule`,
526 /// driving `estop`'s self latch from what it finds, admitting the
527 /// reservations `registry` holds that the operator has vouched for, and
528 /// reporting to `lifecycle` whether a claim currently stands. Each tick
529 /// that reads the operator's DID document also starts a check of the
530 /// handle it claims, into `operator_handle`, which the tick does not
531 /// wait for.
532 ///
533 /// The first tick fires immediately, not after one interval: a server
534 /// that waited a full interval before its first chance to find an
535 /// already-existing claim would refuse writes for no reason a fresh
536 /// restart should ever cause.
537 ///
538 /// `nudge` lets a caller ask for a poll sooner than the schedule's
539 /// interval, bounded by its floor. A nudge arriving less than the floor
540 /// since the last poll waits out the remainder rather than polling, and
541 /// every further nudge inside that window coalesces into the same single
542 /// poll. So an unbounded burst of nudges costs this server one extra
543 /// outbound request, not one per nudge — see [`PollNudge`].
544 #[allow(clippy::too_many_arguments)]
545 pub fn spawn(
546 relation: Arc<OperatorRelation>,
547 estop: Arc<Estop>,
548 lifecycle: Arc<ServerLifecycle>,
549 registry: Arc<dyn Registry>,
550 client: reqwest::Client,
551 private: PrivateAddresses,
552 schedule: PollSchedule,
553 nudge: Arc<PollNudge>,
554 policies: Option<Arc<PolicyPoll>>,
555 records_read: Arc<OperatorRecords>,
556 operator_handle: Option<Arc<OperatorHandle>>,
557 ) -> Self {
558 let stop = Arc::new(Notify::new());
559 let notified = stop.clone();
560 nudge.served.store(true, Ordering::Release);
561 let handle = tokio::spawn(async move {
562 let mut ticker = tokio::time::interval(schedule.interval);
563 ticker.set_missed_tick_behavior(MissedTickBehavior::Delay);
564 // The floor is kept as an instant rather than a counter so a
565 // nudge that arrives during a slow poll cannot stack: whatever
566 // happened while the poll ran, the next one is still no earlier
567 // than `MIN_POLL_INTERVAL` after this one started.
568 let mut earliest_next = tokio::time::Instant::now();
569 // Where the last tick's re-read of root records stopped, and
570 // when each was last seen; see `RootStanding`.
571 let mut standing = RootStanding::default();
572 loop {
573 let nudged = tokio::select! {
574 biased;
575 () = notified.notified() => break,
576 _ = ticker.tick() => false,
577 () = nudge.notify.notified() => true,
578 };
579 if nudged {
580 let now = tokio::time::Instant::now();
581 if now < earliest_next {
582 // Wait out the floor instead of polling. Any further
583 // nudges arriving meanwhile re-arm the same `Notify`
584 // and are absorbed by the one poll below -- which is
585 // exactly the coalescing this is for.
586 tokio::select! {
587 biased;
588 () = notified.notified() => break,
589 () = tokio::time::sleep_until(earliest_next) => {}
590 }
591 }
592 }
593 // Absorb a nudge that arrived while the floor was being
594 // waited out: it is asking for exactly the poll about to
595 // happen, and `Notify` holds one permit, so leaving it armed
596 // would buy the burst a second poll it has already been
597 // given. A nudge arriving *during* the poll below is
598 // deliberately not absorbed -- that one may have been sent
599 // after the read it would need to see.
600 {
601 let pending = nudge.notify.notified();
602 tokio::pin!(pending);
603 tokio::select! {
604 biased;
605 () = &mut pending => {}
606 () = std::future::ready(()) => {}
607 }
608 }
609 earliest_next = tokio::time::Instant::now() + schedule.floor;
610 let poll = nudge.begun.fetch_add(1, Ordering::AcqRel) + 1;
611 let ticked = tokio::time::timeout(
612 POLL_TICK_TIMEOUT,
613 tick(
614 &relation,
615 &estop,
616 &lifecycle,
617 registry.as_ref(),
618 &client,
619 OffsetDateTime::now_utc(),
620 private,
621 &mut standing,
622 policies.as_deref(),
623 &records_read,
624 operator_handle.as_ref(),
625 ),
626 )
627 .await;
628 nudge
629 .finished
630 .send_replace((poll, matches!(ticked, Ok((_, true)))));
631 if ticked.is_err() {
632 // No outcome was recorded for this tick -- not even the
633 // "could not reach the operator's repository" one
634 // `poll_once` itself would have logged, because it never
635 // returned. The next tick is unaffected: `earliest_next`
636 // is already set, and the ticker fires again on its own
637 // schedule.
638 warn!(
639 operator = relation.operator_did(),
640 hostname = relation.hostname(),
641 timeout = ?POLL_TICK_TIMEOUT,
642 "operator poll tick did not finish within its timeout; abandoning it"
643 );
644 }
645 }
646 nudge.served.store(false, Ordering::Release);
647 });
648 Self { handle, stop }
649 }
650
651 /// Stops the poll and waits for the task to finish.
652 pub async fn stop(self) {
653 self.stop.notify_one();
654 let _ = self.handle.await;
655 }
656}
657
658/// One poll: fetch, decide, act. Split out from [`OperatorPoll::spawn`]'s
659/// closure, and public, so a caller — an integration test walking the
660/// bootstrap, in particular — can drive exactly one tick against a real
661/// operator repository without spawning a task or waiting on a timer. The
662/// spawned poll runs this and, on the same resolved endpoint, the
663/// [`PolicyPoll`] it was given. Every call here re-reads standing vouches
664/// from the first host; the spawned poll is what carries on between ticks.
665pub async fn poll_once(
666 relation: &OperatorRelation,
667 estop: &Estop,
668 lifecycle: &ServerLifecycle,
669 registry: &dyn Registry,
670 client: &reqwest::Client,
671 now: OffsetDateTime,
672 private: PrivateAddresses,
673) -> OperatorTransition {
674 tick(
675 relation,
676 estop,
677 lifecycle,
678 registry,
679 client,
680 now,
681 private,
682 &mut RootStanding::default(),
683 None,
684 &OperatorRecords::new(),
685 None,
686 )
687 .await
688 .0
689}
690
691/// [`poll_once`] with `standing` carried for [`refresh_root_standing`],
692/// followed by `policies`' own tick against the endpoint the claim was
693/// read from. A tick that could not resolve the operator reads no policies
694/// either, and the gate keeps the set it had. Answers the transition, and
695/// whether the operator's records were read and their allowances installed.
696#[allow(clippy::too_many_arguments)]
697async fn tick(
698 relation: &OperatorRelation,
699 estop: &Estop,
700 lifecycle: &ServerLifecycle,
701 registry: &dyn Registry,
702 client: &reqwest::Client,
703 now: OffsetDateTime,
704 private: PrivateAddresses,
705 standing: &mut RootStanding,
706 policies: Option<&PolicyPoll>,
707 records_read: &OperatorRecords,
708 handle: Option<&Arc<OperatorHandle>>,
709) -> (OperatorTransition, bool) {
710 let resolved = match resolve_operator(client, relation.operator_did(), private).await {
711 Ok((document, endpoint, pinned)) => {
712 // Not awaited: the claim below never waits on a name for a page.
713 if let Some(handle) = handle {
714 handle.refresh_in_background(document, private);
715 }
716 Ok((endpoint, pinned))
717 }
718 Err(error) => Err(error),
719 };
720 let fetched = match &resolved {
721 Ok((endpoint, pinned)) => {
722 fetch_claim_from(
723 pinned,
724 endpoint,
725 relation.operator_did(),
726 relation.hostname(),
727 )
728 .await
729 }
730 Err(error) => Err(error.clone()),
731 };
732 if let Err(error) = &fetched {
733 warn!(
734 operator = relation.operator_did(),
735 hostname = relation.hostname(),
736 %error,
737 "operator poll could not reach the operator's repository"
738 );
739 }
740 let source = Prefetched(fetched);
741 let transition = relation.poll(&source, now);
742 // Reported to the lifecycle on every tick, before anything is thrown or
743 // released: `didbot_pds::ServerState` is what the write and provisioning
744 // surfaces consult, and it has to have seen this poll before a caller
745 // can observe the e-stop's reaction to it.
746 //
747 // One boolean. "Never claimed" and "claim lapsed" are the same lifecycle
748 // state with the same recovery, so the distinction stops here, where the
749 // grace window that produced it lives. `GracePending` counts as standing
750 // -- the window exists so a blip at the operator's PDS does not take a
751 // server's writes down.
752 lifecycle.observe_operator_claim(matches!(
753 transition,
754 OperatorTransition::NewlyClaimed
755 | OperatorTransition::StillClaimed
756 | OperatorTransition::GracePending
757 ));
758 match transition {
759 OperatorTransition::NewlyClaimed => {
760 info!(
761 operator = relation.operator_did(),
762 "found the operator's record; the claim stands"
763 );
764 estop.release_self();
765 }
766 OperatorTransition::NewlyLapsed => {
767 warn!(
768 operator = relation.operator_did(),
769 hostname = relation.hostname(),
770 "no matching operator record found within the grace window; pausing the server \
771 until the operator's claim reappears \
772 (see plan/alerts.md -- this deployment does not yet write an alert record for it)"
773 );
774 // A legitimate stop: by the time a claim can lapse the server
775 // has been operational, so there is something running to halt.
776 // `ServerPolicy::may_halt` says so as data, and this asserts
777 // the two agree rather than trusting that they do -- a lapse
778 // derived from anywhere the stop is unavailable would mean the
779 // lifecycle and this module disagree about what has happened.
780 debug_assert!(
781 lifecycle.policy().may_halt,
782 "a lapse threw a stop from a state whose policy forbids one"
783 );
784 estop.throw_self(EstopMode::Pause);
785 }
786 OperatorTransition::StillClaimed
787 | OperatorTransition::StillLapsed
788 | OperatorTransition::GracePending
789 | OperatorTransition::StillUnclaimed => {}
790 }
791 // A cheap sanity check that costs nothing on the ordinary path: a poll
792 // reporting anything other than a Cause::OperatorMissing self-pause after
793 // a lapse would mean this module and `Estop` have disagreed about who
794 // engaged the latch.
795 if matches!(
796 transition,
797 OperatorTransition::NewlyLapsed | OperatorTransition::StillLapsed
798 ) {
799 debug_assert_eq!(estop.status().cause, Some(EstopCause::OperatorMissing));
800 }
801 // Claimed or not: a root's standing only ever says what the operator's
802 // repository says, and having reached that repository is all this
803 // needs. The same operator, the same repository, one record key per
804 // root.
805 if let Ok((endpoint, pinned)) = &resolved {
806 refresh_root_standing(
807 registry,
808 pinned,
809 endpoint,
810 relation.operator_did(),
811 standing,
812 now,
813 relation.grace_window(),
814 )
815 .await;
816 }
817 let allowances_read = match &resolved {
818 Ok((endpoint, pinned)) => {
819 refresh_allowances(
820 registry,
821 pinned,
822 endpoint,
823 relation.operator_did(),
824 records_read,
825 )
826 .await
827 }
828 Err(_) => false,
829 };
830 if let (Some(policies), Ok((endpoint, pinned))) = (policies, &resolved) {
831 if let Err(error) = policies.poll_once(pinned, endpoint, registry).await {
832 warn!(
833 operator = relation.operator_did(),
834 %error,
835 "operator policy poll applied nothing; the set last built keeps serving"
836 );
837 }
838 }
839 (transition, allowances_read)
840}
841
842/// One `bot.did.operator` record in the operator's repository, as the last
843/// listing read it.
844#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
845#[serde(rename_all = "camelCase")]
846pub struct OperatorRecordLine {
847 /// The record's own address in the operator's repository.
848 pub uri: String,
849 /// The account the record names.
850 pub subject: String,
851 /// When the writer says they wrote it, by the writer's own clock.
852 pub created_at: Option<String>,
853}
854
855/// Every `bot.did.operator` record the last successful listing of the
856/// operator's repository read.
857///
858/// An app holding the operator's session writes as the operator, and their
859/// PDS cannot tell the two apart. So the operator recognising a record they
860/// did not write is the defence, and that needs the records in front of
861/// them: where each one is, who it names, and when its writer says it was
862/// written.
863/// Kept here because the poll already lists them on every tick for
864/// [`refresh_allowances`], and a page that refetched them would put a
865/// remote read behind a panel.
866///
867/// `None` until a listing has succeeded, which is a different fact from a
868/// repository that holds none.
869#[derive(Debug, Default)]
870pub struct OperatorRecords {
871 last_read: Mutex<Option<Vec<OperatorRecordLine>>>,
872}
873
874impl OperatorRecords {
875 /// A store nothing has written to yet.
876 pub fn new() -> Self {
877 Self::default()
878 }
879
880 /// What the last successful listing read.
881 pub fn read(&self) -> Option<Vec<OperatorRecordLine>> {
882 self.last_read
883 .lock()
884 .unwrap_or_else(|poisoned| poisoned.into_inner())
885 .clone()
886 }
887
888 /// Replaces what the last listing read.
889 pub(crate) fn store(&self, lines: Vec<OperatorRecordLine>) {
890 *self
891 .last_read
892 .lock()
893 .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(lines);
894 }
895}
896
897/// Lists every `bot.did.operator` record in the operator's repository and
898/// installs the `creates` allowances they carry; see
899/// [`didbot_pds::allowance`]. A listing that fails leaves the allowances
900/// last installed in force, the same way a failed policy listing leaves
901/// the last policy set serving: a blip at the operator's PDS must not
902/// widen or narrow what hosted accounts may create.
903///
904/// Every record in the collection is read, not only the ones naming
905/// accounts hosted here; an allowance for an account this server does not
906/// host is indexed and never matched.
907///
908/// A record naming an account some hosted account created carries no
909/// allowance, and is logged instead. The operator admits an account by
910/// creating it from their own repository, so a record that names a name
911/// already taken beneath a hosted creator is one the creator raced the
912/// operator to: honouring it would hand the creator whatever the operator
913/// meant to grant the account they were admitting. Records naming a root,
914/// or a name no account holds yet, are indexed as before.
915///
916/// Answers whether the listing succeeded and its allowances were installed.
917pub async fn refresh_allowances(
918 registry: &dyn Registry,
919 client: &reqwest::Client,
920 endpoint: &str,
921 operator_did: &str,
922 records_read: &OperatorRecords,
923) -> bool {
924 match crate::policy_poll::list_records(client, endpoint, operator_did, COLLECTION).await {
925 Ok(records) => {
926 let hosted_parents: BTreeMap<String, String> = registry
927 .accounts()
928 .into_iter()
929 .filter_map(|account| {
930 let parent = account.parent?;
931 Some((account.did.as_str().to_owned(), parent.as_str().to_owned()))
932 })
933 .collect();
934 let allowances = Allowances::from_records(records.iter().filter_map(|(uri, value)| {
935 let claim = OperatorClaim::from_json(value)?;
936 if let Some(parent) = hosted_parents.get(&claim.subject) {
937 warn!(
938 record = %uri,
939 subject = %claim.subject,
940 parent = %parent,
941 "the operator's record names an account a hosted account created, \
942 so it grants nothing; the operator admits an account by creating it"
943 );
944 return None;
945 }
946 Some((claim.subject, claim.creates))
947 }));
948 registry.set_allowances(allowances);
949 records_read.store(
950 records
951 .iter()
952 .filter_map(|(uri, value)| {
953 Some(OperatorRecordLine {
954 uri: uri.clone(),
955 subject: value["subject"].as_str()?.to_owned(),
956 created_at: value["createdAt"].as_str().map(str::to_owned),
957 })
958 })
959 .collect(),
960 );
961 true
962 }
963 Err(error) => {
964 warn!(
965 operator = operator_did,
966 %error,
967 "could not list the operator's records; the allowances last installed stay in force"
968 );
969 false
970 }
971 }
972}
973
974/// Re-reads the operator's record for every root account and reports each
975/// one's standing to the registry. Answers with the DIDs whose standing
976/// changed.
977///
978/// A root account is one the operator created from their own repository:
979/// active, hosted here, with no hosted parent, and not the server itself.
980/// The same `getRecord` the server's own claim is read with, at the record
981/// key the root's own hostname is. A record counts when it names the
982/// root's DID exactly.
983///
984/// Absence is the only thing read as revocation, and only once it has
985/// lasted `grace`, the window the server's own claim gets and for the
986/// same reason: a server cannot tell "the operator revoked" from "the
987/// operator's PDS is down". `fetch_claim_from` answers `Ok(None)` for a
988/// repository that was reached and holds no record at the key, and
989/// [`ClaimFetchError`] for everything it could not get an answer out of;
990/// an error leaves every standing as it was and ends the pass, since every
991/// root reads from the same endpoint. [`Registry::set_operator_standing`]
992/// quarantines a lapsed root and everything beneath it, and releases them
993/// when the record returns.
994///
995/// At most [`VOUCH_REREAD_LIMIT`] roots per call, in DID order from just
996/// past where the last call stopped and wrapping to the first.
997pub async fn refresh_root_standing(
998 registry: &dyn Registry,
999 client: &reqwest::Client,
1000 endpoint: &str,
1001 operator_did: &str,
1002 standing: &mut RootStanding,
1003 now: OffsetDateTime,
1004 grace: Duration,
1005) -> Vec<String> {
1006 let mut roots: Vec<HostedAccount> = registry
1007 .accounts()
1008 .into_iter()
1009 .filter(|account| account.state == AccountState::Active && account.parent.is_none())
1010 .collect();
1011 roots.sort_by(|a, b| a.did.as_str().cmp(b.did.as_str()));
1012 if roots.is_empty() {
1013 standing.resume_after = None;
1014 return Vec::new();
1015 }
1016 let start = standing.resume_after.as_deref().map_or(0, |last| {
1017 roots.partition_point(|root| root.did.as_str() <= last)
1018 });
1019 let window = roots.len().min(VOUCH_REREAD_LIMIT);
1020 let mut changed = Vec::new();
1021 for root in roots.iter().cycle().skip(start).take(window) {
1022 let did = root.did.as_str();
1023 let rkey = root.did.did().authority();
1024 let found = match fetch_claim_from(client, endpoint, operator_did, &rkey).await {
1025 Ok(found) => found,
1026 Err(error) => {
1027 warn!(
1028 did,
1029 %error,
1030 "could not re-read the operator's record for a root account; \
1031 leaving its standing as it is until the repository answers"
1032 );
1033 break;
1034 }
1035 };
1036 let present = match found {
1037 Some(claim) if claim.subject == did => true,
1038 Some(claim) => {
1039 warn!(
1040 did,
1041 subject = %claim.subject,
1042 "the record at this root's key names a different did; not its record"
1043 );
1044 false
1045 }
1046 None => false,
1047 };
1048 let stands = if present {
1049 standing.last_seen.insert(did.to_owned(), now);
1050 true
1051 } else {
1052 // A quarantine already hung on the root is this server's own
1053 // record that the window ran out before, and it outlives the
1054 // process that hung it. Starting the clock over on a first read
1055 // would give a revoked subtree another whole window, every
1056 // restart.
1057 let already = root.locks.holds(Tag::new(Lock::Quarantined, Party::Parent));
1058 let since = *standing.last_seen.entry(did.to_owned()).or_insert_with(|| {
1059 if already {
1060 now - grace
1061 } else {
1062 now
1063 }
1064 });
1065 now - since < grace
1066 };
1067 match registry.set_operator_standing(did, stands) {
1068 Ok(true) => {
1069 info!(
1070 did,
1071 present, stands, "the operator's record for a root account changed standing"
1072 );
1073 changed.push(did.to_owned());
1074 }
1075 Ok(false) => {}
1076 Err(error) => {
1077 warn!(did, %error, "could not update a root account's standing");
1078 }
1079 }
1080 standing.resume_after = Some(did.to_owned());
1081 }
1082 changed
1083}
1084
1085#[cfg(test)]
1086mod tests {
1087 use super::*;
1088 use axum::extract::Query;
1089 use axum::response::IntoResponse;
1090 use axum::routing::get;
1091 use axum::{Json, Router};
1092 use serde_json::json;
1093 use std::collections::HashMap;
1094 use std::net::SocketAddr;
1095
1096 // ------------------------------------------------------------------
1097 // The revocation bound: what the two constants compose into
1098 // ------------------------------------------------------------------
1099
1100 /// An operator deletes their record. How long until this server has
1101 /// actually stopped?
1102 ///
1103 /// Driven the way the spawned task drives it — a poll every
1104 /// [`DEFAULT_POLL_INTERVAL`], each one finding nothing — so the answer
1105 /// crosses both constants rather than restating either. The pause lands
1106 /// on the first tick at or after the grace window elapses, which is
1107 /// never later than one interval past it.
1108 #[test]
1109 fn a_revocation_is_acted_on_within_the_grace_window_plus_one_interval() {
1110 let grace = didbot_pds::DEFAULT_GRACE_WINDOW;
1111 let interval = time::Duration::seconds(
1112 DEFAULT_POLL_INTERVAL
1113 .as_secs()
1114 .try_into()
1115 .unwrap_or(i64::MAX),
1116 );
1117 let relation = OperatorRelation::new(
1118 "did:web:operator.example",
1119 "agents.example",
1120 "did:web:agents.example",
1121 grace,
1122 );
1123
1124 let start = OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("valid timestamp");
1125 let claim = OperatorClaim::new("did:web:agents.example", start);
1126 assert_eq!(
1127 relation.poll(&Prefetched(Ok(Some(claim))), start),
1128 OperatorTransition::NewlyClaimed
1129 );
1130
1131 // From here the record is gone. Every subsequent tick finds nothing.
1132 let mut now = start;
1133 let lapsed_at = loop {
1134 now += interval;
1135 assert!(
1136 now - start <= grace + interval,
1137 "still running {} past the revocation",
1138 now - start
1139 );
1140 if relation.poll(&Prefetched(Ok(None)), now) == OperatorTransition::NewlyLapsed {
1141 break now;
1142 }
1143 };
1144 assert!(
1145 lapsed_at - start >= grace,
1146 "paused before the grace window it promised: an operator outage would trip it"
1147 );
1148 assert!(!relation.is_claimed());
1149 }
1150
1151 // ------------------------------------------------------------------
1152 // fetch_claim: a real round trip against a loopback server over TLS
1153 // ------------------------------------------------------------------
1154
1155 /// A client that trusts `ca_pem` on top of the roots it is built with.
1156 ///
1157 /// The fake below serves TLS, because every server this project talks to
1158 /// does and no client here can be told otherwise. This is the same seam
1159 /// a development stack uses through `didbot_http::EXTRA_CA_CERTS`,
1160 /// reached directly so two tests running at once do not share one
1161 /// environment variable.
1162 fn client_trusting(ca_pem: &str) -> reqwest::Client {
1163 didbot_http::builder()
1164 .add_root_certificate(
1165 reqwest::Certificate::from_pem(ca_pem.as_bytes()).expect("the authority parses"),
1166 )
1167 .build()
1168 .expect("a client with one extra root builds")
1169 }
1170
1171 /// The path the fake serves an operator's document at in addition to
1172 /// `/.well-known/did.json`, standing in for the one directory a
1173 /// `did:plc` document is fetched from. `https://plc.directory` is not
1174 /// reachable from a test, and `didbot_identity::document_url` is the
1175 /// only thing that names it, so the fake offers a second path and the
1176 /// `did:plc` tests hand that URL to [`fetch_claim_at`] directly.
1177 const DIRECTORY_PATH: &str = "/plc-directory-stand-in";
1178
1179 /// A minimal stand-in for an operator's PDS: serves a did:web document
1180 /// naming itself as the `#atproto_pds`, and answers getRecord for
1181 /// whatever `bot.did.operator` records `records` holds, keyed by rkey.
1182 async fn fake_operator_pds(
1183 records: HashMap<String, serde_json::Value>,
1184 ) -> (SocketAddr, tokio::task::JoinHandle<()>, String) {
1185 fake_operator_pds_counting(records, Arc::new(std::sync::atomic::AtomicUsize::new(0))).await
1186 }
1187
1188 /// [`fake_operator_pds`], publishing a document that claims `did` rather
1189 /// than the `did:web` its own address implies — an operator whose
1190 /// identity is a `did:plc`, whose PDS is still an ordinary host.
1191 async fn fake_operator_pds_for(
1192 did: &str,
1193 records: HashMap<String, serde_json::Value>,
1194 ) -> (SocketAddr, tokio::task::JoinHandle<()>, String) {
1195 fake_operator_pds_inner(
1196 records,
1197 Arc::new(std::sync::atomic::AtomicUsize::new(0)),
1198 "RecordNotFound",
1199 Some(did.to_owned()),
1200 )
1201 .await
1202 }
1203
1204 /// [`fake_operator_pds`], counting every `getRecord` it answers — which
1205 /// is one per poll, and therefore the outbound rate the nudge must not
1206 /// amplify.
1207 async fn fake_operator_pds_counting(
1208 records: HashMap<String, serde_json::Value>,
1209 hits: Arc<std::sync::atomic::AtomicUsize>,
1210 ) -> (SocketAddr, tokio::task::JoinHandle<()>, String) {
1211 fake_operator_pds_inner(records, hits, "RecordNotFound", None).await
1212 }
1213
1214 /// [`fake_operator_pds`], answering a miss with a caller-chosen atproto
1215 /// error name rather than `RecordNotFound` -- the only way to tell a 400
1216 /// that means "no such record" from a 400 that means something else.
1217 async fn fake_operator_pds_answering(
1218 records: HashMap<String, serde_json::Value>,
1219 miss: &'static str,
1220 ) -> (SocketAddr, tokio::task::JoinHandle<()>, String) {
1221 fake_operator_pds_inner(
1222 records,
1223 Arc::new(std::sync::atomic::AtomicUsize::new(0)),
1224 miss,
1225 None,
1226 )
1227 .await
1228 }
1229
1230 /// The one fake both of the above are views of.
1231 async fn fake_operator_pds_inner(
1232 records: HashMap<String, serde_json::Value>,
1233 hits: Arc<std::sync::atomic::AtomicUsize>,
1234 miss: &'static str,
1235 did_override: Option<String>,
1236 ) -> (SocketAddr, tokio::task::JoinHandle<()>, String) {
1237 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
1238 .await
1239 .expect("bind");
1240 let addr = listener.local_addr().expect("local addr");
1241 let base = format!("https://{addr}");
1242 let (tls, ca_pem) = didbot_tls::test_support::server_config(&["127.0.0.1"]);
1243 // The clients this fake is reached through are built inside the
1244 // code under test, so they are told once, for the process.
1245 didbot_http::test_support::trust(&ca_pem);
1246 let did =
1247 did_override.unwrap_or_else(|| format!("did:web:{}%3A{}", addr.ip(), addr.port()));
1248
1249 let document = json!({
1250 "@context": ["https://www.w3.org/ns/did/v1"],
1251 "id": did,
1252 "alsoKnownAs": ["at://operator.example"],
1253 "service": [{
1254 "id": format!("{did}#atproto_pds"),
1255 "type": "AtprotoPersonalDataServer",
1256 "serviceEndpoint": base,
1257 }],
1258 });
1259
1260 let well_known = document.clone();
1261 let router = Router::new()
1262 .route(
1263 "/.well-known/did.json",
1264 get(move || {
1265 let document = well_known.clone();
1266 async move { Json(document) }
1267 }),
1268 )
1269 .route(
1270 DIRECTORY_PATH,
1271 get(move || {
1272 let document = document.clone();
1273 async move { Json(document) }
1274 }),
1275 )
1276 .route(
1277 "/xrpc/com.atproto.repo.getRecord",
1278 get(move |Query(params): Query<HashMap<String, String>>| {
1279 let records = records.clone();
1280 let hits = hits.clone();
1281 async move {
1282 hits.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1283 let rkey = params.get("rkey").cloned().unwrap_or_default();
1284 match records.get(&rkey) {
1285 Some(value) => Json(json!({
1286 "uri": format!("at://operator/bot.did.operator/{rkey}"),
1287 "cid": "bafyfake",
1288 "value": value,
1289 }))
1290 .into_response(),
1291 None => (
1292 axum::http::StatusCode::BAD_REQUEST,
1293 Json(json!({"error": miss})),
1294 )
1295 .into_response(),
1296 }
1297 }
1298 }),
1299 );
1300
1301 let handle = tokio::spawn(async move {
1302 crate::serve_tls(listener, router, tls).await.ok();
1303 });
1304 (addr, handle, ca_pem)
1305 }
1306
1307 /// A stand-in operator PDS whose `getRecord` answers a `302` pointing
1308 /// at another host rather than a record.
1309 async fn fake_operator_pds_redirecting(
1310 to: String,
1311 ) -> (SocketAddr, tokio::task::JoinHandle<()>) {
1312 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
1313 .await
1314 .expect("bind");
1315 let addr = listener.local_addr().expect("local addr");
1316 let base = format!("http://{addr}");
1317 let did = format!("did:web:{}%3A{}", addr.ip(), addr.port());
1318 let document = json!({
1319 "@context": ["https://www.w3.org/ns/did/v1"],
1320 "id": did,
1321 "service": [{
1322 "id": format!("{did}#atproto_pds"),
1323 "type": "AtprotoPersonalDataServer",
1324 "serviceEndpoint": base,
1325 }],
1326 });
1327 let well_known = document.clone();
1328 let router = Router::new()
1329 .route(
1330 "/.well-known/did.json",
1331 get(move || {
1332 let document = well_known.clone();
1333 async move { Json(document) }
1334 }),
1335 )
1336 .route(
1337 DIRECTORY_PATH,
1338 get(move || {
1339 let document = document.clone();
1340 async move { Json(document) }
1341 }),
1342 )
1343 .route(
1344 "/xrpc/com.atproto.repo.getRecord",
1345 get(move |Query(params): Query<HashMap<String, String>>| {
1346 let to = to.clone();
1347 async move {
1348 let rkey = params.get("rkey").cloned().unwrap_or_default();
1349 axum::response::Redirect::temporary(&format!(
1350 "{to}/xrpc/com.atproto.repo.getRecord\
1351 ?repo=operator&collection=bot.did.operator&rkey={rkey}"
1352 ))
1353 }
1354 }),
1355 );
1356
1357 let handle = tokio::spawn(async move {
1358 axum::serve(listener, router).await.ok();
1359 });
1360 (addr, handle)
1361 }
1362
1363 /// The endpoint is checked once, so the read of it follows no redirect:
1364 /// a 3xx from the operator's PDS would land the request at an address
1365 /// that check never saw.
1366 #[tokio::test]
1367 async fn a_redirect_from_the_operators_pds_is_not_followed() {
1368 let hostname = "pds.example.test";
1369 let mut records = HashMap::new();
1370 records.insert(
1371 hostname.to_owned(),
1372 json!({
1373 "subject": "did:web:pds.example.test",
1374 "createdAt": "2026-01-01T00:00:00Z",
1375 }),
1376 );
1377 let hits = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1378 // Nothing ever connects to the server the redirect names, so the
1379 // authority it signs from is not needed here.
1380 let (elsewhere, elsewhere_handle, _ca) =
1381 fake_operator_pds_counting(records, hits.clone()).await;
1382 let (addr, handle) = fake_operator_pds_redirecting(format!("https://{elsewhere}")).await;
1383 let document_url = format!("http://{addr}{DIRECTORY_PATH}");
1384
1385 let result = fetch_claim_at(
1386 &didbot_http::client(),
1387 &document_url,
1388 &format!("did:web:{}%3A{}", addr.ip(), addr.port()),
1389 hostname,
1390 PrivateAddresses::Allowed,
1391 )
1392 .await;
1393 assert!(
1394 matches!(result, Err(ClaimFetchError::Unreachable(_))),
1395 "a redirect is not a record, got {result:?}"
1396 );
1397 assert_eq!(
1398 hits.load(std::sync::atomic::Ordering::SeqCst),
1399 0,
1400 "the redirect was followed"
1401 );
1402
1403 handle.abort();
1404 elsewhere_handle.abort();
1405 }
1406
1407 /// The `#atproto_pds` endpoint inside the operator's document is a URL
1408 /// a fetched document told this server to fetch from. A deployment on
1409 /// the public internet refuses one on its own network, and says which
1410 /// endpoint it refused.
1411 #[tokio::test]
1412 async fn an_endpoint_the_document_names_on_this_network_is_refused() {
1413 let (addr, handle, ca) = fake_operator_pds(HashMap::new()).await;
1414 let operator_did = format!("did:web:{}%3A{}", addr.ip(), addr.port());
1415 let client = client_trusting(&ca);
1416
1417 let error = fetch_claim(
1418 &client,
1419 &operator_did,
1420 "pds.example.test",
1421 PrivateAddresses::Refused,
1422 )
1423 .await
1424 .expect_err("the endpoint is on this deployment's own network");
1425 let message = error.to_string();
1426 assert!(message.contains(&format!("https://{addr}")), "{message}");
1427 assert!(
1428 message.contains("will not fetch from"),
1429 "the refusal names the address list: {message}"
1430 );
1431
1432 handle.abort();
1433 }
1434
1435 #[tokio::test]
1436 async fn a_matching_record_is_fetched_and_parsed() {
1437 let hostname = "pds.example.test";
1438 let mut records = HashMap::new();
1439 records.insert(
1440 hostname.to_owned(),
1441 json!({
1442 "subject": "did:web:pds.example.test",
1443 "createdAt": "2026-01-01T00:00:00Z",
1444 }),
1445 );
1446 let (addr, handle, ca) = fake_operator_pds(records).await;
1447 let operator_did = format!("did:web:{}%3A{}", addr.ip(), addr.port());
1448 let client = client_trusting(&ca);
1449
1450 let claim = fetch_claim(&client, &operator_did, hostname, PrivateAddresses::Allowed)
1451 .await
1452 .expect("should succeed")
1453 .expect("should find a claim");
1454 assert_eq!(claim.subject, "did:web:pds.example.test");
1455
1456 handle.abort();
1457 }
1458
1459 /// An operator whose `did:web` host accepts the connection and never
1460 /// speaks costs a tick's DID resolution exactly
1461 /// `didbot_http::DEFAULT_CONNECT_TIMEOUT` — the handshake is part of
1462 /// connecting — and then `Unreachable`.
1463 #[tokio::test(start_paused = true)]
1464 async fn an_operator_host_that_never_answers_is_unreachable_after_the_client_timeout() {
1465 let addr = didbot_http::test_support::hung_server();
1466 let operator_did = format!("did:web:{}%3A{}", addr.ip(), addr.port());
1467 let client = didbot_http::client();
1468 let started = tokio::time::Instant::now();
1469 let fetching = fetch_claim(
1470 &client,
1471 &operator_did,
1472 "pds.example.test",
1473 PrivateAddresses::Allowed,
1474 );
1475 let err = tokio::time::timeout(didbot_http::DEFAULT_TIMEOUT * 2, fetching)
1476 .await
1477 .expect("the client's own timeout fires first")
1478 .expect_err("nothing ever answers");
1479 assert!(matches!(err, ClaimFetchError::Unreachable(_)), "{err:?}");
1480 assert_eq!(started.elapsed(), didbot_http::DEFAULT_CONNECT_TIMEOUT);
1481 }
1482
1483 #[tokio::test]
1484 async fn a_missing_record_is_ok_none_not_an_error() {
1485 let (addr, handle, ca) = fake_operator_pds(HashMap::new()).await;
1486 let operator_did = format!("did:web:{}%3A{}", addr.ip(), addr.port());
1487 let client = client_trusting(&ca);
1488
1489 let claim = fetch_claim(
1490 &client,
1491 &operator_did,
1492 "pds.example.test",
1493 PrivateAddresses::Allowed,
1494 )
1495 .await
1496 .expect("a 400 RecordNotFound is not an unreachable repository");
1497 assert!(claim.is_none());
1498
1499 handle.abort();
1500 }
1501
1502 #[tokio::test]
1503 async fn a_400_that_is_not_record_not_found_is_unreachable_not_an_absent_claim() {
1504 // The operator's PDS answered, and what it said was that the request
1505 // was wrong -- a collection it does not know, a malformed rkey, a
1506 // proxy in front of it rewriting the query. Reading that as "there is
1507 // no operator claim" pauses provisioning for a reason nothing names.
1508 let (addr, handle, ca) =
1509 fake_operator_pds_answering(HashMap::new(), "InvalidRequest").await;
1510 let operator_did = format!("did:web:{}%3A{}", addr.ip(), addr.port());
1511 let client = client_trusting(&ca);
1512
1513 let result = fetch_claim(
1514 &client,
1515 &operator_did,
1516 "pds.example.test",
1517 PrivateAddresses::Allowed,
1518 )
1519 .await;
1520 assert!(
1521 matches!(result, Err(ClaimFetchError::Unreachable(_))),
1522 "a 400 the poll cannot read as RecordNotFound must be loud, got {result:?}"
1523 );
1524
1525 handle.abort();
1526 }
1527
1528 /// A 400's body is read under the bound the record is read under, so one
1529 /// naming `RecordNotFound` past it is an answer the poll could not read,
1530 /// not an absent claim.
1531 #[tokio::test]
1532 async fn a_400_body_past_the_bound_is_unreachable_not_an_absent_claim() {
1533 let padding = "x".repeat(didbot_http::MAX_JSON_BODY);
1534 let router = Router::new().route(
1535 "/xrpc/com.atproto.repo.getRecord",
1536 get(move || {
1537 let padding = padding.clone();
1538 async move {
1539 (
1540 axum::http::StatusCode::BAD_REQUEST,
1541 Json(json!({ "error": RECORD_NOT_FOUND, "message": padding })),
1542 )
1543 }
1544 }),
1545 );
1546 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
1547 .await
1548 .expect("bind");
1549 let addr = listener.local_addr().expect("local addr");
1550 let handle = tokio::spawn(async move {
1551 axum::serve(listener, router).await.ok();
1552 });
1553
1554 let result = fetch_claim_from(
1555 &didbot_http::client(),
1556 &format!("http://{addr}"),
1557 "did:web:operator.example",
1558 "pds.example.test",
1559 )
1560 .await;
1561 assert!(
1562 matches!(result, Err(ClaimFetchError::Unreachable(_))),
1563 "an oversized 400 was read as an absent claim: {result:?}"
1564 );
1565
1566 handle.abort();
1567 }
1568
1569 /// The operator whose identity is a `did:plc` — the ordinary case for a
1570 /// person with a Bluesky account — reaches a confirmed claim.
1571 ///
1572 /// The whole path runs: the document is fetched, checked to be the one
1573 /// belonging to that DID, and its `#atproto_pds` service is where the
1574 /// record is read from; the record is then handed to the same
1575 /// `OperatorRelation::poll` a live tick uses, which is where "confirmed"
1576 /// actually means something. The one step held out is the URL the
1577 /// directory lives at, which `didbot-identity` owns and tests, and which
1578 /// `a_did_plc_resolves_against_the_plc_directory` below pins from here.
1579 #[tokio::test]
1580 async fn a_did_plc_operator_reaches_a_confirmed_claim() {
1581 let operator_did = "did:plc:nlzmjyfv6loqtxyzvdcznwgf";
1582 let hostname = "pds.example.test";
1583 let server_did = "did:web:pds.example.test";
1584
1585 let mut records = HashMap::new();
1586 records.insert(
1587 hostname.to_owned(),
1588 json!({
1589 "subject": server_did,
1590 "createdAt": "2026-01-01T00:00:00Z",
1591 }),
1592 );
1593 let (addr, handle, ca) = fake_operator_pds_for(operator_did, records).await;
1594 let document_url = format!("https://{addr}{DIRECTORY_PATH}");
1595 let client = client_trusting(&ca);
1596
1597 let claim = fetch_claim_at(
1598 &client,
1599 &document_url,
1600 operator_did,
1601 hostname,
1602 PrivateAddresses::Allowed,
1603 )
1604 .await
1605 .expect("the directory answered and the operator's pds was reached")
1606 .expect("the operator's repository holds a claim at this hostname");
1607 assert_eq!(claim.subject, server_did);
1608
1609 let relation =
1610 OperatorRelation::new(operator_did, hostname, server_did, time::Duration::hours(6));
1611 assert_eq!(
1612 relation.poll(
1613 &Prefetched(Ok(Some(claim))),
1614 OffsetDateTime::from_unix_timestamp(1_800_000_000).expect("valid timestamp"),
1615 ),
1616 OperatorTransition::NewlyClaimed,
1617 "a did:plc operator's claim confirms exactly as a did:web one does"
1618 );
1619 assert!(relation.is_claimed());
1620
1621 handle.abort();
1622 }
1623
1624 /// A directory that answers with a document belonging to somebody else
1625 /// is not an answer about this operator, and the claim in the repository
1626 /// that document names is not this operator's claim.
1627 #[tokio::test]
1628 async fn a_document_claiming_another_did_is_not_read_as_the_operators() {
1629 let hostname = "pds.example.test";
1630 let mut records = HashMap::new();
1631 records.insert(
1632 hostname.to_owned(),
1633 json!({
1634 "subject": "did:web:pds.example.test",
1635 "createdAt": "2026-01-01T00:00:00Z",
1636 }),
1637 );
1638 let (addr, handle, ca) =
1639 fake_operator_pds_for("did:plc:nlzmjyfv6loqtxyzvdcznwgf", records).await;
1640 let document_url = format!("https://{addr}{DIRECTORY_PATH}");
1641 let client = client_trusting(&ca);
1642
1643 let result = fetch_claim_at(
1644 &client,
1645 &document_url,
1646 "did:plc:vwmrkybzt2ncwyipgnu6cocx",
1647 hostname,
1648 PrivateAddresses::Allowed,
1649 )
1650 .await;
1651 assert!(
1652 matches!(result, Err(ClaimFetchError::Unreachable(_))),
1653 "the document at this url claims a different did, got {result:?}"
1654 );
1655
1656 handle.abort();
1657 }
1658
1659 /// The half held out of the test above: which URL each method's document
1660 /// is fetched from. `did:web` is the hostname the DID names, `did:plc`
1661 /// the PLC directory — and this poll takes both from the one dispatch in
1662 /// `didbot-identity` rather than deciding either itself.
1663 #[test]
1664 fn a_did_plc_resolves_against_the_plc_directory() {
1665 let plc = "did:plc:nlzmjyfv6loqtxyzvdcznwgf";
1666 assert_eq!(
1667 didbot_identity::document_url(plc).expect("did:plc is a method this poll resolves"),
1668 format!("{}/{plc}", didbot_identity::DEFAULT_PLC_DIRECTORY)
1669 );
1670 assert_eq!(
1671 didbot_identity::document_url("did:web:operator.example")
1672 .expect("did:web is a method this poll resolves"),
1673 "https://operator.example/.well-known/did.json"
1674 );
1675 }
1676
1677 /// A DID this deployment has no way to resolve is reported as its own
1678 /// error, and still lands in the grace window: nothing about being
1679 /// unable to look confirms a claim.
1680 #[tokio::test]
1681 async fn an_operator_did_this_poll_cannot_resolve_never_confirms_a_claim() {
1682 let client = didbot_http::client();
1683 let result = fetch_claim(
1684 &client,
1685 "did:key:zQ3shrubbery",
1686 "pds.example.test",
1687 PrivateAddresses::Allowed,
1688 )
1689 .await;
1690 assert!(
1691 matches!(result, Err(ClaimFetchError::Unresolvable(_))),
1692 "a method with no document url is this deployment's configuration, \
1693 not somebody else's outage, got {result:?}"
1694 );
1695
1696 let relation = OperatorRelation::new(
1697 "did:key:zQ3shrubbery",
1698 "pds.example.test",
1699 "did:web:pds.example.test",
1700 time::Duration::hours(6),
1701 );
1702 let now = OffsetDateTime::from_unix_timestamp(1_800_000_000).expect("valid timestamp");
1703 assert_eq!(
1704 relation.poll(&Prefetched(result), now),
1705 OperatorTransition::StillUnclaimed,
1706 "an unresolvable operator did leaves the server unclaimed"
1707 );
1708 assert!(!relation.is_claimed());
1709 }
1710
1711 /// The other side of the same taxonomy as
1712 /// `an_operator_did_this_poll_cannot_resolve_never_confirms_a_claim`: a
1713 /// DID that resolves fine to a host that is not answering is somebody
1714 /// else's outage, and stays distinct from one this deployment cannot
1715 /// resolve at all.
1716 #[tokio::test]
1717 async fn a_closed_port_is_unreachable_rather_than_unresolvable() {
1718 // Bind and immediately drop, freeing the port with nothing listening.
1719 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
1720 .await
1721 .expect("bind");
1722 let addr = listener.local_addr().expect("local addr");
1723 drop(listener);
1724
1725 let operator_did = format!("did:web:{}%3A{}", addr.ip(), addr.port());
1726 let client = didbot_http::client();
1727 let result = fetch_claim(
1728 &client,
1729 &operator_did,
1730 "pds.example.test",
1731 PrivateAddresses::Allowed,
1732 )
1733 .await;
1734 assert!(matches!(result, Err(ClaimFetchError::Unreachable(_))));
1735 }
1736 // ------------------------------------------------------------------
1737 // The nudge, and the bound that stops it amplifying
1738 // ------------------------------------------------------------------
1739
1740 /// The property the whole nudge design exists to hold: a burst of
1741 /// "please look now" costs this server *one* extra outbound request, not
1742 /// one per nudge. The bound is on the outbound rate, so it does not
1743 /// matter how many arrive or how fast.
1744 #[tokio::test(flavor = "multi_thread")]
1745 async fn a_burst_of_nudges_produces_at_most_one_extra_poll() {
1746 use std::sync::atomic::{AtomicUsize, Ordering};
1747
1748 let hits = Arc::new(AtomicUsize::new(0));
1749 let (addr, server, ca) = fake_operator_pds_counting(HashMap::new(), hits.clone()).await;
1750 let operator = format!("did:web:{}%3A{}", addr.ip(), addr.port());
1751
1752 let relation = Arc::new(OperatorRelation::new(
1753 operator,
1754 "pds.example.test",
1755 "did:web:pds.example.test",
1756 time::Duration::hours(6),
1757 ));
1758 let nudge = Arc::new(PollNudge::new());
1759 let poll = OperatorPoll::spawn(
1760 relation,
1761 Arc::new(Estop::default()),
1762 Arc::new(ServerLifecycle::unclaimed()),
1763 Arc::new(crate::tests::FakeRegistry::new()) as Arc<dyn Registry>,
1764 client_trusting(&ca),
1765 PrivateAddresses::Allowed,
1766 PollSchedule {
1767 // Long enough that the timer never fires a second time
1768 // inside this test, so every poll after the immediate first
1769 // one is attributable to a nudge.
1770 interval: StdDuration::from_secs(3600),
1771 // Wide enough that a poll -- a TLS handshake and two
1772 // requests -- finishes inside the window each assertion
1773 // below is about, on a machine doing other things.
1774 floor: StdDuration::from_millis(360),
1775 },
1776 nudge.clone(),
1777 None,
1778 Arc::new(OperatorRecords::new()),
1779 None,
1780 );
1781
1782 // Let the immediate first tick land.
1783 tokio::time::sleep(StdDuration::from_millis(180)).await;
1784 let after_first = hits.load(Ordering::SeqCst);
1785 assert_eq!(after_first, 1, "the first tick should fire immediately");
1786
1787 // Now shout. Every one of these lands inside the floor window the
1788 // first poll opened.
1789 for _ in 0..200 {
1790 nudge.nudge();
1791 }
1792 tokio::time::sleep(StdDuration::from_millis(120)).await;
1793 assert_eq!(
1794 hits.load(Ordering::SeqCst),
1795 1,
1796 "a nudge inside the floor window must wait, not poll"
1797 );
1798
1799 // And when the floor expires, the whole burst collapses into one.
1800 tokio::time::sleep(StdDuration::from_millis(600)).await;
1801 assert_eq!(
1802 hits.load(Ordering::SeqCst),
1803 2,
1804 "200 nudges must cost one extra outbound request, not 200"
1805 );
1806
1807 poll.stop().await;
1808 server.abort();
1809 }
1810
1811 /// And the nudge is not decorative: it does bring a poll forward, which
1812 /// is the whole reason to accept one.
1813 #[tokio::test(flavor = "multi_thread")]
1814 async fn a_nudge_brings_the_next_poll_forward() {
1815 use std::sync::atomic::{AtomicUsize, Ordering};
1816
1817 let hits = Arc::new(AtomicUsize::new(0));
1818 let (addr, server, ca) = fake_operator_pds_counting(HashMap::new(), hits.clone()).await;
1819 let operator = format!("did:web:{}%3A{}", addr.ip(), addr.port());
1820
1821 let relation = Arc::new(OperatorRelation::new(
1822 operator,
1823 "pds.example.test",
1824 "did:web:pds.example.test",
1825 time::Duration::hours(6),
1826 ));
1827 let nudge = Arc::new(PollNudge::new());
1828 let poll = OperatorPoll::spawn(
1829 relation,
1830 Arc::new(Estop::default()),
1831 Arc::new(ServerLifecycle::unclaimed()),
1832 Arc::new(crate::tests::FakeRegistry::new()) as Arc<dyn Registry>,
1833 client_trusting(&ca),
1834 PrivateAddresses::Allowed,
1835 PollSchedule {
1836 interval: StdDuration::from_secs(3600),
1837 // See the floor in the test above: a poll now opens a TLS
1838 // connection, and each window has to cover one.
1839 floor: StdDuration::from_millis(150),
1840 },
1841 nudge.clone(),
1842 None,
1843 Arc::new(OperatorRecords::new()),
1844 None,
1845 );
1846
1847 tokio::time::sleep(StdDuration::from_millis(300)).await;
1848 assert_eq!(hits.load(Ordering::SeqCst), 1);
1849 // Past the floor now, so this one is taken immediately rather than
1850 // waited out.
1851 nudge.nudge();
1852 tokio::time::sleep(StdDuration::from_millis(300)).await;
1853 assert_eq!(
1854 hits.load(Ordering::SeqCst),
1855 2,
1856 "an hour-long timer plus a nudge should still have polled twice"
1857 );
1858
1859 poll.stop().await;
1860 server.abort();
1861 }
1862
1863 /// A handle lookup that never answers holds up neither the claim nor
1864 /// the next poll: the check runs beside the tick, not inside it.
1865 #[tokio::test(flavor = "multi_thread")]
1866 async fn a_handle_lookup_that_never_answers_does_not_hold_the_claim() {
1867 let mut records = HashMap::new();
1868 records.insert(
1869 "pds.example.test".to_owned(),
1870 json!({
1871 "subject": "did:web:pds.example.test",
1872 "createdAt": "2026-01-01T00:00:00Z",
1873 }),
1874 );
1875 let (addr, server, ca) = fake_operator_pds(records).await;
1876 let relation = Arc::new(OperatorRelation::new(
1877 format!("did:web:{}%3A{}", addr.ip(), addr.port()),
1878 "pds.example.test",
1879 "did:web:pds.example.test",
1880 time::Duration::hours(6),
1881 ));
1882 let lifecycle = Arc::new(ServerLifecycle::unclaimed());
1883 let nudge = Arc::new(PollNudge::new());
1884 let poll = OperatorPoll::spawn(
1885 relation.clone(),
1886 Arc::new(Estop::default()),
1887 lifecycle.clone(),
1888 Arc::new(crate::tests::FakeRegistry::new()) as Arc<dyn Registry>,
1889 client_trusting(&ca),
1890 PrivateAddresses::Allowed,
1891 PollSchedule {
1892 interval: StdDuration::from_secs(3600),
1893 floor: StdDuration::from_millis(150),
1894 },
1895 nudge.clone(),
1896 None,
1897 Arc::new(OperatorRecords::new()),
1898 Some(Arc::new(OperatorHandle::new(
1899 crate::operator_handle::tests::Stalls,
1900 ))),
1901 );
1902 tokio::time::sleep(StdDuration::from_millis(300)).await;
1903 assert!(relation.is_claimed(), "the first tick found the claim");
1904 assert_eq!(lifecycle.state(), didbot_pds::ServerState::Claimed);
1905
1906 // Well inside `LOOKUP_TIMEOUT`, with the first lookup still stalled.
1907 let started = tokio::time::Instant::now();
1908 nudge.nudge_and_wait(StdDuration::from_secs(3)).await;
1909 assert!(
1910 started.elapsed() < StdDuration::from_secs(2),
1911 "the next poll waited on the lookup: {:?}",
1912 started.elapsed()
1913 );
1914 assert!(relation.is_claimed());
1915
1916 poll.stop().await;
1917 server.abort();
1918 }
1919
1920 /// A caller waiting on a nudge hears back when the poll it asked for
1921 /// finishes, not when its own bound runs out; and with no poll running
1922 /// it hears back at once.
1923 #[tokio::test(flavor = "multi_thread")]
1924 async fn a_wait_on_a_nudge_ends_with_the_poll_it_asked_for() {
1925 use std::sync::atomic::{AtomicUsize, Ordering};
1926
1927 let idle = PollNudge::new();
1928 let started = tokio::time::Instant::now();
1929 assert!(!idle.nudge_and_wait(StdDuration::from_secs(5)).await);
1930 assert!(started.elapsed() < StdDuration::from_secs(1));
1931
1932 let hits = Arc::new(AtomicUsize::new(0));
1933 let (addr, server, ca) = fake_operator_pds_counting(HashMap::new(), hits.clone()).await;
1934 let relation = Arc::new(OperatorRelation::new(
1935 format!("did:web:{}%3A{}", addr.ip(), addr.port()),
1936 "pds.example.test",
1937 "did:web:pds.example.test",
1938 time::Duration::hours(6),
1939 ));
1940 let nudge = Arc::new(PollNudge::new());
1941 let poll = OperatorPoll::spawn(
1942 relation,
1943 Arc::new(Estop::default()),
1944 Arc::new(ServerLifecycle::unclaimed()),
1945 Arc::new(crate::tests::FakeRegistry::new()) as Arc<dyn Registry>,
1946 client_trusting(&ca),
1947 PrivateAddresses::Allowed,
1948 PollSchedule {
1949 interval: StdDuration::from_secs(3600),
1950 floor: StdDuration::from_millis(150),
1951 },
1952 nudge.clone(),
1953 None,
1954 Arc::new(OperatorRecords::new()),
1955 None,
1956 );
1957 tokio::time::sleep(StdDuration::from_millis(300)).await;
1958 assert_eq!(hits.load(Ordering::SeqCst), 1);
1959
1960 let started = tokio::time::Instant::now();
1961 // This fake lists no records, so the poll reads none.
1962 assert!(!nudge.nudge_and_wait(StdDuration::from_secs(5)).await);
1963 assert!(
1964 started.elapsed() < StdDuration::from_secs(4),
1965 "the wait ends with the poll, not the bound: {:?}",
1966 started.elapsed()
1967 );
1968 assert_eq!(hits.load(Ordering::SeqCst), 2);
1969
1970 poll.stop().await;
1971 server.abort();
1972 }
1973}