Do not fall back to anonymous when the /gvfs/config probe is indeterminate - #2104
Open
tyrielv wants to merge 2 commits into
Open
Do not fall back to anonymous when the /gvfs/config probe is indeterminate#2104tyrielv wants to merge 2 commits into
tyrielv wants to merge 2 commits into
Conversation
…inate Mount initializes authentication by querying /gvfs/config without credentials. A success means the server allows anonymous access. A 401 means credentials are required. Any other outcome - a timeout, a 5xx, or a socket error - says nothing about authentication, but the code left IsAnonymous at its default of true and marked initialization complete. That state is unrecoverable for the life of the mount process. HttpRequestor omits the Authorization header while IsAnonymous is true, and the short-circuit in SendRequest means TryGetCredentials is never called, so no credential is ever fetched. The Azure DevOps cache server answers such a request with 400 and the body "A valid Basic Authorization header is required." A 400 is not retryable, RejectCredentials is a no-op because no credential was ever cached, and initialization is already latched, so the probe never runs again. Mount proceeds past this failure when a cache server is configured, so the repo stays mounted but cannot download objects or blob sizes. Directory enumeration then fails with SizesUnavailableException and git reports tracked files as deleted. Every enumeration retries and fails the same way, which floods the mount log. Only a remount clears the state. Default IsAnonymous to false and set it explicitly on the indeterminate branch. Anonymous access is now an affirmative determination that requires a successful unauthenticated probe. If the server does allow anonymous access, it ignores the Authorization header that GVFS sends after an indeterminate probe. Extract IGVFSConfigRequestor from ConfigHttpRequestor and add an internal factory seam so tests can drive each probe outcome without a server. Five tests cover the three branches; the three that pin the fix fail if either half of it is reverted. Assisted-by: Claude Opus 5 Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
Review found that the previous commit broke the probe it depends on. The probe that determines whether the server allows anonymous access was unauthenticated only as a side effect of IsAnonymous defaulting to true. Defaulting it to false removed that mechanism without replacing it, so HttpRequestor.SendRequest saw IsAnonymous == false, called TryGetCredentials, and waited on initializationComplete - the event that only the same call stack can set. Each wait cost the full 120 second initialization timeout and returned a retryable 401, so a mount stalled for roughly 14 minutes across the default seven attempts and then wrongly concluded that credentials were required. This happened on every mount. The mock-based tests never reached SendRequest, so they could not catch it. Make the probe explicitly unauthenticated instead of implicitly so. SendRequest takes a forceAnonymous parameter, ConfigHttpRequestor.TryQueryGVFSConfig passes it through, and the initial probe sets it. A request that carries no credentials now also skips ApproveCredentials and keeps the existing anonymous handling of a 401, which is the definitive answer that the server requires authentication. SendRequest also resolves the decision once into a local instead of reading IsAnonymous four times. Another thread can change the property mid-request, and the credential gate, the Authorization header, and the response handling must agree on one value. Trace the indeterminate branch with Keywords.Telemetry and structured metadata. The plain RelatedWarning overload traces with Keywords.None, which the telemetry listener filters out, so the affected population could not be measured. Replace the ConfigRequestorFactory Func with a plain ConfigRequestorOverride property, matching the settable-knob style this class already uses for InitializationWaitTimeoutMs, and make IGVFSConfigRequestor internal since no production code needs the polymorphism. Add InitialConfigProbeIsSentWithoutCredentials, which mirrors the SendRequest credential gate and fails fast if the probe ever waits on its own initialization. Convert the indeterminate-status test to real TestCase rows so each status reports independently. Mutation testing, per half: removing forceAnonymous fails the new test; reverting the default fails AuthIsNotAnonymousBeforeInitialization; deleting the explicit IsAnonymous assignment on the indeterminate branch fails nothing, so that line is kept only as defensive symmetry and is commented as such. Full unit suite: 948 tests, 0 failed. Assisted-by: Claude Opus 5 Signed-off-by: Tyrie Vella <tyrielv@gmail.com>
tyrielv
marked this pull request as ready for review
September 2, 2026 21:00
tyrielv
enabled auto-merge
September 2, 2026 21:00
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this fixes
A mount can end up permanently unauthenticated, and only a remount clears it.
GitAuthentication.TryInitializeAndQueryGVFSConfigprobes/gvfs/configwithoutcredentials. The probe has three outcomes, but the code only handled two:
IsAnonymous = true✔IsAnonymous = false✔IsAnonymousleft at its defaulttrue✘The third case is indeterminate — it says nothing about authentication. Leaving
IsAnonymousat itstruedefault silently downgrades the process to anonymous.Why it never recovers
HttpRequestor.SendRequestshort-circuits onIsAnonymous:So while
IsAnonymousis true:Authorizationheader is sent, andTryGetCredentialsis never called —git credential fillnever runs, so no credential is ever fetched.A valid Basic Authorization header is required.RetryWrapperaborts immediately.RejectCredentialsis a no-op, because its guard requires a non-null cachedcredential and none was ever cached.
MarkInitialized()already ran, soTryInitializeAndQueryGVFSConfigthrowsInvalidOperationException("Already initialized")if called again — the probenever re-runs.
Every recovery path is closed. The state persists for the life of the process.
User impact
Mount does not fail on this, because when a cache server is configured
InProcessMountdeliberately continues ("Mount will proceed with fallback cacheserver"). The repo mounts and looks healthy, but cannot download objects or blob
sizes. Directory enumeration fails with
SizesUnavailableException, soStartDirectoryEnumerationAsyncHandlerreturns an error and git reports trackedfiles as deleted. Each enumeration retries and fails identically, flooding the
mount log.
Observed on a large enlistment: the config query timed out at mount, then ~35,000
auth errors and ~29,600
SizesUnavailableExceptionin roughly one hour, with themount log growing past 70 MB.
git statusshowed millions of phantom deletions.Zero
TryGetCredentialevents appeared in the entire log, while a separategvfs prefetchprocess authenticated normally against the same repo at the sametime — the classic signature of the process being stuck anonymous. Remounting
fixed it immediately.
The fix
Anonymous access becomes an affirmative determination: it requires a successful
unauthenticated probe.
IsAnonymousnow defaults to false.IsAnonymous = falseexplicitly and says so inthe warning it traces.
If the server really does allow anonymous access, it ignores the
Authorizationheader GVFS sends after an indeterminate probe. The failure mode is now a
possible credential prompt instead of a mount that cannot serve files.
The default flip also closes a related gap: a request issued before initialization
completes previously went out anonymous. It now goes through
TryGetCredentials,which already waits on
initializationComplete.Not a 2.0 regression
v1.0.26098.1has the same!IsAnonymous && TryGetCredentials(...)short-circuitin
SendRequestand the samehasCacheServerfallback in mount, and itsindeterminate branch also never assigns
IsAnonymous. 1.0 reaches the identicalstuck-anonymous state. This targets
vnextrather thanmaster, because itchanges behavior on the authentication path for a long-standing defect rather than
repairing something 2.0 broke.
Known tradeoff (reviewer input welcome)
After an indeterminate probe, a genuinely anonymous server is now latched into requiring credentials for the life of the process, because
MarkInitializedprevents a re-probe. A server that permits anonymous access simply ignores the Authorization header, so the real exposure is narrow: an anonymous repo, with no cached credential, after a transient probe failure — which could newly prompt via GCM.Tests
GitAuthenticationTests— 6 tests covering all three probe outcomes plus the probe's own anonymity:InitialConfigProbeIsSentWithoutCredentials— the regression test for the bug above. MirrorsSendRequest's credential gate exactly (forceAnonymous || IsAnonymous) and asserts no credential is requested during the probe.InitializationWaitTimeoutMsis lowered to 500 ms so a regression fails fast instead of hanging the suite.AuthIsNotAnonymousBeforeInitialization— pins the new default.IndeterminateConfigProbeDoesNotLeaveAuthAnonymous— five real[TestCase(...)]rows (RequestTimeout,InternalServerError,ServiceUnavailable,BadRequest, and no HTTP response) so each status reports independently.IndeterminateConfigProbeStillAuthenticatesLaterRequests— credentials remain obtainable afterward.AnonymousConfigProbeSuccessLeavesAuthAnonymous,UnauthorizedConfigProbeRequiresAuthentication— guards against over-correcting.Mutation testing, corrected. The original description claimed each half of the fix independently failed three tests. That was wrong — it reflected a combined revert. Re-run per half:
forceAnonymous: truefrom the probeInitialConfigProbeIsSentWithoutCredentialsIsAnonymousdefault totrueAuthIsNotAnonymousBeforeInitialization(1 test, not 3)IsAnonymous = falseon the indeterminate branchThe third result is expected: with the default now
false, that assignment cannot change behavior. It is kept as defensive symmetry so each branch states its own outcome, and is commented as such.Full unit suite: 948 tests, 0 failed (11 pre-existing ignored). StyleCop clean.
Why no functional test
The repro needs
/gvfs/configto fail transiently while the cache-server path stays reachable. The functional suite clones from a live server and has no HTTP fault-injection seam, and the config endpoint URL derives from the sameRepoUrlas object downloads, so the two cannot be failed independently. A true end-to-end unit test is also not yet possible here:HttpRequestorhas noHttpMessageHandlerseam onvnext(#2082 adds one). The new test therefore mirrors the production gate rather than drivingSendRequestdirectly.