1use std::{
2 net::ToSocketAddrs,
3 sync::{Arc, Mutex, OnceLock, mpsc},
4 thread,
5 time::Duration,
6};
7
8use objects::{
9 error::HeddleError,
10 object::{ContentHash, StateId, ThreadName},
11};
12use repo::{BlobHydrator, Repository};
13use wire::ProtocolError;
14
15use super::{HostedGrpcClient, HostedSession};
16
17const DEFAULT_HOSTED_HYDRATION_TIMEOUT: Duration = Duration::from_secs(30);
23
24#[derive(Clone, Copy, Debug, Eq, PartialEq)]
25pub enum PullMaterialization {
26 Full,
27 Lazy,
28}
29
30impl PullMaterialization {
31 pub(crate) fn allows_partial_fetch(self) -> bool {
32 matches!(self, Self::Lazy)
33 }
34}
35
36impl HostedGrpcClient {
37 pub async fn hydrate_pulled_state(
38 &mut self,
39 repo: &Repository,
40 repo_path: &str,
41 remote_thread: &str,
42 target_state: StateId,
43 ) -> Result<usize, ProtocolError> {
44 self.hydrate_missing_blobs_for_state(repo, repo_path, remote_thread, target_state)
45 .await
46 }
47}
48
49pub struct LazyHostedHydrator {
74 endpoint: String,
80 repo_path: String,
81 remote_thread: String,
82 local_thread: String,
86 bridge: OnceLock<HydrationBridge>,
87 init_lock: Mutex<()>,
92}
93
94impl LazyHostedHydrator {
95 pub fn new(
96 endpoint: impl Into<String>,
97 repo_path: impl Into<String>,
98 remote_thread: impl Into<String>,
99 local_thread: impl Into<String>,
100 ) -> Self {
101 Self {
102 endpoint: endpoint.into(),
103 repo_path: repo_path.into(),
104 remote_thread: remote_thread.into(),
105 local_thread: local_thread.into(),
106 bridge: OnceLock::new(),
107 init_lock: Mutex::new(()),
108 }
109 }
110
111 fn ensure_bridge(&self) -> objects::error::Result<&HydrationBridge> {
112 if let Some(bridge) = self.bridge.get() {
113 return Ok(bridge);
114 }
115 let _guard = self.init_lock.lock().unwrap_or_else(|poison| {
118 poison.into_inner()
123 });
124 if let Some(bridge) = self.bridge.get() {
125 return Ok(bridge);
126 }
127
128 let bridge = HydrationBridge::connect(&self.endpoint)?;
129 self.bridge.set(bridge).map_err(|_| {
131 HeddleError::Config(
132 "lazy hosted hydrator: bridge slot already filled under init_lock — \
133 this indicates a logic bug in LazyHostedHydrator"
134 .to_string(),
135 )
136 })?;
137 Ok(self.bridge.get().expect("just set under init_lock"))
138 }
139}
140
141impl BlobHydrator for LazyHostedHydrator {
142 fn hydrate(&self, repo: &Repository, _hash: &ContentHash) -> objects::error::Result<()> {
143 let target_state = match repo
154 .refs()
155 .get_thread(&ThreadName::from(self.local_thread.as_str()))
156 {
157 Ok(Some(id)) => id,
158 Ok(None) => {
159 return Err(HeddleError::Config(format!(
160 "lazy hosted hydrator: local thread '{}' has no recorded tip — \
161 was the lazy clone interrupted? Try `heddle pull --lazy` to refresh.",
162 self.local_thread,
163 )));
164 }
165 Err(err) => {
166 return Err(HeddleError::Config(format!(
167 "lazy hosted hydrator: failed to read local thread '{}': {err}",
168 self.local_thread,
169 )));
170 }
171 };
172
173 let bridge = self.ensure_bridge()?;
174 bridge
175 .hydrate(repo, &self.repo_path, &self.remote_thread, target_state)
176 .map(|_count| ())
177 .map_err(|err| HeddleError::Io(std::io::Error::other(err.to_string())))
178 }
179}
180
181struct HydrationBridge {
191 tx: mpsc::Sender<HydrateMessage>,
192 _worker: thread::JoinHandle<()>,
195}
196
197enum HydrateMessage {
198 Run {
199 repo: Arc<Repository>,
200 repo_path: String,
201 remote_thread: String,
202 target_state: StateId,
203 reply: mpsc::SyncSender<Result<usize, ProtocolError>>,
204 },
205}
206
207impl HydrationBridge {
208 fn connect(endpoint: &str) -> objects::error::Result<Self> {
209 let addr = endpoint
212 .to_socket_addrs()
213 .map_err(|err| {
214 HeddleError::Config(format!(
215 "lazy hosted hydrator: resolve endpoint '{endpoint}': {err}",
216 ))
217 })?
218 .next()
219 .ok_or_else(|| {
220 HeddleError::Config(format!(
221 "lazy hosted hydrator: DNS returned no addresses for '{endpoint}'",
222 ))
223 })?;
224
225 let user_config = cli_shared::UserConfig::load_default().map_err(|err| {
226 HeddleError::Config(format!("lazy hosted hydrator: load user config: {err}"))
227 })?;
228 let session = HostedSession::build(&user_config, Some(endpoint.to_string())).map_err(
232 |err| {
233 HeddleError::Config(format!(
234 "lazy hosted hydrator: load TLS/auth client config: {err}"
235 ))
236 },
237 )?;
238
239 let (tx, rx) = mpsc::channel::<HydrateMessage>();
244 let (ready_tx, ready_rx) = mpsc::sync_channel::<Result<(), HeddleError>>(0);
245 let endpoint_for_thread = endpoint.to_string();
246 let worker = thread::Builder::new()
247 .name("heddle-lazy-hydrator".into())
248 .spawn(move || {
249 let runtime = match tokio::runtime::Builder::new_current_thread()
255 .enable_all()
256 .build()
257 {
258 Ok(rt) => rt,
259 Err(err) => {
260 let _ = ready_tx.send(Err(HeddleError::Config(format!(
261 "lazy hosted hydrator: build worker runtime: {err}",
262 ))));
263 return;
264 }
265 };
266
267 let connect_result = runtime.block_on(async {
268 let client = match tokio::time::timeout(
274 DEFAULT_HOSTED_HYDRATION_TIMEOUT,
275 session.connect(addr),
276 )
277 .await
278 {
279 Ok(result) => result.map_err(|err: ProtocolError| {
280 HeddleError::Config(format!(
281 "lazy hosted hydrator: connect to '{endpoint_for_thread}' \
282 (resolved to {addr}): {err}",
283 ))
284 })?,
285 Err(_) => {
286 return Err(HeddleError::Config(format!(
287 "lazy hosted hydrator: connect to '{endpoint_for_thread}' \
288 (resolved to {addr}) timed out after {}",
289 format_duration(DEFAULT_HOSTED_HYDRATION_TIMEOUT)
290 )));
291 }
292 };
293 Ok::<_, HeddleError>(client)
294 });
295 let mut client = match connect_result {
296 Ok(c) => c,
297 Err(err) => {
298 let _ = ready_tx.send(Err(err));
299 return;
300 }
301 };
302
303 if ready_tx.send(Ok(())).is_err() {
308 return;
309 }
310
311 runtime.block_on(async {
317 while let Ok(message) = rx.recv() {
318 match message {
319 HydrateMessage::Run {
320 repo,
321 repo_path,
322 remote_thread,
323 target_state,
324 reply,
325 } => {
326 let result = hydrate_with_rpc_timeout(
327 &mut client,
328 repo.as_ref(),
329 &repo_path,
330 &remote_thread,
331 target_state,
332 DEFAULT_HOSTED_HYDRATION_TIMEOUT,
333 )
334 .await;
335 let _ = reply.send(result);
336 }
337 }
338 }
339 });
340 })
341 .map_err(|err| {
342 HeddleError::Config(format!("lazy hosted hydrator: spawn worker thread: {err}",))
343 })?;
344
345 match ready_rx.recv_timeout(DEFAULT_HOSTED_HYDRATION_TIMEOUT) {
349 Ok(Ok(())) => Ok(Self {
350 tx,
351 _worker: worker,
352 }),
353 Ok(Err(err)) => Err(err),
354 Err(mpsc::RecvTimeoutError::Timeout) => Err(HeddleError::Config(format!(
355 "lazy hosted hydrator: worker did not signal readiness within {}",
356 format_duration(DEFAULT_HOSTED_HYDRATION_TIMEOUT)
357 ))),
358 Err(mpsc::RecvTimeoutError::Disconnected) => Err(HeddleError::Config(
359 "lazy hosted hydrator: worker thread exited before signalling readiness"
360 .to_string(),
361 )),
362 }
363 }
364
365 fn hydrate(
366 &self,
367 repo: &Repository,
368 repo_path: &str,
369 remote_thread: &str,
370 target_state: StateId,
371 ) -> Result<usize, ProtocolError> {
372 self.hydrate_with_timeout(
373 repo,
374 repo_path,
375 remote_thread,
376 target_state,
377 DEFAULT_HOSTED_HYDRATION_TIMEOUT,
378 )
379 }
380
381 fn hydrate_with_timeout(
382 &self,
383 repo: &Repository,
384 repo_path: &str,
385 remote_thread: &str,
386 target_state: StateId,
387 timeout: Duration,
388 ) -> Result<usize, ProtocolError> {
389 let repo = Arc::new(Repository::open(repo.root()).map_err(ProtocolError::from)?);
390
391 let (reply_tx, reply_rx) = mpsc::sync_channel::<Result<usize, ProtocolError>>(1);
394 self.tx
395 .send(HydrateMessage::Run {
396 repo,
397 repo_path: repo_path.to_string(),
398 remote_thread: remote_thread.to_string(),
399 target_state,
400 reply: reply_tx,
401 })
402 .map_err(|err| {
403 ProtocolError::Io(std::io::Error::other(format!(
404 "lazy hosted hydrator: worker channel closed: {err}",
405 )))
406 })?;
407 match reply_rx.recv_timeout(timeout) {
408 Ok(result) => result,
409 Err(mpsc::RecvTimeoutError::Timeout) => Err(hydration_timeout_error(
410 timeout,
411 repo_path,
412 remote_thread,
413 target_state,
414 )),
415 Err(mpsc::RecvTimeoutError::Disconnected) => {
416 Err(ProtocolError::Io(std::io::Error::other(
417 "lazy hosted hydrator: worker reply channel closed before hydration completed",
418 )))
419 }
420 }
421 }
422}
423
424async fn hydrate_with_rpc_timeout(
425 client: &mut HostedGrpcClient,
426 repo: &Repository,
427 repo_path: &str,
428 remote_thread: &str,
429 target_state: StateId,
430 timeout: Duration,
431) -> Result<usize, ProtocolError> {
432 match tokio::time::timeout(
433 timeout,
434 client.hydrate_pulled_state(repo, repo_path, remote_thread, target_state),
435 )
436 .await
437 {
438 Ok(result) => result,
439 Err(_) => Err(hydration_timeout_error(
440 timeout,
441 repo_path,
442 remote_thread,
443 target_state,
444 )),
445 }
446}
447
448fn hydration_timeout_error(
449 timeout: Duration,
450 repo_path: &str,
451 remote_thread: &str,
452 target_state: StateId,
453) -> ProtocolError {
454 ProtocolError::Io(std::io::Error::new(
455 std::io::ErrorKind::TimedOut,
456 format!(
457 "lazy hosted hydrator: blob hydration timed out after {} \
458 (repo={repo_path}, remote_thread={remote_thread}, target_state={target_state})",
459 format_duration(timeout)
460 ),
461 ))
462}
463
464fn format_duration(duration: Duration) -> String {
465 if duration.subsec_nanos() == 0 {
466 format!("{}s", duration.as_secs())
467 } else {
468 format!("{duration:?}")
469 }
470}
471
472pub fn register_hosted_factory() {
478 use std::{path::Path as StdPath, sync::Arc as StdArc};
479
480 use repo::lazy_hydrator::{
481 BlobHydratorFactory, HydratorSection, KIND_HOSTED, register_factory,
482 };
483
484 let factory: BlobHydratorFactory = StdArc::new(
485 |_root: &StdPath,
486 section: &HydratorSection|
487 -> objects::error::Result<StdArc<dyn BlobHydrator>> {
488 let hosted = section.hosted.as_ref().ok_or_else(|| {
489 HeddleError::Config(
490 "lazy hosted hydrator: lazy-hydrator.toml has kind=\"hosted\" \
491 but no [hydrator.hosted] table was found"
492 .to_string(),
493 )
494 })?;
495 Ok(StdArc::new(LazyHostedHydrator::new(
496 hosted.endpoint.clone(),
497 hosted.repo_path.clone(),
498 hosted.remote_thread.clone(),
499 hosted.local_thread.clone(),
500 )))
501 },
502 );
503 register_factory(KIND_HOSTED, factory);
504}
505
506#[cfg(test)]
507mod tests {
508 use std::{
516 sync::{
517 Arc,
518 atomic::{AtomicUsize, Ordering},
519 mpsc,
520 },
521 thread,
522 time::{Duration, Instant},
523 };
524
525 use cli_shared::ClientConfig;
526 use grpc::heddle::api::v1alpha1::{
527 collaboration_service_client::CollaborationServiceClient,
528 state_review_service_client::StateReviewServiceClient,
529 identity_service_client::IdentityServiceClient,
530 registry_service_client::RegistryServiceClient,
531 repo_sync_service_client::RepoSyncServiceClient,
532 repository_service_client::RepositoryServiceClient,
533 workflow_service_client::WorkflowServiceClient,
534 };
535 use objects::object::{Blob, StateId, ThreadName};
536 use repo::Repository;
537 use tempfile::TempDir;
538 use tonic::transport::Endpoint;
539
540 use super::{
541 super::{HostedGrpcClient, helpers::HostedTransportPolicy},
542 BlobHydrator, HydrationBridge, LazyHostedHydrator,
543 };
544
545 fn fabricate_offline_client() -> HostedGrpcClient {
549 let endpoint = Endpoint::from_static("http://127.0.0.1:1");
550 let channel = endpoint.connect_lazy();
551 let config = ClientConfig::default();
552 let transport = HostedTransportPolicy::from_client_config(&config);
553 HostedGrpcClient {
554 inner: RepoSyncServiceClient::new(channel.clone()),
555 user: RegistryServiceClient::new(channel.clone()),
556 auth: IdentityServiceClient::new(channel.clone()),
557 content: RepositoryServiceClient::new(channel.clone()),
558 workflow: WorkflowServiceClient::new(channel.clone()),
559 collaboration: CollaborationServiceClient::new(channel.clone()),
560 review: StateReviewServiceClient::new(channel),
561 token_header: None,
562 transport,
563 auth_proof_key_pem: None,
564 authenticated_principal: None,
565 server_key: None,
566 on_human_signature: None,
567 }
568 }
569
570 fn temp_repo() -> (TempDir, Repository) {
573 let temp = TempDir::new().expect("temp");
574 let repo = Repository::init_default(temp.path()).expect("init heddle repo");
575 (temp, repo)
576 }
577
578 fn offline_bridge() -> HydrationBridge {
581 let (tx, rx) = mpsc::channel::<super::HydrateMessage>();
582 let worker = thread::Builder::new()
583 .name("test-lazy-hydrator".into())
584 .spawn(move || {
585 let runtime = tokio::runtime::Builder::new_current_thread()
586 .enable_all()
587 .build()
588 .expect("worker runtime");
589 let mut client = runtime.block_on(async { fabricate_offline_client() });
590 runtime.block_on(async {
591 while let Ok(message) = rx.recv() {
592 match message {
593 super::HydrateMessage::Run {
594 repo,
595 repo_path,
596 remote_thread,
597 target_state,
598 reply,
599 } => {
600 let result = client
601 .hydrate_pulled_state(
602 repo.as_ref(),
603 &repo_path,
604 &remote_thread,
605 target_state,
606 )
607 .await;
608 let _ = reply.send(result);
609 }
610 }
611 }
612 });
613 })
614 .expect("spawn test worker");
615 HydrationBridge {
616 tx,
617 _worker: worker,
618 }
619 }
620
621 fn offline_lazy_hydrator(local_thread: &str) -> LazyHostedHydrator {
625 let hydrator = LazyHostedHydrator::new(
626 "ignored.example.test:443",
627 "org/acme/repo",
628 "main",
629 local_thread,
630 );
631 hydrator
632 .bridge
633 .set(offline_bridge())
634 .map_err(|_| ())
635 .expect("set bridge");
636 hydrator
637 }
638
639 #[test]
644 fn hydrate_safe_from_tokio_main_context() {
645 let runtime = tokio::runtime::Builder::new_multi_thread()
646 .worker_threads(2)
647 .enable_all()
648 .build()
649 .expect("multi-thread runtime");
650 runtime.block_on(async {
651 let (_temp, repo) = temp_repo();
652 let target = repo
653 .refs()
654 .get_thread(&ThreadName::from("main"))
655 .unwrap()
656 .unwrap();
657 let _ = target;
660
661 let hydrator = offline_lazy_hydrator("main");
662 let blake3 = Blob::new(b"placeholder".to_vec()).hash();
663 let err = hydrator
667 .hydrate(&repo, &blake3)
668 .expect_err("offline endpoint must produce an error");
669 assert!(!err.to_string().is_empty(), "must surface a real error");
670 });
671 }
672
673 #[test]
677 fn hydrate_safe_from_blocking_context() {
678 let (_temp, repo) = temp_repo();
679 let hydrator = offline_lazy_hydrator("main");
680 let blake3 = Blob::new(b"placeholder".to_vec()).hash();
681 let err = hydrator
682 .hydrate(&repo, &blake3)
683 .expect_err("offline endpoint must produce an error");
684 assert!(!err.to_string().is_empty(), "must surface a real error");
685 }
686
687 #[test]
694 fn hydrate_after_thread_advance_uses_new_state() {
695 let recorded: Arc<std::sync::Mutex<Vec<StateId>>> =
700 Arc::new(std::sync::Mutex::new(Vec::new()));
701 let recorded_for_worker = Arc::clone(&recorded);
702 let (tx, rx) = mpsc::channel::<super::HydrateMessage>();
703 let worker = thread::Builder::new()
704 .name("inspect-hydrator".into())
705 .spawn(move || {
706 while let Ok(message) = rx.recv() {
707 match message {
708 super::HydrateMessage::Run {
709 target_state,
710 reply,
711 ..
712 } => {
713 recorded_for_worker.lock().unwrap().push(target_state);
714 let _ = reply.send(Err(wire::ProtocolError::Io(
715 std::io::Error::other("simulated"),
716 )));
717 }
718 }
719 }
720 })
721 .expect("spawn inspect worker");
722 let bridge = HydrationBridge {
723 tx,
724 _worker: worker,
725 };
726
727 let hydrator =
728 LazyHostedHydrator::new("ignored.example.test:443", "org/acme/repo", "main", "main");
729 hydrator.bridge.set(bridge).map_err(|_| ()).expect("set");
730
731 let (_temp, repo) = temp_repo();
732 let first_tip = repo
733 .refs()
734 .get_thread(&ThreadName::from("main"))
735 .unwrap()
736 .unwrap();
737
738 let blake3 = Blob::new(b"a".to_vec()).hash();
740 let _ = hydrator.hydrate(&repo, &blake3);
741
742 let advanced = StateId::from_bytes([1; 32]);
744 assert_ne!(advanced, first_tip, "fresh StateId must differ");
745 repo.refs()
746 .set_thread(&ThreadName::from("main"), &advanced)
747 .expect("advance");
748
749 let _ = hydrator.hydrate(&repo, &blake3);
752
753 let seen = recorded.lock().unwrap().clone();
754 assert_eq!(seen.len(), 2, "two hydrate calls = two recorded states");
755 assert_eq!(seen[0], first_tip, "first call uses original tip");
756 assert_eq!(
757 seen[1], advanced,
758 "second call MUST re-resolve to the advanced tip"
759 );
760 }
761
762 #[test]
769 fn concurrent_first_use_no_race() {
770 const N: usize = 8;
771 let (_temp, repo) = temp_repo();
772 let repo = Arc::new(repo);
773 let hydrator = Arc::new(offline_lazy_hydrator("main"));
776 let observed_ok: Arc<AtomicUsize> = Arc::new(AtomicUsize::new(0));
777 let observed_err: Arc<AtomicUsize> = Arc::new(AtomicUsize::new(0));
778
779 let mut handles = Vec::with_capacity(N);
780 for _ in 0..N {
781 let repo = Arc::clone(&repo);
782 let hydrator = Arc::clone(&hydrator);
783 let observed_ok = Arc::clone(&observed_ok);
784 let observed_err = Arc::clone(&observed_err);
785 handles.push(thread::spawn(move || {
786 let blake3 = Blob::new(b"placeholder".to_vec()).hash();
787 match hydrator.hydrate(repo.as_ref(), &blake3) {
788 Ok(()) => observed_ok.fetch_add(1, Ordering::SeqCst),
789 Err(_) => observed_err.fetch_add(1, Ordering::SeqCst),
790 };
791 }));
792 }
793 for h in handles {
794 h.join().expect("worker joined");
795 }
796 let total = observed_ok.load(Ordering::SeqCst) + observed_err.load(Ordering::SeqCst);
801 assert_eq!(total, N, "every concurrent caller must receive a reply");
802 }
803
804 #[test]
805 fn hydrate_times_out_when_worker_never_replies() {
806 let (_temp, repo) = temp_repo();
807 let target = repo
808 .refs()
809 .get_thread(&ThreadName::from("main"))
810 .unwrap()
811 .unwrap();
812 let (tx, rx) = mpsc::channel::<super::HydrateMessage>();
813 let (release_tx, release_rx) = mpsc::sync_channel::<()>(0);
814 let (done_tx, done_rx) = mpsc::sync_channel::<()>(0);
815 let worker = thread::Builder::new()
816 .name("stalling-hydrator".into())
817 .spawn(move || {
818 match rx.recv() {
819 Ok(super::HydrateMessage::Run { reply, .. }) => {
820 let _ = release_rx.recv();
821 drop(reply);
822 }
823 Err(_) => {}
824 }
825 let _ = done_tx.send(());
826 })
827 .expect("spawn stalling worker");
828 let bridge = HydrationBridge {
829 tx,
830 _worker: worker,
831 };
832
833 let started = Instant::now();
834 let err = bridge
835 .hydrate_with_timeout(
836 &repo,
837 "org/acme/repo",
838 "main",
839 target,
840 Duration::from_millis(50),
841 )
842 .expect_err("stalled worker must time out");
843 let elapsed = started.elapsed();
844
845 assert!(
846 elapsed < Duration::from_secs(1),
847 "hydrate timeout must return promptly; elapsed {elapsed:?}"
848 );
849 let msg = err.to_string();
850 assert!(
851 msg.contains("blob hydration timed out after") && msg.contains("org/acme/repo"),
852 "timeout error must name the operation and repo context; got: {msg}"
853 );
854
855 release_tx.send(()).expect("release stalled worker");
856 done_rx
857 .recv_timeout(Duration::from_secs(1))
858 .expect("worker exits after release");
859 }
860
861 #[test]
864 fn dropping_bridge_shuts_worker_down() {
865 let bridge = offline_bridge();
866 drop(bridge);
872 thread::sleep(Duration::from_millis(50));
874 }
875
876 #[test]
880 fn hydration_message_carries_send_owned_repo_handle() {
881 fn assert_send_static<T: Send + 'static>(_: &T) {}
882 let (_temp, repo) = temp_repo();
883 let (reply, _recv) = mpsc::sync_channel::<Result<usize, wire::ProtocolError>>(1);
884 let message = super::HydrateMessage::Run {
885 repo: Arc::new(repo),
886 repo_path: "org/acme/repo".to_string(),
887 remote_thread: "main".to_string(),
888 target_state: StateId::from_bytes([2; 32]),
889 reply,
890 };
891 assert_send_static(&message);
892 }
893
894 #[test]
895 fn hydration_bridge_does_not_reintroduce_raw_repo_pointer() {
896 let source = include_str!("hydration.rs");
897 let raw_wrapper = ["Repo", "Ptr"].concat();
898 let raw_repo_pointer = ["*const ", "Repository"].concat();
899 assert!(
900 !source.contains(&raw_wrapper),
901 "hydration bridge must not reintroduce the raw-pointer send wrapper"
902 );
903 assert!(
904 !source.contains(&raw_repo_pointer),
905 "hydration bridge must not send raw Repository pointers across threads"
906 );
907 }
908
909 #[test]
916 fn hydrate_returns_config_error_when_local_thread_missing() {
917 let (_temp, repo) = temp_repo();
918 let hydrator = offline_lazy_hydrator("thread-that-was-never-written");
922 let blake3 = Blob::new(b"placeholder".to_vec()).hash();
923 let err = hydrator
924 .hydrate(&repo, &blake3)
925 .expect_err("missing thread must surface as Config error");
926 let msg = err.to_string();
927 assert!(
928 msg.contains("no recorded tip") && msg.contains("thread-that-was-never-written"),
929 "error must name the missing thread and explain why hydration was skipped; got: {msg}"
930 );
931 }
932
933 #[test]
942 fn ensure_bridge_propagates_dns_failure() {
943 let (_temp, repo) = temp_repo();
944 let hydrator = LazyHostedHydrator::new(
948 "definitely-nonexistent-host-for-tests.invalid:443",
949 "org/acme/repo",
950 "main",
951 "main",
952 );
953 let blake3 = Blob::new(b"placeholder".to_vec()).hash();
954 let err = hydrator
955 .hydrate(&repo, &blake3)
956 .expect_err("unresolvable endpoint must surface as a Config error");
957 let msg = err.to_string();
958 assert!(
959 msg.contains("resolve endpoint")
960 || msg.contains("DNS returned no addresses")
961 || msg.contains(".invalid"),
962 "error must identify the DNS-resolution failure; got: {msg}"
963 );
964 let err2 = hydrator
967 .hydrate(&repo, &blake3)
968 .expect_err("second call must also fail rather than reuse a partial bridge");
969 assert!(
970 !err2.to_string().is_empty(),
971 "second call must surface a real error"
972 );
973 }
974}
975
976#[cfg(test)]
977mod register_factory_tests {
978 use std::sync::Mutex;
984
985 use repo::lazy_hydrator::{HostedHydratorConfig, HydratorSection, KIND_HOSTED, lookup_factory};
986 use tempfile::TempDir;
987
988 use super::register_hosted_factory;
989
990 static REGISTRY_LOCK: Mutex<()> = Mutex::new(());
993
994 #[test]
995 fn register_hosted_factory_installs_factory_for_kind_hosted() {
996 let _guard = REGISTRY_LOCK.lock().unwrap_or_else(|p| p.into_inner());
997 register_hosted_factory();
998 assert!(
999 lookup_factory(KIND_HOSTED).is_some(),
1000 "register_hosted_factory must populate the registry under KIND_HOSTED"
1001 );
1002 }
1003
1004 #[test]
1005 fn registered_factory_builds_adapter_for_hosted_section() {
1006 let _guard = REGISTRY_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1007 register_hosted_factory();
1008 let factory =
1009 lookup_factory(KIND_HOSTED).expect("factory present after register_hosted_factory");
1010 let temp = TempDir::new().expect("temp");
1011 let section = HydratorSection {
1012 kind: KIND_HOSTED.to_string(),
1013 hosted: Some(HostedHydratorConfig {
1014 endpoint: "example.heddle.cloud:443".to_string(),
1015 repo_path: "org/acme/repo".to_string(),
1016 remote_thread: "main".to_string(),
1017 local_thread: "main".to_string(),
1018 }),
1019 git_overlay: None,
1020 };
1021 let _hydrator = factory(temp.path(), §ion)
1022 .expect("factory must produce an adapter when [hydrator.hosted] is present");
1023 }
1024
1025 #[test]
1026 fn registered_factory_errors_when_hosted_section_absent() {
1027 let _guard = REGISTRY_LOCK.lock().unwrap_or_else(|p| p.into_inner());
1028 register_hosted_factory();
1029 let factory = lookup_factory(KIND_HOSTED).expect("factory present");
1030 let temp = TempDir::new().expect("temp");
1031 let section = HydratorSection {
1032 kind: KIND_HOSTED.to_string(),
1033 hosted: None,
1034 git_overlay: None,
1035 };
1036 let err = match factory(temp.path(), §ion) {
1037 Ok(_) => panic!(
1038 "factory must reject a kind=hosted section that omits the [hydrator.hosted] table"
1039 ),
1040 Err(e) => e,
1041 };
1042 let msg = err.to_string();
1043 assert!(
1044 msg.contains("[hydrator.hosted]") || msg.contains("hydrator.hosted"),
1045 "error must name the missing TOML table; got: {msg}"
1046 );
1047 }
1048}
1049
1050#[cfg(test)]
1051mod connect_path_tests {
1052 #[test]
1060 fn lazy_hosted_connect_opens_session_through_rotating_seam() {
1061 let source = include_str!("hydration.rs");
1062 assert!(
1063 source.contains("HostedSession::build(&user_config, Some(endpoint.to_string()))"),
1064 "hydration.rs must build its session through the shared HostedSession seam",
1065 );
1066 assert!(
1067 source.contains("session.connect(addr)"),
1068 "hydration.rs must connect via HostedSession::connect, which owns rotation",
1069 );
1070 }
1071}
1072
1073#[cfg(test)]
1074mod config_persistence_tests {
1075 use repo::lazy_hydrator::LazyHydratorConfig;
1080 use tempfile::TempDir;
1081
1082 #[test]
1083 fn lazy_hydrator_config_round_trip_preserves_hostname() {
1084 let temp = TempDir::new().expect("temp");
1085 let heddle = temp.path().join(".heddle");
1086 let endpoint = "example.heddle.cloud:443";
1090 let cfg = LazyHydratorConfig::hosted(endpoint, "org/acme/repo", "main", "main");
1091 cfg.save(&heddle).expect("save");
1092 let loaded = LazyHydratorConfig::load(&heddle)
1093 .expect("load")
1094 .expect("present");
1095 let hosted = loaded
1096 .hydrator
1097 .hosted
1098 .expect("hosted section present after round-trip");
1099 assert_eq!(
1100 hosted.endpoint, endpoint,
1101 "endpoint MUST round-trip as the original hostname:port spec; \
1102 pinning the IP at clone time would break hosts with rotating IPs"
1103 );
1104 assert!(
1108 hosted.endpoint.parse::<std::net::SocketAddr>().is_err(),
1109 "persisted endpoint must be a hostname spec, not a SocketAddr literal"
1110 );
1111 }
1112}