Skip to main content

heddle_client/grpc_hosted/
hydration.rs

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
17/// Default hosted lazy-hydration deadline.
18///
19/// This matches the hosted client config's 30s default connection timeout and
20/// gives lazy reads a bounded failure mode when a gRPC request stalls without a
21/// transport-level TCP timeout.
22const 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
49/// Read-time blob hydrator for **hosted** lazy clones (issue #50).
50///
51/// Plugs into [`repo::Repository::set_blob_hydrator`]: when
52/// [`Repository::require_blob`] hits a missing-blob marker left behind by a
53/// lazy hosted clone (`heddle clone --lazy <hosted-url>` /
54/// `--filter blob:none`), the read path delegates here, this hydrator re-runs
55/// the pull with full materialization for the *current* tip of `local_thread`,
56/// and the read is retried against the freshly populated store.
57///
58/// ## Runtime bridge
59///
60/// `Repository::require_blob` is sync. The underlying gRPC stack is async,
61/// and the hydrator must be invocable from BOTH async contexts (the
62/// `#[tokio::main]` CLI command path) and plain non-Tokio threads (future
63/// FFI callers, test helpers). `Handle::block_on` invoked from within a
64/// running Tokio runtime panics ("Cannot start a runtime from within a
65/// runtime"), so we cannot bridge in-place.
66///
67/// Instead, on first use we spawn a dedicated worker thread that owns its
68/// own current-thread Tokio runtime + a connected `HostedGrpcClient`. Each
69/// `hydrate()` call sends a request over an mpsc channel and blocks on the
70/// reply. The worker `block_on`s the gRPC call inside its private runtime,
71/// avoiding any nesting. This pattern is robust regardless of what the
72/// caller's thread is doing.
73pub struct LazyHostedHydrator {
74    /// Endpoint spec as `host:port` (or an IP literal). Re-resolved via DNS
75    /// on first connect so a hostname behind a load balancer with rotating
76    /// IPs still works across process restarts. We deliberately do NOT
77    /// store a [`std::net::SocketAddr`] here — that would freeze the IP at
78    /// clone time and break later reconnects.
79    endpoint: String,
80    repo_path: String,
81    remote_thread: String,
82    /// Local thread to resolve to a state on each hydrate. Re-read every
83    /// call so a `pull --lazy` that advances the thread tip is honored
84    /// without rewriting `lazy-hydrator.toml`.
85    local_thread: String,
86    bridge: OnceLock<HydrationBridge>,
87    /// Held during first-use bridge construction so the connect + spawn
88    /// sequence is atomic — N concurrent first-time callers see exactly
89    /// one bridge built and shared, rather than N runtimes / N clients
90    /// racing via separate `OnceLock::set` calls (the round-2 bug).
91    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        // Serialize first-time construction so the runtime, client, and
116        // worker thread are installed as one atomic unit.
117        let _guard = self.init_lock.lock().unwrap_or_else(|poison| {
118            // Prior initializer panicked. The bridge is either set (good)
119            // or absent (caller will retry). Either way clearing the
120            // poison and continuing is correct — we re-check `bridge.get`
121            // below.
122            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        // The init_lock guarantees no race: `set` must succeed here.
130        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        // `_hash` is ignored: `hydrate_pulled_state` refetches every
144        // missing blob reachable from `target_state`, not just one. This
145        // matches the hosted-side strategy that already exists
146        // (sync.rs:541) and is the cheapest correct behaviour given the
147        // partial-fetch metadata records the blake3 only.
148
149        // Re-resolve the target state from the repo on EVERY call. If a
150        // `pull --lazy` advanced the local thread between clone and now,
151        // the cached state would point at the OLD tip and we'd leave any
152        // post-pull missing blobs unresolved — that was the round-2 P1.
153        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
181/// Background worker bridging sync `BlobHydrator::hydrate` calls to the
182/// async gRPC stack. Owns a dedicated current-thread Tokio runtime and a
183/// connected `HostedGrpcClient`. Callers reopen the repository root into
184/// an owned handle, dispatch hydrate requests over an mpsc channel, and
185/// block on a per-request reply channel.
186///
187/// This indirection is what makes the hydrator safe to call from a
188/// `#[tokio::main]` async context: the worker's runtime is private, so the
189/// nested `block_on` happens entirely off the caller's runtime.
190struct HydrationBridge {
191    tx: mpsc::Sender<HydrateMessage>,
192    /// Join handle for the worker. Kept so that dropping the bridge
193    /// closes the channel and lets the worker exit cleanly.
194    _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        // Resolve DNS at connect time so a hostname that's persisted
210        // (rather than a frozen IP) re-resolves on every process start.
211        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        // Build + validate the session config on this thread so a rejected
229        // TLS/auth config surfaces synchronously, before the worker thread is
230        // spawned. The worker connects + rotates through `session.connect`.
231        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        // Build the worker thread first so the bridge can store the
240        // tx side immediately. The worker's runtime + client are
241        // constructed inside the worker (so the runtime's
242        // `Handle::current()` matches the thread that drives it).
243        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                // Build the runtime on this thread so all RPCs execute
250                // inside it. `current_thread` is sufficient: hydrate
251                // calls are serialized through the mpsc channel anyway,
252                // and avoiding extra worker threads keeps the resource
253                // footprint of an idle lazy clone minimal.
254                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                    // `session.connect` connects and runs mandatory rotation
269                    // together — the same seam every other hosted entry point
270                    // (clone, fetch, push, pull, support, approval) opens
271                    // through — so a process whose cached token has slipped
272                    // past expiry recovers on first lazy hydrate.
273                    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                // Signal the bridge constructor that connect succeeded
304                // BEFORE entering the request loop. After this point any
305                // bridge-construction errors are gone; the channel is open
306                // and `HydrationBridge::hydrate` calls will succeed.
307                if ready_tx.send(Ok(())).is_err() {
308                    return;
309                }
310
311                // Drive the request loop. `recv` returns Err when the
312                // last `Sender` is dropped (i.e. the LazyHostedHydrator
313                // owning the bridge has been dropped), which is our
314                // shutdown signal — we drop the runtime + client and
315                // exit.
316                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        // Wait for the worker to either confirm connect or report an
346        // error. The wait is bounded so a stalled first-use connect cannot
347        // wedge the sync read path.
348        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        // Bounded reply channel of capacity 1; each sync caller blocks until
392        // the worker returns the gRPC result for this request.
393        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
472/// Register the `"hosted"` factory in the global lazy-hydrator registry.
473/// Call once at process startup. The factory reads the hosted-section
474/// fields out of `lazy-hydrator.toml` and hands back a
475/// [`LazyHostedHydrator`] adapter that defers the actual gRPC connect (and
476/// worker-thread spawn) until the first `require_blob` call needs it.
477pub 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    //! These tests exercise the lazy-hydrator adapter against a worker
509    //! bridge that connects to a definitely-closed `127.0.0.1:1` endpoint
510    //! via `Endpoint::connect_lazy` — the channel doesn't actually dial
511    //! until the first RPC, at which point it fails predictably with a
512    //! transport-layer error. That's enough to drive the bridge's
513    //! sync→async hand-off, runtime construction, and error propagation
514    //! end-to-end without spinning up an in-process gRPC server.
515    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    /// Build a `HostedGrpcClient` that points at a closed loopback port
546    /// via `connect_lazy`. RPCs fail with a transport error rather than
547    /// hanging. Must be called from inside a tokio runtime context.
548    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    /// Build the smallest Heddle repo + seed the `main` thread to a real
571    /// state so `hydrate` can resolve `local_thread`.
572    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    /// Spawn a `HydrationBridge` with a pre-built offline client, bypassing
579    /// the DNS / connect / credential paths so tests stay hermetic.
580    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    /// Construct a `LazyHostedHydrator` whose bridge is already installed
622    /// from `offline_bridge`. Bypasses the real `ensure_bridge` connect
623    /// path so we can drive the trait surface deterministically.
624    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    /// Round-3 test from the task brief — proves the worker bridge is
640    /// callable from inside a `#[tokio::main]`-style multi-thread async
641    /// context. With the previous design (`Handle::block_on` from the
642    /// outer runtime's thread) this would have panicked.
643    #[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            // Seed a known thread tip the hydrator can resolve via
658            // `local_thread`.
659            let _ = target;
660
661            let hydrator = offline_lazy_hydrator("main");
662            let blake3 = Blob::new(b"placeholder".to_vec()).hash();
663            // Must not panic. The offline client surfaces a transport
664            // error, which the trait reshapes into a HeddleError::Io. We
665            // assert "non-empty error" rather than pinning tonic wording.
666            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    /// Round-3 test from the task brief — direct counterpart to the
674    /// Tokio test above. The hydrator must also work on plain non-Tokio
675    /// threads (the future FFI / library-embedder path).
676    #[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    /// Round-3 test from the task brief. If `target_state` were cached at
688    /// first hydrate (the round-2 bug), the second call against an advanced
689    /// thread tip would hydrate against the OLD state. We exercise both
690    /// the first and second hydrate, and inspect the request the bridge
691    /// processed via an inspection bridge that captures the target_state
692    /// it received.
693    #[test]
694    fn hydrate_after_thread_advance_uses_new_state() {
695        // Build an inspecting bridge: instead of running real RPCs it
696        // records the StateId on each request and replies with an
697        // "io error: simulated". That lets us verify the bridge saw the
698        // post-advance StateId on the second call.
699        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        // First hydrate — bridge sees the original tip.
739        let blake3 = Blob::new(b"a".to_vec()).hash();
740        let _ = hydrator.hydrate(&repo, &blake3);
741
742        // Advance the local "main" thread to a fresh, distinct StateId.
743        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        // Second hydrate — bridge MUST see the advanced tip, not the
750        // first one (round-2 cached-state bug regression guard).
751        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    /// Round-3 test from the task brief. With the round-2 design,
763    /// concurrent first-time callers raced two separate `OnceLock::set`
764    /// calls (runtime + inner) and could end up storing an inner whose
765    /// `Handle` referenced a runtime that was dropped by the losing
766    /// thread. Now there's a single OnceLock + an init_lock, so all
767    /// callers observe exactly one bridge.
768    #[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        // The arc allows N threads to share one hydrator that they all
774        // race to initialize.
775        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        // Either outcome is fine — the assertion is that no panic /
797        // deadlock occurred and every caller got a reply. The offline
798        // client produces errors, so we expect all N to land in the err
799        // bucket; we accept any split as long as the total is N.
800        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    /// Drop the bridge → worker exits cleanly. Catches the case where a
862    /// future refactor leaks the worker forever.
863    #[test]
864    fn dropping_bridge_shuts_worker_down() {
865        let bridge = offline_bridge();
866        // Pull the worker handle out via a Drop-detecting wrapper isn't
867        // possible without restructuring; instead we observe that
868        // dropping the bridge closes the channel and `send` afterwards
869        // would fail. The cleanest visible assertion: dropping the
870        // bridge does not hang the test.
871        drop(bridge);
872        // Give the worker a moment to wind down on slow CI.
873        thread::sleep(Duration::from_millis(50));
874    }
875
876    /// Force the owned repo handle into the type system. The hydration
877    /// worker must receive an owned `Arc<Repository>` rather than a raw
878    /// borrowed pointer whose lifetime is erased across the mpsc channel.
879    #[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    /// Round-4 patch-coverage fill: exercise the `hydrate` early-return
910    /// taken when the persisted `local_thread` has no recorded tip in the
911    /// current repo (e.g. the lazy clone was interrupted before the first
912    /// thread write landed). The hydrator must surface this as a clean
913    /// `Config` error rather than calling `ensure_bridge` and dialing the
914    /// network for a state we don't have.
915    #[test]
916    fn hydrate_returns_config_error_when_local_thread_missing() {
917        let (_temp, repo) = temp_repo();
918        // Pre-set the bridge so `ensure_bridge` would succeed if reached —
919        // that way a failure here proves the early-return fired before the
920        // bridge was consulted.
921        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    /// Round-4 patch-coverage fill: drive the real `ensure_bridge` path
934    /// (no pre-installed bridge) against an unresolvable hostname. The
935    /// DNS error must propagate back through `HydrationBridge::connect`
936    /// → `ensure_bridge` → `hydrate` rather than panicking or hanging.
937    ///
938    /// The `.invalid` TLD is RFC 2606-reserved and guaranteed never to
939    /// resolve, so this test stays hermetic in CI environments without
940    /// outbound DNS.
941    #[test]
942    fn ensure_bridge_propagates_dns_failure() {
943        let (_temp, repo) = temp_repo();
944        // Note: no `offline_lazy_hydrator` — this constructor leaves
945        // `bridge` empty so the first `hydrate()` exercises the real
946        // ensure_bridge → HydrationBridge::connect path including DNS.
947        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        // Repeat the call — second attempt must also fail-fast (no
965        // half-initialized bridge cached on disk / in OnceLock).
966        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    //! Round-4 patch-coverage fill for `register_hosted_factory` and the
979    //! closure it installs in the lazy-hydrator registry. Both branches
980    //! of the closure (missing `[hydrator.hosted]` table → Config error;
981    //! present table → ready-to-install adapter) are exercised here.
982
983    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    /// Serialize tests that mutate the process-wide hydrator registry so
991    /// they don't race on the global `"hosted"` key.
992    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(), &section)
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(), &section) {
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    //! Source-presence test for the credential-rotation invariant. Lazy
1053    //! hydration must open its session through the shared `HostedSession`
1054    //! seam — whose `connect` connects and rotates together (guarded by a
1055    //! source-presence test in `session.rs`) — rather than connecting by
1056    //! hand and risking a dropped rotation. Without rotation, a process
1057    //! whose cached token has slipped past expiry hits an auth failure on
1058    //! first lazy hydrate even though the rotation data is on disk.
1059    #[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    //! Tests for the round-3 hostname-vs-IP persistence fix. These live
1076    //! alongside the hydrator tests because the contract — "endpoint
1077    //! field stores a host:port string, NOT a resolved SocketAddr" — is
1078    //! enforced at the LazyHostedHydrator boundary.
1079    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        // The persisted endpoint MUST be the hostname spec, not a
1087        // SocketAddr-formatted IP. clone.rs is the producer; here we
1088        // simulate it and verify load round-trips byte-for-byte.
1089        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        // Sanity: the persisted value must not parse as a SocketAddr —
1105        // if it does, the producer was silently resolving DNS at save
1106        // time and we'd be back to the round-2 bug shape.
1107        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}