Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
64e5d33
Update default Microsoft Git version to v2.54.0.vfs.0.5
dscho Jul 16, 2026
20566f1
Merge pull request #2069 from microsoft/automation/gitrelease-v2.54.0…
dscho Jul 16, 2026
72ef651
ci: run build.yaml on vnext
tyrielv Jul 15, 2026
46c694f
Split blob-hydration and directory-enumeration failure telemetry by c…
tyrielv Jul 20, 2026
985033a
Merge pull request #2068 from tyrielv/tyrielv/vnext-ci-triggers
tyrielv Jul 20, 2026
25aba44
Bump actions/setup-dotnet from 5 to 6
dependabot[bot] Jul 21, 2026
c81ea97
Merge pull request #2072 from microsoft/dependabot/github_actions/act…
tyrielv Jul 21, 2026
4e56660
Update default Microsoft Git version to v2.55.0.vfs.0.3
dscho Jul 27, 2026
3b7ac38
Merge pull request #2073 from microsoft/automation/gitrelease-v2.55.0…
dscho Jul 27, 2026
0c817b0
gvfs health: distinguish directory-scoped status from repository status
Jul 30, 2026
0fc73a2
Merge pull request #2071 from tyrielv/tyrielv/split-hydration-enum-te…
tyrielv Aug 5, 2026
13794ab
Attribute wrapped blob-hydration failures to their inner cause
tyrielv Aug 5, 2026
2e85256
Merge pull request #2077 from tyrielv/tyrielv/hydration-retryable-unwrap
tyrielv Aug 6, 2026
ff1abe5
Update default Microsoft Git version to v2.55.0.vfs.0.6
dscho Aug 6, 2026
138349b
Merge pull request #2079 from microsoft/automation/gitrelease-v2.55.0…
dscho Aug 6, 2026
53e8ad4
Merge pull request #2078 from microsoft/johnc/gvfs-health-directory-c…
tyrielv Aug 6, 2026
b9b3ae1
Mount: retry hook copy and tolerate transiently-locked hooks
tyrielv Aug 5, 2026
c7fa968
Merge pull request #2075 from tyrielv/tyvella/fix-mount-hook-copy-retry
tyrielv Aug 10, 2026
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
8 changes: 4 additions & 4 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ run-name: ${{ inputs.run_name || 'VFS for Git' }}

on:
pull_request:
branches: [ master, releases/shipped ]
branches: [ master, releases/shipped, vnext ]
push:
branches: [ master, releases/shipped ]
branches: [ master, releases/shipped, vnext ]
workflow_dispatch:
inputs:
git_version:
Expand All @@ -24,7 +24,7 @@ permissions:
checks: read

env:
GIT_VERSION: ${{ github.event.inputs.git_version || 'v2.54.0.vfs.0.4' }}
GIT_VERSION: ${{ github.event.inputs.git_version || 'v2.55.0.vfs.0.6' }}

jobs:
validate:
Expand Down Expand Up @@ -283,7 +283,7 @@ jobs:

- name: Install .NET SDK
if: steps.skip.outputs.result != 'true'
uses: actions/setup-dotnet@v5
uses: actions/setup-dotnet@v6
with:
global-json-file: src/global.json

Expand Down
125 changes: 96 additions & 29 deletions GVFS/GVFS.Common/FileSystem/HooksInstaller.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
using GVFS.Common.Tracing;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
Expand Down Expand Up @@ -183,7 +182,7 @@ public static bool TryHooksInstallationAction(Action action, out string errorMes
{
if (retriesLeft == 0)
{
errorMessage = re.InnerException.ToString();
errorMessage = (re.InnerException ?? re).ToString();
return false;
}

Expand All @@ -209,7 +208,7 @@ private static bool TryUpdateHook(
return TryUpdateHook(context, hook.Name, installedHookPath, enlistmentHookPath, out errorMessage);
}

private static bool TryUpdateHook(
internal static bool TryUpdateHook(
GVFSContext context,
string hookName,
string installedHookPath,
Expand All @@ -228,47 +227,66 @@ private static bool TryUpdateHook(
{
copyHook = true;

EventMetadata metadata = new EventMetadata();
metadata.Add("Area", "Mount");
metadata.Add(nameof(enlistmentHookPath), enlistmentHookPath);
metadata.Add(nameof(installedHookPath), installedHookPath);
metadata.Add(TracingConstants.MessageKey.WarningMessage, hookName + " not found in enlistment, copying from installation folder");
context.Tracer.RelatedWarning(hookName + " MissingFromEnlistment", metadata);
EventMetadata metadata = CreateHookEventMetadata(installedHookPath, enlistmentHookPath);
metadata.Add("HookUpdateResult", "MissingFromEnlistment");
context.Tracer.RelatedWarning(metadata, hookName + " not found in enlistment, copying from installation folder", Keywords.Telemetry);
}
else
{
try
{
FileVersionInfo enlistmentVersion = FileVersionInfo.GetVersionInfo(enlistmentHookPath);
FileVersionInfo installedVersion = FileVersionInfo.GetVersionInfo(installedHookPath);
copyHook = enlistmentVersion.FileVersion != installedVersion.FileVersion;
// Compare the enlistment hook against the installed hook by FileVersion.
// These native hook binaries embed their GVFS version in the PE version
// resource, so the version differs only when a GVFS upgrade changed the
// hook - which is rare (roughly monthly) compared to daily mounts. So the
// common daily mount does no copy, and a copy happens on the first mount
// after an upgrade.
copyHook = !HookVersionsMatch(context, installedHookPath, enlistmentHookPath);
}
catch (Exception e)
{
EventMetadata metadata = new EventMetadata();
metadata.Add("Area", "Mount");
metadata.Add(nameof(enlistmentHookPath), enlistmentHookPath);
metadata.Add(nameof(installedHookPath), installedHookPath);
// Reading the version opens the hook files, either of which can be
// transiently locked (open handle, AV scan) - the same failure class the
// copy path is hardened against. Do not fail the mount here: assume the
// enlistment hook may be stale, set copyHook so the resilient copy path
// runs (retry with backoff, then the "already matches" recheck). If a lock
// persists, that path reports the error after exhausting retries.
EventMetadata metadata = CreateHookEventMetadata(installedHookPath, enlistmentHookPath);
metadata.Add("Exception", e.ToString());
context.Tracer.RelatedError(metadata, "Failed to compare " + hookName + " version");
errorMessage = "Error comparing " + hookName + " versions. " + ConsoleHelper.GetGVFSLogMessage(context.Enlistment.WorkingDirectoryRoot);
return false;
metadata.Add("HookUpdateResult", "CompareFailed");
context.Tracer.RelatedWarning(metadata, "Failed to compare " + hookName + " version; will attempt to refresh the hook", Keywords.Telemetry);
copyHook = true;
}
}

if (copyHook)
{
try
// Retry the copy with backoff, matching the clone-time InstallHooks path.
// The enlistment hook can be transiently locked (open handle, AV scan),
// in which case the rename fails with a RetryableException wrapping
// ERROR_ACCESS_DENIED. A transient lock must not be fatal to the mount.
if (!TryHooksInstallationAction(() => CopyHook(context, installedHookPath, enlistmentHookPath), out string copyError))
{
CopyHook(context, installedHookPath, enlistmentHookPath);
}
catch (Exception e)
{
EventMetadata metadata = new EventMetadata();
metadata.Add("Area", "Mount");
metadata.Add(nameof(enlistmentHookPath), enlistmentHookPath);
metadata.Add(nameof(installedHookPath), installedHookPath);
metadata.Add("Exception", e.ToString());
// The copy could not complete after retries. If the enlistment hook
// already matches the installed one, the binary is correct and the
// lock is harmless - treat it as success rather than killing the mount.
if (HookExistsAndVersionMatches(context, installedHookPath, enlistmentHookPath))
{
EventMetadata alreadyCorrect = CreateHookEventMetadata(installedHookPath, enlistmentHookPath);
alreadyCorrect.Add("CopyError", copyError);
alreadyCorrect.Add("HookUpdateResult", "LockedButAlreadyCorrect");
context.Tracer.RelatedWarning(
alreadyCorrect,
hookName + " could not be re-copied but already matches the installed hook; continuing",
Keywords.Telemetry);

errorMessage = null;
return true;
}

EventMetadata metadata = CreateHookEventMetadata(installedHookPath, enlistmentHookPath);
metadata.Add("Exception", copyError);
metadata.Add("HookUpdateResult", "CopyFailed");
context.Tracer.RelatedError(metadata, "Failed to copy " + hookName + " to enlistment");
errorMessage = "Error copying " + hookName + " to enlistment. " + ConsoleHelper.GetGVFSLogMessage(context.Enlistment.WorkingDirectoryRoot);
return false;
Expand All @@ -279,6 +297,55 @@ private static bool TryUpdateHook(
return true;
}

/// <summary>
/// Seeds an <see cref="EventMetadata"/> with the fields common to every mount-time
/// hook-update outcome. Callers add an outcome-specific "HookUpdateResult" value (and
/// any exception detail) so all outcomes are queryable by that field.
/// </summary>
private static EventMetadata CreateHookEventMetadata(string installedHookPath, string enlistmentHookPath)
{
EventMetadata metadata = new EventMetadata();
metadata.Add("Area", "Mount");
metadata.Add(nameof(enlistmentHookPath), enlistmentHookPath);
metadata.Add(nameof(installedHookPath), installedHookPath);
return metadata;
}

/// <summary>
/// Returns true only when both files report the same, non-empty FileVersion. An
/// absent/empty version is treated as "cannot confirm identical" (not a match), so the
/// resilient copy path runs. Otherwise two version-less binaries would compare equal
/// (string.Equals(null, null) == true) and the hook would never be refreshed, silently
/// defeating the self-heal this comparison provides. Both files must exist.
/// </summary>
private static bool HookVersionsMatch(GVFSContext context, string installedHookPath, string enlistmentHookPath)
{
string installedVersion = context.FileSystem.GetFileVersion(installedHookPath);
string enlistmentVersion = context.FileSystem.GetFileVersion(enlistmentHookPath);

return !string.IsNullOrEmpty(installedVersion)
&& string.Equals(installedVersion, enlistmentVersion, StringComparison.OrdinalIgnoreCase);
}

/// <summary>
/// Returns true only when the enlistment hook exists and its FileVersion matches the
/// installed hook. Any failure to read or compare (for example, the file is exclusively
/// locked) is treated as "does not match" so callers do not mistake an unknown state
/// for success.
/// </summary>
private static bool HookExistsAndVersionMatches(GVFSContext context, string installedHookPath, string enlistmentHookPath)
{
try
{
return context.FileSystem.FileExists(enlistmentHookPath)
&& HookVersionsMatch(context, installedHookPath, enlistmentHookPath);
}
catch (Exception)
{
return false;
}
}

public class HooksConfigurationException : Exception
{
public HooksConfigurationException(string message)
Expand Down
16 changes: 3 additions & 13 deletions GVFS/GVFS.Common/FileSystem/PhysicalFileSystem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -258,19 +258,9 @@ public virtual string[] GetFiles(string directoryPath, string mask)
return Directory.GetFiles(directoryPath, mask);
}

public virtual FileVersionInfo GetVersionInfo(string path)
public virtual string GetFileVersion(string path)
{
return FileVersionInfo.GetVersionInfo(path);
}

public virtual bool FileVersionsMatch(FileVersionInfo versionInfo1, FileVersionInfo versionInfo2)
{
return versionInfo1.FileVersion == versionInfo2.FileVersion;
}

public virtual bool ProductVersionsMatch(FileVersionInfo versionInfo1, FileVersionInfo versionInfo2)
{
return versionInfo1.ProductVersion == versionInfo2.ProductVersion;
return FileVersionInfo.GetVersionInfo(path).FileVersion;
}

public bool TryWriteTempFileAndRename(string destinationPath, string contents, out Exception handledException)
Expand Down Expand Up @@ -310,7 +300,7 @@ public bool TryWriteTempFileAndRename(string destinationPath, string contents, o
}
}

public bool TryCopyToTempFileAndRename(string sourcePath, string destinationPath, out Exception handledException)
public virtual bool TryCopyToTempFileAndRename(string sourcePath, string destinationPath, out Exception handledException)
{
handledException = null;
string tempFilePath = destinationPath + ".temp";
Expand Down
79 changes: 77 additions & 2 deletions GVFS/GVFS.Common/Git/GVFSGitObjects.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.ComponentModel;
using System.IO;
using System.Net;
using System.Threading;
Expand Down Expand Up @@ -33,14 +34,48 @@ public enum RequestSource
SymLinkCreation,
}

/// <summary>
/// Why a blob-hydration request ultimately failed. Recorded on the terminal failure
/// telemetry so failures outside gvfs.exe's control (network, local disk/IO, ProjFS)
/// can be told apart from failures that point at an actionable bug or a server/data
/// problem. Kept in sync with the telemetry bucketing in the devprod.git.telemetry
/// workbook (gvfs-regression-signatures.kql).
/// </summary>
public enum BlobHydrationFailureCategory
{
None = 0,

// Outside gvfs.exe's control:
NetworkUnavailable, // A network/HTTP-layer exception while fetching the blob.
DownloadFailed, // The blob download reported failure (transient/unclassified).
LocalIO, // IOException reading the local object or streaming to the ProjFS buffer.
ProjFSWriteFailed, // ProjFS WriteFileData returned a non-recoverable error.

// Actionable (bug, corruption, or server/data problem):
ObjectNotOnServer, // The cache server returned 404 for the blob.
LocalCopyFailed, // Blob downloaded, but the subsequent local copy still failed.
SizeMismatch, // Blob length did not match the length ProjFS requested.
Unexpected, // Unclassified exception.
}

protected GVFSContext Context { get; private set; }

public virtual bool TryCopyBlobContentStream(
string sha,
CancellationToken cancellationToken,
RequestSource requestSource,
Action<Stream, long> writeAction)
Action<Stream, long> writeAction,
out BlobHydrationFailureCategory failureCategory)
{
// Track the outcome of the most recent attempt so that the terminal failure
// telemetry can attribute the failure to a cause (network vs. object-missing vs.
// local copy) that is otherwise collapsed into the bool return value below. The
// final category is also surfaced via the out parameter so the caller can tag its
// own terminal telemetry with the same cause.
DownloadAndSaveObjectResult lastDownloadResult = DownloadAndSaveObjectResult.Error;
bool downloadSucceededButCopyFailed = false;
BlobHydrationFailureCategory capturedCategory = BlobHydrationFailureCategory.None;

RetryWrapper<bool> retrier = new RetryWrapper<bool>(this.GitObjectRequestor.RetryConfig.MaxAttempts, cancellationToken);
retrier.OnFailure +=
errorArgs =>
Expand All @@ -50,10 +85,44 @@ public virtual bool TryCopyBlobContentStream(
metadata.Add("AttemptNumber", errorArgs.TryCount);
metadata.Add("WillRetry", errorArgs.WillRetry);

BlobHydrationFailureCategory category;
if (errorArgs.Error != null)
{
metadata.Add("Exception", errorArgs.Error.ToString());

// A RetryableException wraps its real cause in InnerException, so inspect the
// inner exception rather than the RetryableException type. On this branch the
// exception arrives from Context.Repository.TryCopyBlobContentStream - typically
// StreamUtil wrapping an IOException while reading a corrupt/truncated local
// loose object (UnauthorizedAccessException/Win32Exception are treated the same
// as they belong to the local disk/IO family). Without this unwrap every
// RetryableException - the single largest hydration-failure bucket in the field -
// is misattributed to NetworkUnavailable even when the cause is local disk/IO. A
// stream-read IOException can still originate in the download layer, but we cannot
// tell where it came from, so it is bucketed as local IO.
Exception rootError = (errorArgs.Error as RetryableException)?.InnerException ?? errorArgs.Error;
category = rootError is IOException || rootError is UnauthorizedAccessException || rootError is Win32Exception
? BlobHydrationFailureCategory.LocalIO
: BlobHydrationFailureCategory.NetworkUnavailable;
}
else if (downloadSucceededButCopyFailed)
{
category = BlobHydrationFailureCategory.LocalCopyFailed;
}
else if (lastDownloadResult == DownloadAndSaveObjectResult.ObjectNotOnServer)
{
category = BlobHydrationFailureCategory.ObjectNotOnServer;
}
else
{
// The download reported failure without an exception; the cause (network,
// disk-save, etc.) is unclassified, so use the neutral DownloadFailed bucket
// rather than over-asserting NetworkUnavailable.
category = BlobHydrationFailureCategory.DownloadFailed;
}

capturedCategory = category;
metadata.Add(nameof(BlobHydrationFailureCategory), category.ToString());

string message = "TryCopyBlobContentStream: Failed to provide blob contents";
if (errorArgs.WillRetry)
Expand All @@ -76,19 +145,25 @@ public virtual bool TryCopyBlobContentStream(
}
else
{
downloadSucceededButCopyFailed = false;

// Pass in false for retryOnFailure because the retrier in this method manages multiple attempts
if (this.TryDownloadAndSaveObject(sha, cancellationToken, requestSource, retryOnFailure: false) == DownloadAndSaveObjectResult.Success)
lastDownloadResult = this.TryDownloadAndSaveObject(sha, cancellationToken, requestSource, retryOnFailure: false);
if (lastDownloadResult == DownloadAndSaveObjectResult.Success)
{
if (this.Context.Repository.TryCopyBlobContentStream(sha, writeAction))
{
return new RetryWrapper<bool>.CallbackResult(true);
}

downloadSucceededButCopyFailed = true;
}

return new RetryWrapper<bool>.CallbackResult(error: null, shouldRetry: true);
}
});

failureCategory = invokeResult.Result ? BlobHydrationFailureCategory.None : capturedCategory;
return invokeResult.Result;
}

Expand Down
Loading
Loading