Skip to content
Draft
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
13 changes: 13 additions & 0 deletions GVFS/GVFS.Common/GVFSConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,13 @@ public static class GitConfig

public const string MaxHttpConnectionsConfig = GVFSPrefix + "max-http-connections";

/// <summary>
/// Overrides how long a runtime credential fetch may block waiting on the
/// credential manager, in seconds. 0 or negative restores the pre-bound
/// behavior of waiting indefinitely. Read once into <see cref="RetryConfig"/>.
/// </summary>
public const string CredentialTimeoutSeconds = GVFSPrefix + "credential-timeout-seconds";

public const string PrefetchUseIdx = GVFSPrefix + "prefetch-use-idx";
public const bool PrefetchUseIdxDefault = false;

Expand All @@ -70,6 +77,12 @@ public static class GitConfig

public const string PrefetchOffload = GVFSPrefix + "prefetch-offload";
public const bool PrefetchOffloadDefault = false;

/* Off-by-default flag (stabilization ship). When enabled, HttpRequestor releases
* its process-wide connection-pool slot before running the credential-reject leg on
* a 401, so a slow credential helper cannot starve healthy parallel requests. */
public const string ReleaseConnectionBeforeCredentialReject = GVFSPrefix + "release-connection-before-credential-reject";
public const bool ReleaseConnectionBeforeCredentialRejectDefault = false;
}

public static class LocalGVFSConfig
Expand Down
64 changes: 48 additions & 16 deletions GVFS/GVFS.Common/Git/GitAuthentication.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ public class GitAuthentication
public const int DefaultCredentialTimeoutMs = 30_000;
public const int BackgroundCredentialTimeoutMs = 120_000;

/// <summary>
/// Minimum time to wait for an in-flight credential fetch before giving up on
/// serialization. The effective wait is never shorter than the fetch timeout
/// itself, so a slow but legitimate prompt cannot cause a second prompt.
/// </summary>
private const int DefaultCredentialGateWaitMs = 60_000;

private readonly Lock gitAuthLock = new Lock();
private readonly SemaphoreSlim credentialGate = new SemaphoreSlim(1, 1);
private readonly ICredentialStore credentialStore;
Expand Down Expand Up @@ -68,7 +75,16 @@ public bool IsBackingOff

private GitSsl GitSsl { get; }

public void ApproveCredentials(ITracer tracer, string credentialString)
/// <summary>
/// Test-only hook to force the anonymous state. Production code determines this
/// by probing the server in <see cref="TryInitializeAndQueryGVFSConfig"/>.
/// </summary>
internal void SetIsAnonymousForTesting(bool isAnonymous)
{
this.IsAnonymous = isAnonymous;
}

public void ApproveCredentials(ITracer tracer, string credentialString, int credentialTimeoutMs = DefaultCredentialTimeoutMs, CancellationToken cancellationToken = default)
{
lock (this.gitAuthLock)
{
Expand All @@ -86,7 +102,7 @@ public void ApproveCredentials(ITracer tracer, string credentialString)
string password;
if (TryParseCredentialString(this.cachedCredentialString, out username, out password))
{
if (!this.credentialStore.TryStoreCredential(tracer, this.repoUrl, username, password, out string error))
if (!this.credentialStore.TryStoreCredential(tracer, this.repoUrl, username, password, out string error, credentialTimeoutMs, cancellationToken))
{
// Storing credentials is best effort attempt - log failure, but do not fail
tracer.RelatedWarning("Failed to store credential string: {0}", error);
Expand All @@ -107,7 +123,7 @@ public void ApproveCredentials(ITracer tracer, string credentialString)
}
}

public void RejectCredentials(ITracer tracer, string credentialString)
public void RejectCredentials(ITracer tracer, string credentialString, int credentialTimeoutMs = DefaultCredentialTimeoutMs, CancellationToken cancellationToken = default)
{
lock (this.gitAuthLock)
{
Expand All @@ -118,7 +134,7 @@ public void RejectCredentials(ITracer tracer, string credentialString)
// We can't assume that the credential store's cached credential is the same as the one we have.
// Reload the credential from the store to ensure we're rejecting the correct one.
int attemptsBeforeCheckingExistingCredential = this.numberOfAttempts;
if (this.TryCallGitCredential(tracer, out string getCredentialError))
if (this.TryCallGitCredential(tracer, out string getCredentialError, out _, credentialTimeoutMs, cancellationToken))
{
if (this.cachedCredentialString != cachedCredentialAtStartOfReject)
{
Expand All @@ -139,7 +155,7 @@ public void RejectCredentials(ITracer tracer, string credentialString)
string password;
if (TryParseCredentialString(this.cachedCredentialString, out username, out password))
{
if (!this.credentialStore.TryDeleteCredential(tracer, this.repoUrl, username, password, out string error))
if (!this.credentialStore.TryDeleteCredential(tracer, this.repoUrl, username, password, out string error, credentialTimeoutMs, cancellationToken))
{
// Deleting credentials is best effort attempt - log failure, but do not fail
tracer.RelatedWarning("Failed to delete credential string: {0}", error);
Expand All @@ -154,7 +170,7 @@ public void RejectCredentials(ITracer tracer, string credentialString)
["RepoUrl"] = this.repoUrl,
});
tracer.RelatedWarning(metadata, "Failed to parse credential string for rejection. Rejecting any credential for this repo URL.");
this.credentialStore.TryDeleteCredential(tracer, this.repoUrl, username: null, password: null, error: out string error);
this.credentialStore.TryDeleteCredential(tracer, this.repoUrl, username: null, password: null, error: out string error, timeoutMs: credentialTimeoutMs, cancellationToken: cancellationToken);
}

this.cachedCredentialString = null;
Expand All @@ -169,8 +185,20 @@ public void RejectCredentials(ITracer tracer, string credentialString)
}
}

public bool TryGetCredentials(ITracer tracer, out string credentialString, out string errorMessage)
public bool TryGetCredentials(ITracer tracer, out string credentialString, out string errorMessage, int credentialTimeoutMs = DefaultCredentialTimeoutMs, CancellationToken cancellationToken = default)
{
return this.TryGetCredentials(tracer, out credentialString, out errorMessage, out _, credentialTimeoutMs, cancellationToken);
}

/// <summary>
/// Fetches credentials, reporting via <paramref name="timedOut"/> whether the failure was
/// the credential manager exceeding its bound rather than a genuine auth failure. Callers
/// use this to avoid immediately retrying, which would re-prompt the user.
/// </summary>
public bool TryGetCredentials(ITracer tracer, out string credentialString, out string errorMessage, out bool timedOut, int credentialTimeoutMs = DefaultCredentialTimeoutMs, CancellationToken cancellationToken = default)
{
timedOut = false;

if (!this.isInitialized)
{
// Initialization may still be running in the background (mount can
Expand Down Expand Up @@ -199,7 +227,7 @@ public bool TryGetCredentials(ITracer tracer, out string credentialString, out s
return false;
}

if (!this.TryCallGitCredential(tracer, out errorMessage))
if (!this.TryCallGitCredential(tracer, out errorMessage, out timedOut, credentialTimeoutMs, cancellationToken))
{
return false;
}
Expand Down Expand Up @@ -285,7 +313,7 @@ public bool TryInitializeAndQueryGVFSConfig(
// Server requires authentication — fetch credentials
this.IsAnonymous = false;

if (!this.TryCallGitCredential(tracer, out errorMessage, credentialTimeoutMs))
if (!this.TryCallGitCredential(tracer, out errorMessage, out _, credentialTimeoutMs))
{
isAuthFailure = true;
// Mark initialized even on failure so TryGetCredentials can
Expand Down Expand Up @@ -327,7 +355,7 @@ internal bool TryInitializeAndRequireAuth(ITracer tracer, out string errorMessag
throw new InvalidOperationException("Already initialized");
}

if (this.TryCallGitCredential(tracer, out errorMessage))
if (this.TryCallGitCredential(tracer, out errorMessage, out _))
{
this.MarkInitialized();
return true;
Expand Down Expand Up @@ -419,20 +447,24 @@ private void MarkInitialized()
this.initializationComplete.Set();
}

private bool TryCallGitCredential(ITracer tracer, out string errorMessage, int timeoutMs = -1)
private bool TryCallGitCredential(ITracer tracer, out string errorMessage, out bool timedOut, int timeoutMs = -1, CancellationToken cancellationToken = default)
{
// Serialize credential fetches so only one git-credential-fill
// process runs at a time. Without this, a background auth task
// and a foreground object download could both spawn GCM prompts.
// Wait up to 60s for an in-flight fetch; if the gate is still
// held (e.g., background GCM prompt), fall through and let this
// caller spawn its own credential fetch.
bool acquired = this.credentialGate.Wait(60_000);
// Wait at least as long as the fetch itself may take; otherwise the
// gate would expire while the in-flight fetch is still legitimately
// waiting on the user, and we would fall through and spawn a second
// competing GCM prompt in exactly the slow-prompt case this bound
// exists to tolerate.
int gateTimeoutMs = timeoutMs < 0 ? DefaultCredentialGateWaitMs : Math.Max(DefaultCredentialGateWaitMs, timeoutMs);
bool acquired = this.credentialGate.Wait(gateTimeoutMs, cancellationToken);
timedOut = false;
try
{
string gitUsername;
string gitPassword;
if (!this.credentialStore.TryGetCredential(tracer, this.repoUrl, out gitUsername, out gitPassword, out errorMessage, timeoutMs))
if (!this.credentialStore.TryGetCredential(tracer, this.repoUrl, out gitUsername, out gitPassword, out errorMessage, out timedOut, timeoutMs, cancellationToken))
{
this.UpdateBackoff();
return false;
Expand Down
Loading
Loading