Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 90 additions & 9 deletions lore-revision/src/repository/status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -806,6 +806,34 @@ async fn count_subtrees(roots: Vec<CountWork>) -> Result<(u64, u64), StatusError
Ok((directories, files))
}

/// Resolve the remote's latest revision for a branch, degrading to the existing
/// `NoRemote` state when the remote is unavailable so an unreachable remote never
/// stalls a local status read; transport connect timeouts bound the wait.
///
/// Returns `(latest, authorized, available)`, where `available` reflects
/// connectivity, not query success — a reachable remote that errors is still available.
async fn resolve_remote_latest(
repository: &Arc<RepositoryContext>,
branch_id: BranchId,
) -> (Option<Hash>, bool, bool) {
let remote = match repository.remote().await {
Ok(r) => r,
Err(err) => {
lore_debug!("Remote unavailable for status: {err}");
return (None, false, false);
}
};

match branch::load_remote(remote, repository.id, branch_id).await {
Ok(status) => (Some(status.latest), true, true),
Err(err) if err.is_branch_not_found() => (None, true, true),
Err(err) => {
lore_debug!("Remote branch query failed: {err}");
(None, false, true)
}
}
}

pub async fn status(
repository: Arc<RepositoryContext>,
paths: Option<Vec<RelativePath>>,
Expand Down Expand Up @@ -907,14 +935,8 @@ pub async fn status(

// Authorized only on an authoritative answer — a latest revision
// or branch not found; proving the identity is authorized and has access
let (remote_latest, remote_authorized) = match repository.remote().await {
Ok(remote) => match branch::load_remote(remote, repository.id, branch.id).await {
Ok(status) => (Some(status.latest), true),
Err(err) if err.is_branch_not_found() => (None, true),
Err(_) => (None, false),
},
Err(_) => (None, false),
};
let (remote_latest, remote_authorized, remote_available) =
resolve_remote_latest(&repository, branch.id).await;

let remote_state = if let Some(remote_latest) = remote_latest {
state::State::deserialize(repository.clone(), remote_latest)
Expand Down Expand Up @@ -1080,7 +1102,7 @@ pub async fn status(
},
local_ahead,
remote_ahead,
repository.remote().await.is_ok(),
remote_available,
remote_authorized,
remote_latest.is_some(),
);
Expand Down Expand Up @@ -1458,3 +1480,62 @@ pub async fn status(

Ok(())
}

#[cfg(test)]
mod remote_resolve_tests {
use lore_transport::ProtocolError;

use super::*;
use crate::errors::Disconnected;
use crate::lore::BranchId;
use crate::repository::RemoteState;
use crate::repository::RepositoryContext;
use crate::repository::RepositoryFormat;
use crate::repository::create_client_memory_stores;

fn disconnected() -> ProtocolError {
ProtocolError::from(Disconnected)
}

async fn context_with_state(state: RemoteState) -> Arc<RepositoryContext> {
let (immutable, mutable) = create_client_memory_stores()
.await
.expect("in-memory stores should be creatable");
Arc::new(RepositoryContext::new_with_state(
None,
immutable,
mutable,
crate::lore::RepositoryId::default(),
crate::instance::InstanceId::default(),
state,
Arc::default(),
RepositoryFormat::Lore,
))
}

#[tokio::test]
async fn offline_remote_resolves_to_unavailable() {
let ctx = context_with_state(RemoteState::Offline).await;

let result = resolve_remote_latest(&ctx, BranchId::default()).await;

assert_eq!(
result,
(None, false, false),
"offline should degrade to unavailable"
);
}

#[tokio::test]
async fn failed_remote_resolves_to_unavailable() {
let ctx = context_with_state(RemoteState::Failed(disconnected())).await;

let result = resolve_remote_latest(&ctx, BranchId::default()).await;

assert_eq!(
result,
(None, false, false),
"failed remote should degrade to unavailable"
);
}
}
3 changes: 3 additions & 0 deletions lore-transport/src/grpc/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ pub const REVISION_LIST_STRATEGY_HEADER: &str = "x-lore-revision-list-strategy";
const RETRY_START_BACKOFF_MS: u64 = 50;
const RETRY_MAX_BACKOFF_MS: u64 = 10_000;
const RETRY_MAX_ATTEMPTS: usize = 60;
const GRPC_CONNECT_TIMEOUT_SECS: u64 = 5;

fn grpc_retry() -> crate::util::Retry {
crate::util::retry(
Expand Down Expand Up @@ -575,6 +576,8 @@ async fn connect_to_endpoint(remote: &str) -> Result<Channel, ProtocolError> {

lore_trace!("Set user agent to {user_agent}");

endpoint = endpoint.connect_timeout(Duration::from_secs(GRPC_CONNECT_TIMEOUT_SECS));

// Silent propagation of connection errors
let channel = endpoint
.connect()
Expand Down
15 changes: 12 additions & 3 deletions lore-transport/src/quic/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ pub struct EndpointConfig {

const IDLE_TIMEOUT_MS: u32 = 30000;
const KEEP_ALIVE_MS: u64 = 500;
const HANDSHAKE_TIMEOUT_SECS: u64 = 5;
pub const DEFAULT_EXPECTED_RTT_MS: u64 = 100;

#[derive(Clone, Debug)]
Expand Down Expand Up @@ -772,14 +773,22 @@ pub async fn connect(
Ok(mut endpoint) => {
endpoint.set_default_client_config(client_config.clone());
match endpoint.connect(remote_addr, server_name) {
Ok(connecting) => match connecting.await {
Ok(connection) => {
Ok(connecting) => match tokio::time::timeout(
Duration::from_secs(HANDSHAKE_TIMEOUT_SECS),
connecting,
)
.await
{
Ok(Ok(connection)) => {
lore_debug!("Success QUIC connecting to {remote_addr}");
return Ok(connection);
}
Err(err) => {
Ok(Err(err)) => {
lore_debug!("Failed QUIC connecting to {remote_addr}: {err}");
}
Err(_) => {
lore_debug!("QUIC handshake timeout to {remote_addr}");
}
},
Err(err) => {
lore_debug!("Failed QUIC connect to {remote_addr}: {err}");
Expand Down