1use std::future::Future;
28use std::time::{Duration, Instant};
29
30use recall_wire::devices::{
31 ACCESS_DENIED, AUTHORIZATION_PENDING, ENROLL_PATH, ENROLL_POLL_PATH, EXPIRED_TOKEN,
32 INVALID_GRANT, POLL_INTERVAL_SECONDS, SCOPE_WORKER, SLOW_DOWN,
33};
34use recall_wire::discovery::CAPABILITY_MERGE_QUEUE;
35use recall_wire::jobs::{self, KIND_MERGE};
36use recall_wire::{
37 ClaimRequest, ClaimResponse, ClaudeCliReport, Discovery, EnrollPending, EnrollPollRequest,
38 EnrollPollResponse, EnrollRequest, Job, MergeResult, ResultRequest, ResultResponse,
39 DISCOVERY_PATH,
40};
41use reqwest::StatusCode;
42
43use crate::api::{Api, ApiError};
44use crate::config::Config;
45use crate::identity::Identity;
46use crate::merge::{Merger, Status};
47
48const UNSIGNED_TIMEOUT: Duration = Duration::from_secs(30);
50
51const RESULT_TIMEOUT: Duration = Duration::from_secs(60);
53
54const RESULT_TRIES: u32 = 5;
57
58const MAX_BACKOFF: Duration = Duration::from_secs(60);
61
62const NOT_LOGGED_IN_RECHECK: Duration = Duration::from_secs(60);
66
67const FAST_POLL_FOR: Duration = Duration::from_secs(60);
71
72const SLOW_POLL: Duration = Duration::from_secs(30);
74
75#[derive(Debug, thiserror::Error)]
78pub enum Fatal {
79 #[error("{0}")]
81 Identity(String),
82 #[error(
84 "{path} was made for {made_for}, not {configured}, and a worker's key is never \
85 offered to a second server. If RECALL_WORKER_SERVER is wrong, correct it; to move \
86 this worker to {configured}, revoke it on {made_for}, delete {path} and restart"
87 )]
88 OtherServer {
89 made_for: String,
91 configured: String,
93 path: String,
95 },
96 #[error(
99 "{0} was written by an earlier recall-worker that did not record which server it \
100 enrolled with, so it cannot be checked against RECALL_WORKER_SERVER. Revoke the \
101 device it enrolled as (or deny its code), delete {0} and restart"
102 )]
103 Unbound(String),
104 #[error("{0}")]
106 NotRecall(String),
107 #[error("the owner denied this worker's enrolment. Delete {0} and restart to ask again")]
109 Denied(String),
110 #[error(
112 "this device was approved with the {0} scope, not worker, so it cannot claim jobs. \
113 Revoke it, delete {1} and restart, then approve the new code as a worker \
114 (recall devices approve <code> --worker)"
115 )]
116 WrongScope(String, String),
117 #[error(
119 "the server refused this worker ({0}). If it was revoked on purpose, nothing is wrong; \
120 to enrol again, delete {1} and restart"
121 )]
122 Refused(String, String),
123 #[error("{0}")]
126 Other(String),
127}
128
129#[derive(Debug, Clone, PartialEq, Eq)]
131pub enum Step {
132 Idle,
134 Done(ResultResponse),
136 Rejected(String),
139}
140
141pub struct Worker {
143 cfg: Config,
144 api: Api,
145 id: Identity,
146 merger: Merger,
147 status: Status,
148 status_at: Option<Instant>,
149 announced: Option<String>,
151}
152
153fn log(message: &str) {
154 eprintln!("recall-worker: {message}");
155}
156
157fn is_final(e: &ApiError) -> bool {
161 e.status().is_some_and(|s| {
162 s.is_redirection()
163 || (s.is_client_error()
164 && s != StatusCode::REQUEST_TIMEOUT
165 && s != StatusCode::TOO_MANY_REQUESTS)
166 })
167}
168
169fn next_wait(e: &ApiError, backoff: Duration) -> Duration {
172 match e {
173 ApiError::Status {
174 retry_after: Some(s),
175 ..
176 } => Duration::from_secs(*s),
177 _ => (backoff * 2).clamp(Duration::from_secs(1), MAX_BACKOFF),
178 }
179}
180
181impl Worker {
182 pub fn new(cfg: Config) -> Result<Self, Fatal> {
186 if cfg.data_dir.as_os_str().is_empty() {
187 return Err(Fatal::Other(
188 "no data directory: set RECALL_WORKER_DIR to where the worker's key is kept".into(),
189 ));
190 }
191 let path = Identity::path(&cfg.data_dir).display().to_string();
192 let mut id = Identity::load_or_create(&cfg.data_dir, &cfg.server)
193 .map_err(|e| Fatal::Identity(format!("cannot use {path}: {e}")))?;
194 match id.server.as_deref() {
195 Some(server) if server == cfg.server => {}
196 Some(server) => {
197 return Err(Fatal::OtherServer {
198 made_for: server.to_string(),
199 configured: cfg.server.clone(),
200 path,
201 })
202 }
203 None if id.device_id.is_none() && id.enrollment_id.is_none() => {
206 id.server = Some(cfg.server.clone());
207 id.save(&cfg.data_dir)
208 .map_err(|e| Fatal::Identity(format!("cannot write {path}: {e}")))?;
209 }
210 None => return Err(Fatal::Unbound(path)),
211 }
212 let api = Api::new(&cfg.server).map_err(|e| Fatal::Other(e.to_string()))?;
213 let merger = Merger::new(cfg.claude_bin.clone(), cfg.merge_timeout);
214 Ok(Self {
215 cfg,
216 api,
217 id,
218 merger,
219 status: Status::default(),
220 status_at: None,
221 announced: None,
222 })
223 }
224
225 pub fn identity(&self) -> &Identity {
227 &self.id
228 }
229
230 fn identity_path(&self) -> String {
231 Identity::path(&self.cfg.data_dir).display().to_string()
232 }
233
234 fn save(&self) -> Result<(), Fatal> {
235 self.id
236 .save(&self.cfg.data_dir)
237 .map_err(|e| Fatal::Identity(format!("cannot write {}: {e}", self.identity_path())))
238 }
239
240 pub async fn run(mut self, shutdown: impl Future<Output = ()>) -> Result<(), Fatal> {
244 tokio::pin!(shutdown);
245 log(&format!(
246 "{} for {}, key fingerprint {}",
247 crate::user_agent(),
248 self.cfg.server,
249 self.id.fingerprint()
250 ));
251 if let Some(warning) = self.cfg.plaintext_warning() {
252 log(&warning);
253 }
254 tokio::select! {
255 _ = &mut shutdown => return Ok(()),
256 checked = self.check_server() => checked?,
257 }
258 if self.id.device_id.is_none() {
259 tokio::select! {
260 _ = &mut shutdown => return Ok(()),
261 enrolled = self.enrol() => enrolled?,
262 }
263 }
264 log(&format!(
265 "enrolled as {}; waiting for jobs",
266 self.id.device_id.as_deref().unwrap_or("")
267 ));
268 let mut backoff = Duration::ZERO;
269 loop {
270 if !backoff.is_zero() {
271 tokio::select! {
272 _ = &mut shutdown => return Ok(()),
273 _ = tokio::time::sleep(backoff) => {}
274 }
275 }
276 let step = tokio::select! {
277 _ = &mut shutdown => return Ok(()),
278 step = self.step() => step,
279 };
280 backoff = match step {
281 Ok(Step::Idle) => Duration::ZERO,
282 Ok(Step::Done(outcome)) => {
283 log(&format!(
284 "job {}: {}{}",
285 outcome.id,
286 outcome.state,
287 match (outcome.applied, &outcome.follow_up) {
288 (true, _) => ", merged file stored".to_string(),
289 (false, Some(next)) => {
290 format!(", the file changed meanwhile; follow-up {next}")
291 }
292 (false, None) => String::new(),
293 }
294 ));
295 Duration::ZERO
296 }
297 Ok(Step::Rejected(why)) => {
298 log(&why);
299 Duration::ZERO
300 }
301 Err(e) => {
302 if let Some(fatal) = self.fatal(&e) {
303 return Err(fatal);
304 }
305 if is_final(&e) && e.status() != Some(StatusCode::UNAUTHORIZED) {
310 return Err(Fatal::NotRecall(format!(
311 "{} refused a claim ({e}), and asking again will not change that. \
312 If the server was rolled back to a release without the merge \
313 queue, restart the worker once it has the queue again",
314 self.cfg.server
315 )));
316 }
317 let wait = next_wait(&e, backoff);
318 log(&format!("claim failed ({e}); trying again in {wait:?}"));
319 wait
320 }
321 };
322 }
323 }
324
325 fn fatal(&self, e: &ApiError) -> Option<Fatal> {
327 match e.status()? {
328 StatusCode::UNAUTHORIZED
332 if e.message().contains("unknown device") || e.message().contains("revoked") =>
333 {
334 Some(Fatal::Refused(
335 e.message().to_string(),
336 self.identity_path(),
337 ))
338 }
339 StatusCode::FORBIDDEN => Some(Fatal::Refused(
340 e.message().to_string(),
341 self.identity_path(),
342 )),
343 _ => None,
344 }
345 }
346
347 pub async fn check_server(&self) -> Result<(), Fatal> {
352 let server = &self.cfg.server;
353 let mut backoff = Duration::ZERO;
354 loop {
355 let answer: Result<Discovery, ApiError> =
356 self.api.get(DISCOVERY_PATH, UNSIGNED_TIMEOUT).await;
357 match answer {
358 Ok(doc) if doc.can(CAPABILITY_MERGE_QUEUE) => return Ok(()),
359 Ok(doc) => {
360 return Err(Fatal::NotRecall(format!(
361 "{server} is Recall {}, which has no merge queue, so there is nothing \
362 for a worker to do. Upgrade it, or stop the worker",
363 doc.server.version
364 )))
365 }
366 Err(ApiError::Body(why)) => {
367 return Err(Fatal::NotRecall(format!(
368 "{server} did not answer {DISCOVERY_PATH} with Recall's discovery \
369 document ({why}); RECALL_WORKER_SERVER must name a Recall server"
370 )))
371 }
372 Err(e) if is_final(&e) => {
373 return Err(Fatal::NotRecall(format!(
374 "{server} answered {DISCOVERY_PATH} with {e}: it is not a Recall server, \
375 or one older than 0.4.1, which has no merge queue. Check \
376 RECALL_WORKER_SERVER"
377 )))
378 }
379 Err(e) => {
380 backoff = next_wait(&e, backoff);
381 log(&format!(
382 "cannot reach {server} ({e}); trying again in {backoff:?}"
383 ));
384 tokio::time::sleep(backoff).await;
385 }
386 }
387 }
388 }
389
390 pub async fn enrol(&mut self) -> Result<(), Fatal> {
394 loop {
395 match self.id.user_code.clone() {
396 Some(code) if self.id.enrollment_id.is_some() => self.announce(&code),
397 _ => self.start_enrolment().await?,
398 }
399 match self.wait_for_approval().await? {
400 Some(device_id) => {
401 self.id.device_id = Some(device_id);
402 self.id.enrollment_id = None;
403 self.id.user_code = None;
404 self.save()?;
405 return Ok(());
406 }
407 None => {
410 self.id.enrollment_id = None;
411 self.id.user_code = None;
412 self.save()?;
413 }
414 }
415 }
416 }
417
418 async fn start_enrolment(&mut self) -> Result<(), Fatal> {
419 let req = EnrollRequest {
420 name: self.cfg.name.clone(),
421 public_key: self.id.public_key(),
422 agent: crate::user_agent(),
423 authkey: None,
424 };
425 let mut backoff = Duration::ZERO;
426 let pending: EnrollPending = loop {
427 match self.api.post(ENROLL_PATH, &req, UNSIGNED_TIMEOUT).await {
428 Ok(pending) => break pending,
429 Err(e) if e.status() == Some(StatusCode::CONFLICT) => {
430 return Err(Fatal::Other(format!(
431 "{}. Set RECALL_WORKER_NAME to another name, or revoke the old worker",
432 e.message()
433 )))
434 }
435 Err(e) if is_final(&e) => {
438 return Err(Fatal::Other(format!(
439 "{} refused the enrolment ({e}); asking again would be refused \
440 the same way",
441 self.cfg.server
442 )))
443 }
444 Err(e) => {
445 backoff = next_wait(&e, backoff);
446 log(&format!(
447 "cannot reach the server to enrol ({e}); trying again in {backoff:?}"
448 ));
449 tokio::time::sleep(backoff).await;
450 }
451 }
452 };
453 self.id.enrollment_id = Some(pending.enrollment_id);
454 self.id.user_code = Some(pending.user_code.clone());
455 self.save()?;
456 self.announce(&pending.user_code);
457 Ok(())
458 }
459
460 fn announce(&mut self, code: &str) {
462 if self.announced.as_deref() == Some(code) {
463 return;
464 }
465 self.announced = Some(code.to_string());
466 log(&format!(
467 "waiting for approval of code {code} as a worker, key fingerprint {}",
468 self.id.fingerprint()
469 ));
470 log(&format!(
471 "approve it from an admin device: recall devices approve {code} --worker \
472 --fingerprint {}",
473 self.id.fingerprint()
474 ));
475 log(&format!(
476 "or with the operator token: POST /v1/devices/approve \
477 {{\"user_code\":\"{code}\",\"scope\":\"worker\",\"fingerprint\":\"{}\"}} \
478 (see deploy/README.md, \"The merge worker\")",
479 self.id.fingerprint()
480 ));
481 }
482
483 fn poll_interval(since: Duration, slowed: Duration) -> Duration {
486 let base = if since < FAST_POLL_FOR {
487 Duration::from_secs(POLL_INTERVAL_SECONDS)
488 } else {
489 SLOW_POLL
490 };
491 base + slowed
492 }
493
494 async fn wait_for_approval(&mut self) -> Result<Option<String>, Fatal> {
497 let Some(enrollment_id) = self.id.enrollment_id.clone() else {
498 return Ok(None);
499 };
500 let since = Instant::now();
501 let mut slowed = Duration::ZERO;
502 let req = EnrollPollRequest { enrollment_id };
503 loop {
504 tokio::time::sleep(Self::poll_interval(since.elapsed(), slowed)).await;
505 let polled: Result<EnrollPollResponse, ApiError> = self
506 .api
507 .post(ENROLL_POLL_PATH, &req, UNSIGNED_TIMEOUT)
508 .await;
509 match polled {
510 Ok(approved) if approved.scope == SCOPE_WORKER => {
511 return Ok(Some(approved.device_id))
512 }
513 Ok(approved) => {
514 self.id.device_id = Some(approved.device_id);
517 self.save()?;
518 return Err(Fatal::WrongScope(approved.scope, self.identity_path()));
519 }
520 Err(e) if e.status() == Some(StatusCode::BAD_REQUEST) => match e.message() {
521 AUTHORIZATION_PENDING => {}
522 SLOW_DOWN => slowed += Duration::from_secs(5),
523 EXPIRED_TOKEN | INVALID_GRANT => {
524 log("the code expired before it was approved; asking for a new one");
525 return Ok(None);
526 }
527 ACCESS_DENIED => return Err(Fatal::Denied(self.identity_path())),
528 other => log(&format!("poll refused: {other}")),
529 },
530 Err(e) if is_final(&e) => {
531 return Err(Fatal::Other(format!(
532 "{} refused the poll for this worker's approval ({e})",
533 self.cfg.server
534 )))
535 }
536 Err(e) => log(&format!("poll failed ({e}); trying again")),
537 }
538 }
539 }
540
541 async fn refresh_status(&mut self) {
544 let every = if self.status.logged_in {
545 self.cfg.claude_status_interval
546 } else {
547 self.cfg.claude_status_interval.min(NOT_LOGGED_IN_RECHECK)
548 };
549 if self.status_at.is_some_and(|at| at.elapsed() < every) {
550 return;
551 }
552 let (was, first) = (self.status.logged_in, self.status.checked_at.is_empty());
554 self.status = self.merger.check_status().await;
555 self.status_at = Some(Instant::now());
556 if first || self.status.logged_in != was {
557 log(&if self.status.logged_in {
558 "the claude CLI is logged in; taking merge jobs".to_string()
559 } else {
560 format!(
561 "the claude CLI cannot merge ({}); taking no jobs until it can. \
562 Log it in with: docker compose exec -it -u node recall-worker claude setup-token",
563 if self.status.error.is_empty() {
564 "not logged in"
565 } else {
566 &self.status.error
567 }
568 )
569 });
570 }
571 }
572
573 pub fn claim_request(&self) -> ClaimRequest {
576 ClaimRequest {
577 kinds: if self.status.logged_in {
578 vec![KIND_MERGE.to_string()]
579 } else {
580 Vec::new()
581 },
582 wait_seconds: self.cfg.wait_seconds,
583 lease_seconds: self.cfg.lease_seconds,
584 claude_cli: Some(ClaudeCliReport {
585 checked_at: self.status.checked_at.clone(),
586 available: self.status.available,
587 logged_in: self.status.logged_in,
588 error: self.status.error.clone(),
589 }),
590 }
591 }
592
593 pub async fn step(&mut self) -> Result<Step, ApiError> {
595 self.refresh_status().await;
596 let device_id = self.id.device_id.clone().unwrap_or_default();
597 let claim = self.claim_request();
598 let answer: ClaimResponse = self
599 .api
600 .post_signed(
601 jobs::CLAIM_PATH,
602 &claim,
603 self.id.key(),
604 &device_id,
605 Duration::from_secs(self.cfg.wait_seconds + 30),
606 )
607 .await?;
608 let Some(job) = answer.job else {
609 return Ok(Step::Idle);
610 };
611 let result = self.work(&job).await;
612 self.report(&job, &result).await
613 }
614
615 pub async fn work(&mut self, job: &Job) -> ResultRequest {
621 let outcome = match (job.kind.as_str(), &job.merge) {
622 (KIND_MERGE, Some(m)) => {
623 if recall_wire::content_sha256(&m.stored.content) != m.stored.sha256
624 || recall_wire::content_sha256(&m.incoming.content) != m.incoming.sha256
625 {
626 Err("a version's content does not match its sha256".to_string())
627 } else if m.stored.content == m.incoming.content {
628 Ok(m.incoming.content.clone())
631 } else {
632 log(&format!(
633 "job {}: merging {}/{} (attempt {})",
634 job.id, m.project_key, m.file_path, job.attempt
635 ));
636 let merged = self
637 .merger
638 .merge(&m.stored.content, &m.incoming.content)
639 .await;
640 if merged.is_err() {
641 self.status_at = None;
642 }
643 merged.map_err(|e| e.to_string())
644 }
645 }
646 (kind, _) => Err(format!("this worker does not do {kind} jobs")),
647 };
648 match outcome {
649 Ok(content) => ResultRequest {
650 lease_id: job.lease_id.clone(),
651 merge: Some(MergeResult { content }),
652 error: None,
653 },
654 Err(error) => {
655 log(&format!("job {}: {error}", job.id));
656 ResultRequest {
657 lease_id: job.lease_id.clone(),
658 merge: None,
659 error: Some(error),
660 }
661 }
662 }
663 }
664
665 async fn report(&self, job: &Job, result: &ResultRequest) -> Result<Step, ApiError> {
668 let device_id = self.id.device_id.clone().unwrap_or_default();
669 let mut wait = Duration::from_secs(1);
670 let mut last = None;
671 for _ in 0..RESULT_TRIES {
672 let posted: Result<ResultResponse, ApiError> = self
673 .api
674 .post_signed(
675 &jobs::result_path(&job.id),
676 result,
677 self.id.key(),
678 &device_id,
679 RESULT_TIMEOUT,
680 )
681 .await;
682 match posted {
683 Ok(outcome) => return Ok(Step::Done(outcome)),
684 Err(e)
685 if matches!(
686 e.status(),
687 Some(
688 StatusCode::CONFLICT | StatusCode::NOT_FOUND | StatusCode::BAD_REQUEST
689 )
690 ) =>
691 {
692 return Ok(Step::Rejected(format!(
693 "job {}: the server did not take the result: {}",
694 job.id,
695 e.message()
696 )))
697 }
698 Err(e) if self.fatal(&e).is_some() => return Err(e),
699 Err(e) => {
700 log(&format!(
701 "job {}: posting the result failed ({e}); trying again in {wait:?}",
702 job.id
703 ));
704 last = Some(e);
705 tokio::time::sleep(wait).await;
706 wait *= 2;
707 }
708 }
709 }
710 Err(last.unwrap_or_else(|| ApiError::Transport("no attempt was made".into())))
711 }
712}
713
714#[cfg(test)]
715mod tests {
716 use super::*;
717 use recall_wire::{MergeInput, MergeSide};
718 use tokio::io::{AsyncReadExt, AsyncWriteExt};
719
720 fn config(dir: &std::path::Path, server: &str) -> Config {
721 Config {
722 server: server.to_string(),
723 data_dir: dir.to_path_buf(),
724 claude_bin: "definitely-not-a-real-claude".into(),
726 ..Config::default()
727 }
728 }
729
730 fn side(content: &str) -> MergeSide {
731 MergeSide {
732 sha256: recall_wire::content_sha256(content),
733 content: content.to_string(),
734 source_env: "laptop".into(),
735 updated_at: "2026-10-02T09:10:11.020Z".into(),
736 }
737 }
738
739 fn job(stored: MergeSide, incoming: MergeSide) -> Job {
740 Job {
741 id: "job_1".into(),
742 kind: KIND_MERGE.into(),
743 lease_id: "lse_1".into(),
744 lease_expires_at: "2026-10-02T09:16:03.118Z".into(),
745 attempt: 1,
746 merge: Some(MergeInput {
747 project_key: "acme/app".into(),
748 file_path: "topics/auth.md".into(),
749 stored,
750 incoming,
751 }),
752 }
753 }
754
755 fn worker() -> (tempfile::TempDir, Worker) {
758 let dir = tempfile::tempdir().unwrap();
759 let w = Worker::new(config(dir.path(), "http://127.0.0.1:9")).unwrap();
760 (dir, w)
761 }
762
763 #[tokio::test]
767 async fn a_version_that_does_not_match_its_hash_is_not_merged() {
768 let (_dir, mut w) = worker();
769 let mut stored = side("A");
770 stored.sha256 = recall_wire::content_sha256("not A");
771 let mut incoming = side("B");
772 incoming.sha256 = recall_wire::content_sha256("not B");
773 for (s, i) in [
774 (stored.clone(), side("B")),
775 (side("A"), incoming.clone()),
776 (stored.clone(), {
779 let mut same = side("A");
780 same.sha256 = stored.sha256.clone();
781 same
782 }),
783 ] {
784 let result = w.work(&job(s, i)).await;
785 assert!(result.merge.is_none(), "{result:?}");
786 assert!(
787 result.error.as_deref().unwrap().contains("sha256"),
788 "{result:?}"
789 );
790 assert_eq!(result.lease_id, "lse_1");
791 }
792 let result = w.work(&job(side("A"), side("A"))).await;
794 assert_eq!(result.merge.unwrap().content, "A");
795 }
796
797 #[test]
800 fn a_worker_that_cannot_merge_claims_nothing() {
801 let (_dir, mut w) = worker();
802 assert!(w.claim_request().kinds.is_empty(), "never checked");
803 w.status = Status {
804 checked_at: "2026-10-02T09:13:40.002Z".into(),
805 available: true,
806 logged_in: false,
807 error: "not logged in".into(),
808 };
809 let claim = w.claim_request();
810 assert!(claim.kinds.is_empty());
811 assert_eq!(claim.claude_cli.unwrap().error, "not logged in");
812 w.status.logged_in = true;
813 assert_eq!(w.claim_request().kinds, vec![KIND_MERGE.to_string()]);
814 }
815
816 #[tokio::test]
818 async fn a_failed_merge_has_the_cli_checked_again() {
819 let (_dir, mut w) = worker();
820 w.status.logged_in = true;
821 w.status_at = Some(Instant::now());
822 let result = w.work(&job(side("A"), side("B"))).await;
823 assert!(result.error.unwrap().contains("unavailable"));
824 assert!(w.status_at.is_none());
825 }
826
827 #[test]
828 fn a_code_is_polled_quickly_for_a_minute_then_slowly() {
829 let fast = Duration::from_secs(POLL_INTERVAL_SECONDS);
830 assert_eq!(Worker::poll_interval(Duration::ZERO, Duration::ZERO), fast);
831 assert_eq!(
832 Worker::poll_interval(Duration::from_secs(59), Duration::from_secs(5)),
833 fast + Duration::from_secs(5)
834 );
835 assert_eq!(
836 Worker::poll_interval(Duration::from_secs(61), Duration::ZERO),
837 SLOW_POLL
838 );
839 assert!(SLOW_POLL >= Duration::from_secs(30));
840 }
841
842 #[test]
844 fn an_identity_is_bound_to_its_server() {
845 let dir = tempfile::tempdir().unwrap();
846 Worker::new(config(dir.path(), "http://recall-server:8787")).unwrap();
847 match Worker::new(config(dir.path(), "https://recall.example.com")) {
848 Err(Fatal::OtherServer {
849 made_for,
850 configured,
851 ..
852 }) => {
853 assert_eq!(made_for, "http://recall-server:8787");
854 assert_eq!(configured, "https://recall.example.com");
855 }
856 other => panic!("not refused: {:?}", other.err()),
857 }
858 assert!(Worker::new(config(dir.path(), "http://recall-server:8787")).is_ok());
859 }
860
861 #[test]
864 fn an_identity_from_before_servers_were_recorded() {
865 let dir = tempfile::tempdir().unwrap();
866 let mut id = Identity::from_seed([3; 32]);
867 id.save(dir.path()).unwrap();
868 Worker::new(config(dir.path(), "http://recall-server:8787")).unwrap();
869 assert_eq!(
870 Identity::load(dir.path())
871 .unwrap()
872 .unwrap()
873 .server
874 .as_deref(),
875 Some("http://recall-server:8787")
876 );
877
878 let dir = tempfile::tempdir().unwrap();
879 id.device_id = Some("dev_somewhere".into());
880 id.save(dir.path()).unwrap();
881 assert!(matches!(
882 Worker::new(config(dir.path(), "http://recall-server:8787")),
883 Err(Fatal::Unbound(_))
884 ));
885 }
886
887 #[test]
888 fn a_worker_needs_a_data_directory() {
889 assert!(matches!(
890 Worker::new(Config {
891 server: "http://127.0.0.1:9".into(),
892 ..Config::default()
893 }),
894 Err(Fatal::Other(_))
895 ));
896 }
897
898 async fn fake_server(
901 answer: fn(&str, &str) -> (u16, String),
902 ) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
903 use std::sync::atomic::{AtomicUsize, Ordering};
904 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
905 let url = format!("http://{}", listener.local_addr().unwrap());
906 let count = std::sync::Arc::new(AtomicUsize::new(0));
907 let counted = count.clone();
908 tokio::spawn(async move {
909 while let Ok((mut conn, _)) = listener.accept().await {
910 let counted = counted.clone();
911 tokio::spawn(async move {
912 let mut buf = Vec::new();
913 let mut chunk = [0u8; 4096];
914 let head_end = loop {
916 let n = conn.read(&mut chunk).await.unwrap_or(0);
917 if n == 0 {
918 return;
919 }
920 buf.extend_from_slice(&chunk[..n]);
921 if let Some(i) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
922 break i + 4;
923 }
924 };
925 let head = String::from_utf8_lossy(&buf[..head_end]).to_string();
926 let length = head
927 .lines()
928 .find_map(|l| {
929 let (k, v) = l.split_once(':')?;
930 k.eq_ignore_ascii_case("content-length")
931 .then(|| v.trim().parse::<usize>().ok())?
932 })
933 .unwrap_or(0);
934 while buf.len() < head_end + length {
935 let n = conn.read(&mut chunk).await.unwrap_or(0);
936 if n == 0 {
937 break;
938 }
939 buf.extend_from_slice(&chunk[..n]);
940 }
941 let mut first = head.lines().next().unwrap_or_default().split(' ');
942 let (method, path) = (first.next().unwrap_or(""), first.next().unwrap_or(""));
943 counted.fetch_add(1, Ordering::SeqCst);
944 let (status, body) = answer(method, path);
945 let resp = format!(
946 "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\n\
947 content-length: {}\r\nconnection: close\r\n\r\n{body}",
948 body.len()
949 );
950 let _ = conn.write_all(resp.as_bytes()).await;
951 let _ = conn.shutdown().await;
952 });
953 }
954 });
955 (url, count)
956 }
957
958 fn discovery(capabilities: &str) -> String {
959 format!(
960 r#"{{"protocol":{{"current":1,"supported":[1]}},"server":{{"version":"0.4.0","build":{{"channel":"release"}}}},"min_client":"0.1.0","auth":{{"methods":["bearer"]}},"capabilities":{{{capabilities}}}}}"#
961 )
962 }
963
964 async fn run_briefly(url: &str) -> Result<(), Fatal> {
966 let dir = tempfile::tempdir().unwrap();
967 let w = Worker::new(config(dir.path(), url)).unwrap();
968 tokio::time::timeout(Duration::from_secs(10), w.run(std::future::pending()))
969 .await
970 .expect("the worker kept retrying something that will not change")
971 }
972
973 #[tokio::test]
976 async fn a_server_without_the_merge_queue_is_never_enrolled_with() {
977 let (url, count) = fake_server(|_, path| match path {
978 DISCOVERY_PATH => (404, r#"{"error":"not found"}"#.into()),
979 _ => (500, "{}".into()),
980 })
981 .await;
982 assert!(matches!(run_briefly(&url).await, Err(Fatal::NotRecall(_))));
983 assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), 1);
984
985 let (url, count) = fake_server(|_, path| match path {
986 DISCOVERY_PATH => (200, discovery(r#""devices":{}"#)),
987 _ => (500, "{}".into()),
988 })
989 .await;
990 match run_briefly(&url).await {
991 Err(Fatal::NotRecall(why)) => assert!(why.contains("no merge queue"), "{why}"),
992 other => panic!("not refused: {other:?}"),
993 }
994 assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), 1);
995
996 let (url, _) = fake_server(|_, _| (200, "<html>hello</html>".into())).await;
997 assert!(matches!(run_briefly(&url).await, Err(Fatal::NotRecall(_))));
998 }
999
1000 #[tokio::test]
1003 async fn an_enrolment_refused_with_404_is_final() {
1004 let (url, count) = fake_server(|_, path| match path {
1005 DISCOVERY_PATH => (200, discovery(r#""merge_queue":{}"#)),
1006 _ => (404, r#"{"error":"not found"}"#.into()),
1007 })
1008 .await;
1009 match run_briefly(&url).await {
1010 Err(Fatal::Other(why)) => assert!(why.contains("refused the enrolment"), "{why}"),
1011 other => panic!("not refused: {other:?}"),
1012 }
1013 assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), 2);
1014 }
1015}