Skip to content
Open
12 changes: 12 additions & 0 deletions lore-revision/src/auth/userinfo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,10 +91,22 @@ pub struct LoreAuthIdentityEventData {
/// remote path. Because the local path reads a cached token captured at
/// login, a name change made server-side since then is not reflected until
/// the token is refreshed.
///
/// In offline or local-only mode, returns without emitting any events —
/// resolution requires the remote auth service, and callers fall back to
/// displaying the raw ids.
pub async fn resolve_user_info(
repository: Arc<RepositoryContext>,
ids: LoreArray<LoreString>,
) -> Result<(), UserInfoError> {
// Resolution needs the remote auth service; a local-only or offline
// operation must not drive a connect just to prettify display names.
// Callers fall back to showing the raw ids.
if execution_context().globals().offline_or_local() {
lore_debug!("Skipping user info resolution in offline/local mode");
return Ok(());
}

let remote = repository
.remote()
.await
Expand Down
14 changes: 7 additions & 7 deletions lore-revision/src/file/history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,13 @@ async fn find_start_revision(
resolved.id
};

let local_latest = branch::load_latest(repository.clone(), branch)
.await
.unwrap_or_default();
if execution_context().globals().local() {
return Ok(local_latest);
}

let remote_latest = if let Ok(remote) = repository.remote().await {
branch::load_remote_latest(remote.clone(), repository.id, branch)
.await
Expand All @@ -385,13 +392,6 @@ async fn find_start_revision(
return Ok(remote_latest);
}

let local_latest = branch::load_latest(repository.clone(), branch)
.await
.unwrap_or_default();
if execution_context().globals().local() {
return Ok(local_latest);
}

// Is there a latest?
if remote_latest.is_zero() {
return Ok(local_latest);
Expand Down
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"
);
}
}
4 changes: 1 addition & 3 deletions lore-revision/src/revision.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1219,9 +1219,7 @@ pub async fn resolve(
branch_status.id
};

let remote_latest = if let Ok(remote) = repository.remote().await
&& should_search_remote
{
let remote_latest = if should_search_remote && let Ok(remote) = repository.remote().await {
branch::load_remote_latest(remote.clone(), repository.id, branch)
.await
.ok()
Expand Down
15 changes: 7 additions & 8 deletions lore-revision/src/revision/history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -177,15 +177,14 @@ async fn find_start_revision(
.await
.internal("loading branch name")?;

let remote_latest = if let Ok(remote) = repository.remote().await {
branch::load_remote_latest(remote.clone(), repository.id, branch)
.await
.unwrap_or_default()
} else {
Hash::default()
};

if execution_context().globals().remote() {
let remote_latest = if let Ok(remote) = repository.remote().await {
branch::load_remote_latest(remote.clone(), repository.id, branch)
.await
.unwrap_or_default()
} else {
Hash::default()
};
return Ok((remote_latest, Some(branch)));
}

Expand Down
22 changes: 22 additions & 0 deletions lore-revision/tests/revision.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,28 @@ mod tests {
.await;
}

// Exercises the branch@LATEST arm with a local-only search location: the
// remote must not be consulted and the miss surfaces as RevisionNotFound.
#[tokio::test]
async fn resolve_latest_local_only_returns_revision_not_found_without_remote() {
let execution = setup_test_execution();
LORE_CONTEXT
.scope(execution, async move {
let repository = make_repo_context().await;
let signature = format!("{}@LATEST", uuid::Uuid::now_v7());
let err =
revision::resolve(repository, signature, None, ResolveSearchLocation::Local)
.await
.expect_err("resolve should fail for unknown branch latest");
assert!(
err.is_revision_not_found(),
"expected RevisionNotFound, got {err:?}"
);
assert!(!err.is_internal());
})
.await;
}

#[tokio::test]
async fn test_diff3() {
let (_immutable_store, _mutable_store, execution) =
Expand Down
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
Loading