diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 32706260ff..e7de3e1fb9 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -24,7 +24,7 @@ permissions: checks: read env: - GIT_VERSION: ${{ github.event.inputs.git_version || 'v2.55.0.vfs.0.10' }} + GIT_VERSION: ${{ github.event.inputs.git_version || 'v2.56.0.vfs.0.4' }} jobs: validate: @@ -249,7 +249,7 @@ jobs: - name: Upload microsoft/git installers if: steps.check.outputs.result == '' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: MicrosoftGit path: MicrosoftGit @@ -308,21 +308,21 @@ jobs: - name: Upload functional tests drop if: steps.skip.outputs.result != 'true' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: FunctionalTests_${{ matrix.configuration }}_${{ matrix.architecture }} path: artifacts\GVFS.FunctionalTests - name: Upload FastFetch drop if: steps.skip.outputs.result != 'true' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: FastFetch_${{ matrix.configuration }}_${{ matrix.architecture }} path: artifacts\FastFetch - name: Upload GVFS installer if: steps.skip.outputs.result != 'true' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: GVFS_${{ matrix.configuration }}_${{ matrix.architecture }} path: artifacts\GVFS.Installers diff --git a/.github/workflows/functional-tests.yaml b/.github/workflows/functional-tests.yaml index 110e3c14b8..5b05a0f1a0 100644 --- a/.github/workflows/functional-tests.yaml +++ b/.github/workflows/functional-tests.yaml @@ -80,7 +80,7 @@ jobs: id: download-git if: steps.skip.outputs.result != 'true' continue-on-error: true - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: ${{ inputs.git_artifact_name }} path: git @@ -90,7 +90,7 @@ jobs: - name: Download Git installer (retry) if: steps.skip.outputs.result != 'true' && steps.download-git.outcome == 'failure' - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: ${{ inputs.git_artifact_name }} path: git @@ -102,7 +102,7 @@ jobs: id: download-gvfs if: steps.skip.outputs.result != 'true' continue-on-error: true - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: GVFS_${{ matrix.configuration }}_${{ matrix.architecture }} path: gvfs @@ -112,7 +112,7 @@ jobs: - name: Download GVFS installer (retry) if: steps.skip.outputs.result != 'true' && steps.download-gvfs.outcome == 'failure' - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: GVFS_${{ matrix.configuration }}_${{ matrix.architecture }} path: gvfs @@ -124,7 +124,7 @@ jobs: id: download-ft if: steps.skip.outputs.result != 'true' continue-on-error: true - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: FunctionalTests_${{ matrix.configuration }}_${{ matrix.architecture }} path: ft @@ -134,7 +134,7 @@ jobs: - name: Download functional tests drop (retry) if: steps.skip.outputs.result != 'true' && steps.download-ft.outcome == 'failure' - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: FunctionalTests_${{ matrix.configuration }}_${{ matrix.architecture }} path: ft @@ -145,7 +145,7 @@ jobs: - name: Download FastFetch drop if: steps.skip.outputs.result != 'true' continue-on-error: true - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: FastFetch_${{ matrix.configuration }}_${{ matrix.architecture }} path: ft @@ -189,7 +189,7 @@ jobs: - name: Upload installation logs if: always() && steps.skip.outputs.result != 'true' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 continue-on-error: true with: name: ${{ env.ARTIFACT_PREFIX }}InstallationLogs_${{ env.FT_MATRIX_NAME }} @@ -209,7 +209,7 @@ jobs: - name: Upload failure diagnostics (mount dumps + logs) if: always() && steps.skip.outputs.result != 'true' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 continue-on-error: true with: name: ${{ env.ARTIFACT_PREFIX }}FailureDiagnostics_${{ env.FT_MATRIX_NAME }} @@ -218,14 +218,14 @@ jobs: - name: Upload functional test results if: always() && steps.skip.outputs.result != 'true' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: ${{ env.ARTIFACT_PREFIX }}FunctionalTests_Results_${{ env.FT_MATRIX_NAME }} path: TestResult.xml - name: Upload Git trace2 output if: always() && steps.skip.outputs.result != 'true' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: ${{ env.ARTIFACT_PREFIX }}GitTrace2_${{ env.FT_MATRIX_NAME }} path: C:\temp\git-trace2.log diff --git a/.github/workflows/upgrade-tests.yaml b/.github/workflows/upgrade-tests.yaml index 04cb26f6b1..75fdbfd3be 100644 --- a/.github/workflows/upgrade-tests.yaml +++ b/.github/workflows/upgrade-tests.yaml @@ -66,14 +66,14 @@ jobs: id: download-git if: steps.skip.outputs.result != 'true' continue-on-error: true - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: MicrosoftGit path: git - name: Download Git installer (retry) if: steps.skip.outputs.result != 'true' && steps.download-git.outcome == 'failure' - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: MicrosoftGit path: git @@ -82,14 +82,14 @@ jobs: id: download-gvfs if: steps.skip.outputs.result != 'true' continue-on-error: true - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: GVFS_${{ matrix.configuration }}_x64 path: gvfs-new - name: Download current GVFS installer (retry) if: steps.skip.outputs.result != 'true' && steps.download-gvfs.outcome == 'failure' - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: GVFS_${{ matrix.configuration }}_x64 path: gvfs-new @@ -387,7 +387,7 @@ jobs: - name: Upload service logs if: always() && steps.skip.outputs.result != 'true' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 continue-on-error: true with: name: UpgradeTest_Logs_${{ matrix.scenario }} diff --git a/GVFS/FastFetch/CheckoutPrefetcher.cs b/GVFS/FastFetch/CheckoutPrefetcher.cs index 4064aa7bc0..2b2f827979 100644 --- a/GVFS/FastFetch/CheckoutPrefetcher.cs +++ b/GVFS/FastFetch/CheckoutPrefetcher.cs @@ -134,9 +134,9 @@ public override void Prefetch(string branchOrCommit, bool isBranch) bool allowIndexMetadataUpdateFromWorkingTree = this.allowIndexMetadataUpdateFromWorkingTree || checkout.UpdatedWholeTree; newIndex.UpdateFileSizesAndTimes(checkout.AddedOrEditedLocalFiles, allowIndexMetadataUpdateFromWorkingTree, shouldSignIndex, sourceIndex); - // All the slow stuff is over, so we will now move the final index into .git\index, shortly followed by + // All the slow stuff is over, so we will now move the final index into place, shortly followed by // updating the ref files and releasing index.lock. - string indexPath = Path.Combine(this.Enlistment.DotGitRoot, GVFSConstants.DotGit.IndexName); + string indexPath = this.Enlistment.GitIndexPath; this.Tracer.RelatedEvent(EventLevel.Informational, "MoveUpdatedIndexToFinalLocation", new EventMetadata() { { "UpdatedIndex", indexGen.TemporaryIndexFilePath }, { "Index", indexPath } }); File.Delete(indexPath); File.Move(indexGen.TemporaryIndexFilePath, indexPath); @@ -196,7 +196,7 @@ protected override void UpdateRefs(string branchOrCommit, bool isBranch, GitRefs private Index GetSourceIndex() { - string indexPath = Path.Combine(this.Enlistment.DotGitRoot, GVFSConstants.DotGit.IndexName); + string indexPath = this.Enlistment.GitIndexPath; if (File.Exists(indexPath)) { diff --git a/GVFS/GVFS.Common/DiskLayoutUpgrades/DiskLayoutUpgrade.cs b/GVFS/GVFS.Common/DiskLayoutUpgrades/DiskLayoutUpgrade.cs index d268191abc..96d713e17c 100644 --- a/GVFS/GVFS.Common/DiskLayoutUpgrades/DiskLayoutUpgrade.cs +++ b/GVFS/GVFS.Common/DiskLayoutUpgrades/DiskLayoutUpgrade.cs @@ -16,10 +16,12 @@ public abstract class DiskLayoutUpgrade protected abstract int SourceMinorVersion { get; } protected abstract bool IsMajorUpgrade { get; } - public static bool TryRunAllUpgrades(string enlistmentRoot) + public static bool TryRunAllUpgrades(string enlistmentRoot, out string error, out ReturnCode returnCode) { majorVersionUpgrades = new Dictionary(); minorVersionUpgrades = new Dictionary>(); + error = null; + returnCode = ReturnCode.Success; foreach (DiskLayoutUpgrade upgrade in GVFSPlatform.Instance.DiskLayoutUpgrade.Upgrades) { @@ -31,7 +33,7 @@ public static bool TryRunAllUpgrades(string enlistmentRoot) try { DiskLayoutUpgrade upgrade = null; - while (TryFindUpgrade(tracer, enlistmentRoot, out upgrade)) + while (TryFindUpgrade(tracer, enlistmentRoot, out upgrade, out error, out returnCode)) { if (upgrade == null) { @@ -40,11 +42,13 @@ public static bool TryRunAllUpgrades(string enlistmentRoot) if (!upgrade.TryUpgrade(tracer, enlistmentRoot)) { + returnCode = ReturnCode.GenericError; return false; } if (!CheckLayoutVersionWasIncremented(tracer, enlistmentRoot, upgrade)) { + returnCode = ReturnCode.GenericError; return false; } } @@ -55,6 +59,8 @@ public static bool TryRunAllUpgrades(string enlistmentRoot) { StartLogFile(enlistmentRoot, tracer); tracer.RelatedError(e.ToString()); + error = e.Message; + returnCode = ReturnCode.GenericError; return false; } finally @@ -64,17 +70,19 @@ public static bool TryRunAllUpgrades(string enlistmentRoot) } } - public static bool TryCheckDiskLayoutVersion(ITracer tracer, string enlistmentRoot, out string error) + public static bool TryCheckDiskLayoutVersion(ITracer tracer, string enlistmentRoot, out string error, out ReturnCode returnCode) { error = string.Empty; + returnCode = ReturnCode.Success; int majorVersion; int minorVersion; try { - if (TryGetDiskLayoutVersion(tracer, enlistmentRoot, out majorVersion, out minorVersion, out error)) + if (TryGetDiskLayoutVersion(tracer, enlistmentRoot, out majorVersion, out minorVersion, out error, out returnCode)) { if (majorVersion < GVFSPlatform.Instance.DiskLayoutUpgrade.Version.MinimumSupportedMajorVersion) { + returnCode = ReturnCode.GenericError; error = string.Format( "Breaking change to GVFS disk layout has been made since cloning. \r\nEnlistment disk layout version: {0} \r\nGVFS disk layout version: {1} \r\nMinimum supported version: {2}", majorVersion, @@ -85,6 +93,7 @@ public static bool TryCheckDiskLayoutVersion(ITracer tracer, string enlistmentRo } else if (majorVersion > GVFSPlatform.Instance.DiskLayoutUpgrade.Version.CurrentMajorVersion) { + returnCode = ReturnCode.GenericError; error = string.Format( "Changes to GVFS disk layout do not allow mounting after downgrade. Try mounting again using a more recent version of GVFS. \r\nEnlistment disk layout version: {0} \r\nGVFS disk layout version: {1}", majorVersion, @@ -94,6 +103,7 @@ public static bool TryCheckDiskLayoutVersion(ITracer tracer, string enlistmentRo } else if (majorVersion != GVFSPlatform.Instance.DiskLayoutUpgrade.Version.CurrentMajorVersion) { + returnCode = ReturnCode.GenericError; error = string.Format( "GVFS disk layout version doesn't match current version. Try running 'gvfs mount' to upgrade. \r\nEnlistment disk layout version: {0}.{1} \r\nGVFS disk layout version: {2}.{3}", majorVersion, @@ -112,7 +122,12 @@ public static bool TryCheckDiskLayoutVersion(ITracer tracer, string enlistmentRo RepoMetadata.Shutdown(); } - error = "Failed to read disk layout version. " + ConsoleHelper.GetGVFSLogMessage(enlistmentRoot); + if (returnCode != ReturnCode.MissingDiskLayoutVersion) + { + returnCode = ReturnCode.GenericError; + error = "Failed to read disk layout version. " + ConsoleHelper.GetGVFSLogMessage(enlistmentRoot); + } + return false; } @@ -256,13 +271,12 @@ private static bool CheckLayoutVersionWasIncremented(JsonTracer tracer, string e return true; } - private static bool TryFindUpgrade(JsonTracer tracer, string enlistmentRoot, out DiskLayoutUpgrade upgrade) + private static bool TryFindUpgrade(JsonTracer tracer, string enlistmentRoot, out DiskLayoutUpgrade upgrade, out string error, out ReturnCode returnCode) { int majorVersion; int minorVersion; - string error; - if (!TryGetDiskLayoutVersion(tracer, enlistmentRoot, out majorVersion, out minorVersion, out error)) + if (!TryGetDiskLayoutVersion(tracer, enlistmentRoot, out majorVersion, out minorVersion, out error, out returnCode)) { StartLogFile(enlistmentRoot, tracer); tracer.RelatedError(error); @@ -300,6 +314,8 @@ private static bool TryFindUpgrade(JsonTracer tracer, string enlistmentRoot, out // return true to indicate that we succeeded, and no upgrader was found upgrade = null; + error = null; + returnCode = ReturnCode.Success; return true; } @@ -309,9 +325,22 @@ private static bool TryGetDiskLayoutVersion( out int majorVersion, out int minorVersion, out string error) + { + ReturnCode returnCode; + return TryGetDiskLayoutVersion(tracer, enlistmentRoot, out majorVersion, out minorVersion, out error, out returnCode); + } + + private static bool TryGetDiskLayoutVersion( + ITracer tracer, + string enlistmentRoot, + out int majorVersion, + out int minorVersion, + out string error, + out ReturnCode returnCode) { majorVersion = 0; minorVersion = 0; + returnCode = ReturnCode.GenericError; string dotGVFSPath = Path.Combine(enlistmentRoot, GVFSPlatform.Instance.Constants.DotGVFSRoot); @@ -323,13 +352,19 @@ private static bool TryGetDiskLayoutVersion( return false; } - if (!RepoMetadata.Instance.TryGetOnDiskLayoutVersion(out majorVersion, out minorVersion, out error)) + if (!RepoMetadata.Instance.TryGetOnDiskLayoutVersion(out majorVersion, out minorVersion, out error, out returnCode)) { + if (returnCode == ReturnCode.MissingDiskLayoutVersion) + { + error += " " + ConsoleHelper.GetGVFSLogMessage(enlistmentRoot); + } + return false; } } error = null; + returnCode = ReturnCode.Success; return true; } diff --git a/GVFS/GVFS.Common/Enlistment.cs b/GVFS/GVFS.Common/Enlistment.cs index a9208b0edf..1faf2f59d7 100644 --- a/GVFS/GVFS.Common/Enlistment.cs +++ b/GVFS/GVFS.Common/Enlistment.cs @@ -62,6 +62,12 @@ protected Enlistment( public string WorkingDirectoryRoot { get; } public string WorkingDirectoryBackingRoot { get; } + /// + /// Path to the git directory that holds the repository metadata GVFS reads or writes + /// directly. The default is "<WorkingDirectoryBackingRoot>\.git", which is a file in a + /// linked worktree. Enlistments that support linked worktrees must set it to the + /// shared (common) git directory during construction. + /// public string DotGitRoot { get; protected set; } public abstract string GitObjectsRoot { get; protected set; } public abstract string LocalObjectsRoot { get; protected set; } diff --git a/GVFS/GVFS.Common/FileSystem/HydrationFailureDiagnostics.cs b/GVFS/GVFS.Common/FileSystem/HydrationFailureDiagnostics.cs new file mode 100644 index 0000000000..70b28521b9 --- /dev/null +++ b/GVFS/GVFS.Common/FileSystem/HydrationFailureDiagnostics.cs @@ -0,0 +1,83 @@ +using GVFS.Common.Tracing; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace GVFS.Common.FileSystem +{ + /// + /// Turns an failure exception into structured, + /// Kusto-queryable telemetry. Centralized here so every hydration call site (the prefetch + /// pipeline and the `gvfs prefetch --hydrate` verb) reports the failure identity consistently. + /// + public static class HydrationFailureDiagnostics + { + private const int FacilityWin32HResult = unchecked((int)0x80070000); + private const int FacilityMask = unchecked((int)0xFFFF0000); + private const int Win32CodeMask = 0xFFFF; + + /// + /// Builds per-file EventMetadata describing a hydration failure: exception type, HResult, + /// and (when derivable) the underlying Win32 error code. + /// + public static EventMetadata BuildMetadata(string path, Exception failure) + { + if (failure == null) + { + throw new ArgumentNullException(nameof(failure)); + } + + EventMetadata metadata = new EventMetadata + { + { "Path", path }, + { "ExceptionType", failure.GetType().Name }, + { "HResult", "0x" + failure.HResult.ToString("X8") }, + }; + + int? win32Error = TryGetWin32ErrorCode(failure.HResult); + if (win32Error.HasValue) + { + metadata.Add("Win32Error", win32Error.Value); + } + + return metadata; + } + + /// + /// Returns a short string identifying the distinct kind of failure (exception type plus + /// either the Win32 error code or the raw HResult), suitable for aggregating counts across + /// many failed files without emitting one event per file. + /// + public static string GetFailureSignature(Exception failure) + { + if (failure == null) + { + throw new ArgumentNullException(nameof(failure)); + } + + int? win32Error = TryGetWin32ErrorCode(failure.HResult); + return win32Error.HasValue + ? $"{failure.GetType().Name}(Win32={win32Error.Value})" + : $"{failure.GetType().Name}(HResult=0x{failure.HResult:X8})"; + } + + /// + /// Formats a signature-to-count dictionary as a single string (e.g. "IOException(Win32=32)=3;...") + /// for inclusion as one EventMetadata field in a summary event. + /// + public static string FormatSignatureCounts(IReadOnlyDictionary signatureCounts) + { + return string.Join(";", signatureCounts.Select(kvp => $"{kvp.Key}={kvp.Value}")); + } + + private static int? TryGetWin32ErrorCode(int hresult) + { + if ((hresult & FacilityMask) == FacilityWin32HResult) + { + return hresult & Win32CodeMask; + } + + return null; + } + } +} diff --git a/GVFS/GVFS.Common/FileSystem/IPlatformFileSystem.cs b/GVFS/GVFS.Common/FileSystem/IPlatformFileSystem.cs index b9ac2c2e8d..99509a0cc6 100644 --- a/GVFS/GVFS.Common/FileSystem/IPlatformFileSystem.cs +++ b/GVFS/GVFS.Common/FileSystem/IPlatformFileSystem.cs @@ -11,7 +11,16 @@ public interface IPlatformFileSystem bool TryGetNormalizedPath(string path, out string normalizedPath, out string errorMessage); void SetDirectoryLastWriteTime(string path, DateTime lastWriteTime, out bool directoryExists); void ChangeMode(string path, ushort mode); - bool HydrateFile(string fileName, byte[] buffer); + /// + /// Hydrates a file by reading it, forcing any virtualization layer to materialize its content. + /// + /// Path of the file to hydrate. + /// Scratch buffer used to read from the file. Must be at least 1 byte. + /// + /// The exception that caused hydration to fail, or null if hydration succeeded. + /// + /// True if the file was hydrated successfully, false otherwise. + bool HydrateFile(string fileName, byte[] buffer, out Exception failure); bool IsExecutable(string filePath); bool IsSocket(string filePath); bool TryCreateDirectoryAccessibleByAuthUsers(string directoryPath, out string error, ITracer tracer = null); diff --git a/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs b/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs index 26e2de306f..959bd0eafd 100644 --- a/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs +++ b/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs @@ -176,6 +176,7 @@ private static WorktreeInfo TryParseWorktreeGitFile(string worktreeRoot, string /// Returns the working directory paths of all worktrees registered /// under /worktrees by reading each entry's /// gitdir file. The primary worktree is not included. + /// Unreadable registrations are skipped. Do not use this method to guard destructive operations. /// public static string[] GetKnownWorktreePaths(string gitDir) { @@ -212,6 +213,103 @@ public static string[] GetKnownWorktreePaths(string gitDir) return paths.ToArray(); } + /// + /// Reads registrations for destructive-operation guards. + /// Missing or empty reverse pointers are ignored only when no other metadata remains. + /// + public static bool TryGetKnownWorktreePaths( + string gitDir, + out string[] paths, + out string error, + out Exception exception) + { + ArgumentNullException.ThrowIfNull(gitDir); + paths = null; + error = null; + exception = null; + string pathToRead = gitDir; + try + { + // Unlike Directory.Exists, this reports access failures instead of treating them as absence. + if ((File.GetAttributes(gitDir) & FileAttributes.Directory) == 0) + { + error = $"Invalid Git directory at '{gitDir}': expected a directory."; + return false; + } + string worktreesDir = Path.Combine(gitDir, "worktrees"); + pathToRead = worktreesDir; + try + { + File.GetAttributes(worktreesDir); + } + catch (FileNotFoundException) + { + File.GetAttributes(gitDir); + paths = Array.Empty(); + return true; + } + catch (DirectoryNotFoundException) + { + File.GetAttributes(gitDir); + paths = Array.Empty(); + return true; + } + + List worktreePaths = new List(); + foreach (string entry in Directory.GetDirectories(worktreesDir)) + { + pathToRead = Path.Combine(entry, "gitdir"); + string gitdirContent = string.Empty; + try + { + using (FileStream stream = File.OpenRead(pathToRead)) + { + using (StreamReader reader = new StreamReader(stream)) + { + gitdirContent = reader.ReadToEnd(); + } + } + } + catch (FileNotFoundException) + { + } + + if (gitdirContent.Length == 0) + { + foreach (string metadataPath in Directory.GetFileSystemEntries(entry)) + { + if (!string.Equals(Path.GetFileName(metadataPath), "gitdir", StringComparison.Ordinal)) + { + error = $"Cannot verify worktree registration at '{pathToRead}': the pointer is missing or empty, but metadata remains at '{metadataPath}'."; + return false; + } + } + + continue; + } + + gitdirContent = gitdirContent.Trim().Replace('/', Path.DirectorySeparatorChar); + if (string.IsNullOrEmpty(gitdirContent) || + !string.Equals(Path.GetFileName(gitdirContent), GVFSConstants.DotGit.Root, StringComparison.Ordinal)) + { + error = $"Invalid worktree registration at '{pathToRead}': expected a path to a .git file."; + return false; + } + + worktreePaths.Add(Path.GetDirectoryName(Path.GetFullPath(gitdirContent, entry))); + } + + paths = worktreePaths.ToArray(); + return true; + } + catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException || ex is ArgumentException) + { + error = $"Failed to read worktree registrations at '{pathToRead}': {ex.Message}"; + exception = ex; + return false; + } + } + public class WorktreeInfo { public const string EnlistmentRootFileName = "gvfs-enlistment-root"; diff --git a/GVFS/GVFS.Common/GVFSEnlistment.cs b/GVFS/GVFS.Common/GVFSEnlistment.cs index 7cd441aadb..506e36a5ba 100644 --- a/GVFS/GVFS.Common/GVFSEnlistment.cs +++ b/GVFS/GVFS.Common/GVFSEnlistment.cs @@ -13,6 +13,12 @@ public partial class GVFSEnlistment : Enlistment { public const string BlobSizesCacheName = "blobSizes"; + /// + /// How often + /// polls the mount process for its status. + /// + internal const int MountStatusPollIntervalMs = 100; + private const string GitObjectCacheName = "gitObjects"; private string gitVersion; @@ -281,7 +287,9 @@ public static bool WaitUntilMounted( } else if (getStatusResponse.MountStatus == NamedPipeMessages.GetStatus.MountFailed) { - errorMessage = string.Format("Failed to mount at {0}", enlistmentRoot); + errorMessage = !string.IsNullOrWhiteSpace(getStatusResponse.MountError) + ? getStatusResponse.MountError + : string.Format("Failed to mount at {0}", enlistmentRoot); tracer.RelatedError($"{nameof(WaitUntilMounted)}: Mount failed: {errorMessage}"); return false; } @@ -292,8 +300,8 @@ public static bool WaitUntilMounted( onProgress(getStatusResponse.MountProgress); } - tracer.RelatedInfo($"{nameof(WaitUntilMounted)}: Waiting 500ms for mount process to be ready"); - Thread.Sleep(100); + tracer.RelatedInfo($"{nameof(WaitUntilMounted)}: Waiting {MountStatusPollIntervalMs}ms for mount process to be ready"); + Thread.Sleep(MountStatusPollIntervalMs); } } catch (BrokenPipeException e) diff --git a/GVFS/GVFS.Common/Git/GitAuthentication.cs b/GVFS/GVFS.Common/Git/GitAuthentication.cs index 37c3563b51..c35a11a823 100644 --- a/GVFS/GVFS.Common/Git/GitAuthentication.cs +++ b/GVFS/GVFS.Common/Git/GitAuthentication.cs @@ -1,4 +1,4 @@ -using GVFS.Common.Http; +using GVFS.Common.Http; using GVFS.Common.Tracing; using System; using System.Collections.Generic; @@ -377,9 +377,9 @@ private static bool TryParseCredentialString(string credentialString, out string if (credentialString != null) { byte[] data = Convert.FromBase64String(credentialString); - string rawCredString = Encoding.ASCII.GetString(data); + string rawCredString = Encoding.UTF8.GetString(data); - string[] usernamePassword = rawCredString.Split(':'); + string[] usernamePassword = rawCredString.Split(new[] { ':' }, 2); if (usernamePassword.Length == 2) { username = usernamePassword[0]; @@ -438,9 +438,9 @@ private bool TryCallGitCredential(ITracer tracer, out string errorMessage, int t return false; } - if (!string.IsNullOrEmpty(gitUsername) && !string.IsNullOrEmpty(gitPassword)) + if (!string.IsNullOrEmpty(gitPassword)) { - this.cachedCredentialString = Convert.ToBase64String(Encoding.ASCII.GetBytes(gitUsername + ":" + gitPassword)); + this.cachedCredentialString = Convert.ToBase64String(Encoding.UTF8.GetBytes(gitUsername + ":" + gitPassword)); this.isCachedCredentialStringApproved = false; } else diff --git a/GVFS/GVFS.Common/Git/GitIndexGenerator.cs b/GVFS/GVFS.Common/Git/GitIndexGenerator.cs index 19c5981358..5c27e32619 100644 --- a/GVFS/GVFS.Common/Git/GitIndexGenerator.cs +++ b/GVFS/GVFS.Common/Git/GitIndexGenerator.cs @@ -37,6 +37,7 @@ public class GitIndexGenerator 0, 0, 0, 0 // file length }; + private readonly string indexPath; private readonly string indexLockPath; private Enlistment enlistment; @@ -63,7 +64,8 @@ public GitIndexGenerator(ITracer tracer, Enlistment enlistment, bool shouldHashI // other is from the 'gvfs repair' verb. That environment is special in that it only runs on unmounted // repo's, so 'index.lock' is irrelevant as a locking mechanism in that context. There can't be git // commands to lock out. - this.indexLockPath = Path.Combine(enlistment.DotGitRoot, GVFSConstants.DotGit.IndexName + ".lock2"); + this.indexPath = enlistment.GitIndexPath; + this.indexLockPath = this.indexPath + ".lock2"; } public string TemporaryIndexFilePath => this.indexLockPath; @@ -82,7 +84,12 @@ public GitIndexGenerator(ITracer tracer, Enlistment enlistment, bool shouldHashI /// public void CreateFromRef(string refName, uint indexVersion, bool isFinal) { - using (ITracer updateIndexActivity = this.tracer.StartActivity("CreateFromHeadTree", EventLevel.Informational)) + EventMetadata metadata = new EventMetadata + { + { "IndexPath", this.indexPath }, + { "IsWorktree", this.enlistment is GVFSEnlistment gvfsEnlistment && gvfsEnlistment.IsWorktree }, + }; + using (ITracer updateIndexActivity = this.tracer.StartActivity("CreateFromHeadTree", EventLevel.Informational, metadata)) { Thread entryWritingThread = new Thread(() => this.WriteAllEntries(indexVersion, isFinal)); entryWritingThread.Start(); @@ -245,11 +252,10 @@ private byte[] GetIndexHash() return new byte[20]; } - private void ReplaceExistingIndex() + internal void ReplaceExistingIndex() { - string indexPath = Path.Combine(this.enlistment.DotGitRoot, GVFSConstants.DotGit.IndexName); - File.Delete(indexPath); - File.Move(this.indexLockPath, indexPath); + File.Delete(this.indexPath); + File.Move(this.indexLockPath, this.indexPath); } private class LsTreeEntry diff --git a/GVFS/GVFS.Common/Git/GitProcess.cs b/GVFS/GVFS.Common/Git/GitProcess.cs index df54900b7c..a193d92fba 100644 --- a/GVFS/GVFS.Common/Git/GitProcess.cs +++ b/GVFS/GVFS.Common/Git/GitProcess.cs @@ -37,7 +37,34 @@ public class GitProcess : ICredentialStore /// private const int MaxCapturedStdOutChars = 128 * 1024 * 1024; // ~256 MB of UTF-16 + private const string MissingPasswordErrorMessage = "Git credential fill returned no password"; + private const int MaxCredentialOutputKeys = 32; + private const int MaxCredentialOutputKeyLength = 32; + private static readonly Encoding UTF8NoBOM = new UTF8Encoding(false); + + // Key names defined by the git credential protocol. They are names, not values, so they are + // safe to trace. Any other key is reported as "". + private static readonly HashSet CredentialOutputKeyAllowlist = new HashSet(StringComparer.Ordinal) + { + "authtype", + "capability[]", + "continue", + "credential", + "ephemeral", + "host", + "oauth_refresh_token", + "password", + "password_expiry_utc", + "path", + "protocol", + "quit", + "state[]", + "url", + "username", + "wwwauth[]" + }; + private static bool failedToSetEncoding = false; private static string expireTimeDateString; @@ -55,7 +82,7 @@ public class GitProcess : ICredentialStore private string gitBinPath; private string workingDirectoryRoot; - private string dotGitRoot; + private string gitDirPath; private Process executingProcess; private bool stopping; @@ -101,7 +128,18 @@ public GitProcess(string gitBinPath, string workingDirectoryRoot) if (this.workingDirectoryRoot != null) { - this.dotGitRoot = Path.Combine(this.workingDirectoryRoot, GVFSConstants.DotGit.Root); + // Deliberately not Enlistment.DotGitRoot. In a linked worktree, --git-dir must be + // the path of the worktree's .git file, not the gitdir: target stored inside it. + // Given that file, git reads the gitdir: pointer itself. It resolves files in the + // per-worktree git directory by default (for example HEAD, index, ORIG_HEAD, + // logs/HEAD, MERGE_HEAD, CHERRY_PICK_HEAD, BISECT_START, FETCH_HEAD, rebase-merge/, + // refs/bisect, refs/worktree, refs/rewritten). Only an explicit shared set goes + // through commondir to the shared git directory (config, objects, packed-refs, + // refs/heads, hooks). + // Passing Enlistment.DotGitRoot would bind commands to the main worktree instead. + // Built from an Enlistment, workingDirectoryRoot is WorkingDirectoryBackingRoot, + // the same root that the base Enlistment uses to derive DotGitRoot. + this.gitDirPath = Path.Combine(this.workingDirectoryRoot, GVFSConstants.DotGit.Root); } } @@ -294,18 +332,16 @@ public virtual bool TryGetCertificatePassword( EventMetadata errorData = new EventMetadata(); errorData.Add("CertificatePath", certificatePath); errorData.Add("hasVisibleConsoleWindow", hasVisibleConsoleWindow); - tracer.RelatedWarning( - errorData, - "Git could not get credentials: " + gitCredentialOutput.Errors, - Keywords.Network | Keywords.Telemetry); - errorMessage = gitCredentialOutput.Errors; + AddCredentialFillResult(errorData, gitCredentialOutput); + TraceCredentialFillFailure(tracer, errorData, gitCredentialOutput.Errors); + errorMessage = GetCredentialFillFailureMessage(gitCredentialOutput); return false; } password = ParseValue(gitCredentialOutput.Output, "password="); - bool success = password != null; + bool success = !string.IsNullOrEmpty(password); EventMetadata metadata = new EventMetadata { @@ -316,7 +352,11 @@ public virtual bool TryGetCertificatePassword( if (!success) { - metadata.Add("Output", gitCredentialOutput.Output); + errorMessage = MissingPasswordErrorMessage; + EventMetadata rejectedData = new EventMetadata(); + rejectedData.Add("CertificatePath", certificatePath); + rejectedData.Add("hasVisibleConsoleWindow", hasVisibleConsoleWindow); + TraceCredentialFillMissingPassword(tracer, rejectedData, gitCredentialOutput); } activity.Stop(metadata); @@ -359,6 +399,7 @@ public virtual bool TryGetCredential( { EventMetadata errorData = new EventMetadata(); errorData.Add("hasVisibleConsoleWindow", hasVisibleConsoleWindow); + AddCredentialFillResult(errorData, gitCredentialOutput); if (gitCredentialOutput.Errors.StartsWith("Operation timed out")) { @@ -367,14 +408,12 @@ public virtual bool TryGetCredential( errorData, "Git credential fill timed out after " + timeoutMs + "ms", Keywords.Network | Keywords.Telemetry); + TraceCredentialFillErrorOutput(tracer, gitCredentialOutput.Errors); } else { - errorMessage = gitCredentialOutput.Errors; - tracer.RelatedWarning( - errorData, - "Git could not get credentials: " + gitCredentialOutput.Errors, - Keywords.Network | Keywords.Telemetry); + errorMessage = GetCredentialFillFailureMessage(gitCredentialOutput); + TraceCredentialFillFailure(tracer, errorData, gitCredentialOutput.Errors); } return false; @@ -388,16 +427,20 @@ public virtual bool TryGetCredential( consoleMetadata.Add("hasVisibleConsoleWindow", hasVisibleConsoleWindow); activity.RelatedInfo(consoleMetadata, "Credential fill console window visibility"); - username = ParseValue(gitCredentialOutput.Output, "username="); + // Some helpers return only a token. An absent username is valid; + // an absent password is not. + username = ParseValue(gitCredentialOutput.Output, "username=") ?? string.Empty; password = ParseValue(gitCredentialOutput.Output, "password="); - bool success = username != null && password != null; - + bool success = !string.IsNullOrEmpty(password); EventMetadata metadata = new EventMetadata(); metadata.Add("Success", success); if (!success) { - metadata.Add("Output", gitCredentialOutput.Output); + errorMessage = MissingPasswordErrorMessage; + EventMetadata rejectedData = new EventMetadata(); + rejectedData.Add("hasVisibleConsoleWindow", hasVisibleConsoleWindow); + TraceCredentialFillMissingPassword(tracer, rejectedData, gitCredentialOutput); } activity.Stop(metadata); @@ -1138,22 +1181,143 @@ private static bool CurrentProcessHasVisibleConsoleWindowSafe() } } + private static void AddCredentialFillResult(EventMetadata data, Result result) + { + // Describes the failure without the text of stderr, which can contain a secret. + data.Add("ExitCode", result.ExitCode); + data.Add("ErrorsLength", result.Errors.Length); + data.Add("ErrorsTruncated", result.ErrorsTruncated); + } + + private static void TraceCredentialFillFailure(ITracer tracer, EventMetadata errorData, string errors) + { + tracer.RelatedWarning(errorData, "Git could not get credentials", Keywords.Network | Keywords.Telemetry); + TraceCredentialFillErrorOutput(tracer, errors); + } + + private static void TraceCredentialFillErrorOutput(ITracer tracer, string errors) + { + // Git can echo unparseable helper output, including secrets, to stderr. Telemetry events and + // the error message that is returned to callers carry no stderr; the text stays in the local log. + tracer.RelatedWarning("Git credential fill error output: " + errors); + } + + private static string GetCredentialFillFailureMessage(Result result) + { + return "Git credential fill failed with exit code " + result.ExitCode + "; see the log for its error output"; + } + + private static void TraceCredentialFillMissingPassword(ITracer tracer, EventMetadata errorData, Result result) + { + AddCredentialFillResult(errorData, result); + errorData.Add("OutputKeys", GetCredentialOutputKeys(result.Output, result.OutputTruncated)); + tracer.RelatedWarning(errorData, MissingPasswordErrorMessage, Keywords.Network | Keywords.Telemetry); + } + + /// + /// Summarizes the output of "git credential fill" for diagnostics. + /// The output is a list of "key=value" lines that can include the + /// plaintext secret, so the summary never includes a value. + /// Lines end at "\n", as in git. Each line maps to a known key name, + /// "<other>" for an unknown key, or "<malformed>" for a line + /// without a key. The summary lists at most + /// lines. + /// + private static string GetCredentialOutputKeys(string credentialOutput, bool outputTruncated) + { + if (string.IsNullOrEmpty(credentialOutput)) + { + return string.Empty; + } + + if (outputTruncated) + { + return ""; + } + + StringBuilder keys = new StringBuilder(); + int keyCount = 0; + int position = 0; + while (position < credentialOutput.Length) + { + int lineEnd = credentialOutput.IndexOf('\n', position); + if (lineEnd < 0) + { + lineEnd = credentialOutput.Length; + } + + ReadOnlySpan line = credentialOutput.AsSpan(position, lineEnd - position).TrimEnd('\r'); + position = lineEnd + 1; + if (line.IsWhiteSpace()) + { + continue; + } + + if (keyCount == MaxCredentialOutputKeys) + { + keys.Append(",..."); + break; + } + + if (keyCount > 0) + { + keys.Append(','); + } + + int separatorIndex = line.IndexOf('='); + if (separatorIndex <= 0) + { + keys.Append(""); + } + else if (separatorIndex > MaxCredentialOutputKeyLength) + { + keys.Append(""); + } + else + { + string key = line.Slice(0, separatorIndex).ToString(); + keys.Append(CredentialOutputKeyAllowlist.Contains(key) ? key : ""); + } + + keyCount++; + } + + return keys.ToString(); + } + + /// + /// Returns the value of the "key=value" line that starts with , + /// or null if no line has that prefix. Lines end at "\n", the last line need not end with one, + /// and a repeated key takes its last value. This is how git reads credential output. + /// + /// + /// The scan reads all of the output, with no early exit, because the last value wins. The + /// output is bounded by and a real helper writes a few lines. + /// Unlike git, which stops reading at the first empty line, this skips empty lines and keeps + /// reading. That accepts every output that git accepts. + /// private static string ParseValue(string contents, string prefix) { - int startIndex = contents.IndexOf(prefix) + prefix.Length; - if (startIndex >= 0 && startIndex < contents.Length) + string value = null; + int position = 0; + while (position < contents.Length) { - int endIndex = contents.IndexOf('\n', startIndex); - if (endIndex >= 0 && endIndex < contents.Length) + int lineEnd = contents.IndexOf('\n', position); + if (lineEnd < 0) { - return - contents - .Substring(startIndex, endIndex - startIndex) - .Trim('\r'); + lineEnd = contents.Length; } + + ReadOnlySpan line = contents.AsSpan(position, lineEnd - position).TrimEnd('\r'); + if (line.StartsWith(prefix, StringComparison.Ordinal)) + { + value = line.Slice(prefix.Length).ToString(); + } + + position = lineEnd + 1; } - return null; + return value; } /// @@ -1204,7 +1368,8 @@ private Result InvokeGitInWorkingDirectoryRoot( } /// - /// Invokes git.exe against an enlistment's .git folder. + /// Invokes git.exe against this process's --git-dir path. In a linked worktree this is + /// the worktree's .git file, not the gitdir target named inside it. /// This method should be used only with git-commands that ignore the working directory /// private Result InvokeGitAgainstDotGitFolder(string command, bool usePreCommandHook = true) @@ -1228,7 +1393,7 @@ private Result InvokeGitAgainstDotGitFolder( return this.InvokeGitImpl( command, workingDirectory: Environment.SystemDirectory, - dotGitDirectory: this.dotGitRoot, + dotGitDirectory: this.gitDirPath, useReadObjectHook: false, writeStdIn: writeStdIn, parseStdOutLine: parseStdOutLine, diff --git a/GVFS/GVFS.Common/GitStatusCache.cs b/GVFS/GVFS.Common/GitStatusCache.cs index 870dce1c7c..369adda87c 100644 --- a/GVFS/GVFS.Common/GitStatusCache.cs +++ b/GVFS/GVFS.Common/GitStatusCache.cs @@ -50,7 +50,11 @@ public class GitStatusCache : IDisposable private CancellationTokenSource shutdownTokenSource; private Task activeHydrationTask; - private volatile EnlistmentHydrationSummary cachedHydrationSummary; + private volatile HydrationCacheSnapshot hydrationCache = new HydrationCacheSnapshot(null, NamedPipeMessages.HydrationStatus.CacheState.NotComputed, 0); + private int hydrationInvalidationVersion; + private long hydrationRequestCount; + private long hydrationUnavailableCount; + private readonly Lock hydrationStatisticsLock = new Lock(); private Func projectedFolderCountProvider; @@ -119,6 +123,7 @@ public virtual void Shutdown() /// public virtual void Invalidate() { + Interlocked.Increment(ref this.hydrationInvalidationVersion); this.lastInvalidationTime = DateTime.UtcNow; this.cacheState = CacheState.Dirty; } @@ -142,9 +147,29 @@ public void RefreshAndWait() /// Returns the cached hydration summary if one has been computed, /// or null if no valid summary is available yet. /// - public EnlistmentHydrationSummary GetCachedHydrationSummary() + public EnlistmentHydrationSummary GetCachedHydrationSummary(out NamedPipeMessages.HydrationStatus.CacheInfo info) { - return this.cachedHydrationSummary; + HydrationCacheSnapshot snapshot = this.hydrationCache; + EnlistmentHydrationSummary summary = snapshot.Summary; + bool available = summary != null && summary.IsValid; + lock (this.hydrationStatisticsLock) + { + this.hydrationRequestCount++; + if (!available) + { + this.hydrationUnavailableCount++; + } + } + + info = new NamedPipeMessages.HydrationStatus.CacheInfo + { + State = snapshot.State, + CalculationId = summary?.Diagnostics?.CalculationId, + AgeMs = summary == null ? -1 : Math.Max(0, (long)(DateTime.UtcNow - snapshot.CompletedUtc).TotalMilliseconds), + Invalidated = snapshot.InvalidationVersion != Volatile.Read(ref this.hydrationInvalidationVersion), + CountsComplete = summary?.Diagnostics?.CountsComplete ?? false, + }; + return available ? summary : null; } /// @@ -253,6 +278,22 @@ public virtual bool WriteTelemetryandReset(EventMetadata metadata) } StatusStatistics statusStatistics = Interlocked.Exchange(ref this.statistics, new StatusStatistics()); + long hydrationRequests; + long hydrationUnavailable; + lock (this.hydrationStatisticsLock) + { + hydrationRequests = this.hydrationRequestCount; + hydrationUnavailable = this.hydrationUnavailableCount; + this.hydrationRequestCount = 0; + this.hydrationUnavailableCount = 0; + } + if (hydrationRequests > 0) + { + metadata["HydrationStatus.RequestCount"] = hydrationRequests; + metadata["HydrationStatus.UnavailableCount"] = hydrationUnavailable; + metadata["HydrationStatus.CurrentCacheState"] = this.hydrationCache.State; + wroteTelemetry = true; + } if (statusStatistics.BackgroundStatusScanCount > 0) { @@ -413,26 +454,24 @@ private void RebuildStatusCacheIfNeeded(bool ignoreBackoff) } } - private void UpdateHydrationSummary() + internal void UpdateHydrationSummary() { if (this.projectedFolderCountProvider == null) { + this.RecordHydrationSkip(NamedPipeMessages.HydrationStatus.CacheState.ProviderUnavailable); return; } - bool enabled = TEST_EnableHydrationSummaryOverride - ?? this.context.Repository.LibGit2RepoInvoker.GetConfigBoolOrDefault(GVFSConstants.GitConfig.ShowHydrationStatus, GVFSConstants.GitConfig.ShowHydrationStatusDefault); - if (!enabled) + if (!this.GetHydrationSummaryEnabled()) { + this.RecordHydrationSkip(NamedPipeMessages.HydrationStatus.CacheState.FeatureDisabled); return; } - HydrationStatusCircuitBreaker circuitBreaker = new HydrationStatusCircuitBreaker( - this.context.Enlistment.DotGVFSRoot, - this.context.Tracer); - + HydrationStatusCircuitBreaker circuitBreaker = this.CreateHydrationCircuitBreaker(); if (circuitBreaker.IsDisabled()) { + this.RecordHydrationSkip(NamedPipeMessages.HydrationStatus.CacheState.CircuitBreakerDisabled); return; } @@ -443,14 +482,25 @@ private void UpdateHydrationSummary() * Accessing it here ensures that the value is cached for when a user invokes "git status", * and this is also a convenient place to log telemetry for it. */ + int invalidationVersion = Volatile.Read(ref this.hydrationInvalidationVersion); + HydrationSummaryDiagnostics diagnostics = new HydrationSummaryDiagnostics + { + Caller = HydrationSummaryCaller.BackgroundCache, + FolderCountSource = HydrationFolderCountSource.Projection, + }; EnlistmentHydrationSummary hydrationSummary = - EnlistmentHydrationSummary.CreateSummary(this.context.Enlistment, this.context.FileSystem, this.context.Tracer, this.projectedFolderCountProvider, this.shutdownTokenSource.Token); + this.CreateHydrationSummary(diagnostics); + NamedPipeMessages.HydrationStatus.CacheState state = hydrationSummary.IsValid + ? diagnostics.CountsComplete ? NamedPipeMessages.HydrationStatus.CacheState.Available : NamedPipeMessages.HydrationStatus.CacheState.IncompleteData + : diagnostics.Outcome == HydrationSummaryOutcome.Cancelled ? NamedPipeMessages.HydrationStatus.CacheState.Cancelled + : diagnostics.Outcome == HydrationSummaryOutcome.Failed ? NamedPipeMessages.HydrationStatus.CacheState.Failed + : NamedPipeMessages.HydrationStatus.CacheState.InvalidCounts; + this.hydrationCache = new HydrationCacheSnapshot(hydrationSummary, state, invalidationVersion); EventMetadata metadata = new EventMetadata(); metadata.Add("Area", EtwArea); if (hydrationSummary.IsValid) { - this.cachedHydrationSummary = hydrationSummary; - + metadata["CalculationId"] = diagnostics.CalculationId.ToString("N"); metadata[nameof(hydrationSummary.TotalFolderCount)] = hydrationSummary.TotalFolderCount; metadata[nameof(hydrationSummary.TotalFileCount)] = hydrationSummary.TotalFileCount; metadata[nameof(hydrationSummary.HydratedFolderCount)] = hydrationSummary.HydratedFolderCount; @@ -464,36 +514,74 @@ private void UpdateHydrationSummary() } else if (hydrationSummary.Error != null) { - this.cachedHydrationSummary = null; circuitBreaker.RecordFailure(); metadata["Exception"] = hydrationSummary.Error.ToString(); this.context.Tracer.RelatedWarning( metadata, $"{nameof(GitStatusCache)}{nameof(RebuildStatusCacheIfNeeded)}: hydration summary could not be calculated.", - Keywords.Telemetry); + Keywords.None); } else { - // Invalid summary with no error — likely cancelled during shutdown - this.cachedHydrationSummary = null; this.context.Tracer.RelatedInfo( - $"{nameof(GitStatusCache)}{nameof(RebuildStatusCacheIfNeeded)}: hydration summary was cancelled."); + $"{nameof(GitStatusCache)}{nameof(RebuildStatusCacheIfNeeded)}: hydration summary outcome: {diagnostics.Outcome}."); } } catch (Exception ex) { - this.cachedHydrationSummary = null; + this.hydrationCache = new HydrationCacheSnapshot(null, NamedPipeMessages.HydrationStatus.CacheState.Failed, Volatile.Read(ref this.hydrationInvalidationVersion)); circuitBreaker.RecordFailure(); EventMetadata metadata = new EventMetadata(); metadata.Add("Area", EtwArea); - metadata.Add("Exception", ex.ToString()); - this.context.Tracer.RelatedError( + metadata.Add("ExceptionType", ex.GetType().FullName); + metadata.Add("ExceptionHResult", ex.HResult); + this.context.Tracer.RelatedWarning(new EventMetadata { ["Exception"] = ex.ToString() }, "Hydration cache update failed.", Keywords.None); + this.context.Tracer.RelatedWarning( metadata, $"{nameof(GitStatusCache)}{nameof(RebuildStatusCacheIfNeeded)}: Exception trying to update hydration summary cache.", Keywords.Telemetry); } } + internal virtual bool GetHydrationSummaryEnabled() + { + return TEST_EnableHydrationSummaryOverride + ?? this.context.Repository.LibGit2RepoInvoker.GetConfigBoolOrDefault(GVFSConstants.GitConfig.ShowHydrationStatus, GVFSConstants.GitConfig.ShowHydrationStatusDefault); + } + + internal virtual HydrationStatusCircuitBreaker CreateHydrationCircuitBreaker() + { + return new HydrationStatusCircuitBreaker(this.context.Enlistment.DotGVFSRoot, this.context.Tracer); + } + + internal virtual EnlistmentHydrationSummary CreateHydrationSummary(HydrationSummaryDiagnostics diagnostics) + { + return EnlistmentHydrationSummary.CreateSummary(this.context.Enlistment, this.context.FileSystem, this.context.Tracer, + this.projectedFolderCountProvider, this.shutdownTokenSource.Token, diagnostics); + } + + private void RecordHydrationSkip(NamedPipeMessages.HydrationStatus.CacheState state) + { + HydrationCacheSnapshot previous = this.hydrationCache; + this.hydrationCache = new HydrationCacheSnapshot(previous.Summary, state, previous.InvalidationVersion, previous.CompletedUtc); + } + + private sealed class HydrationCacheSnapshot + { + public HydrationCacheSnapshot(EnlistmentHydrationSummary summary, NamedPipeMessages.HydrationStatus.CacheState state, int invalidationVersion, DateTime? completedUtc = null) + { + this.Summary = summary; + this.State = state; + this.InvalidationVersion = invalidationVersion; + this.CompletedUtc = completedUtc ?? DateTime.UtcNow; + } + + public EnlistmentHydrationSummary Summary { get; } + public NamedPipeMessages.HydrationStatus.CacheState State { get; } + public int InvalidationVersion { get; } + public DateTime CompletedUtc { get; } + } + /// /// Rebuild the status cache. This will run the background status to /// generate status results, and update the serialized status cache diff --git a/GVFS/GVFS.Common/HealthCalculator/EnlistmentHydrationSummary.cs b/GVFS/GVFS.Common/HealthCalculator/EnlistmentHydrationSummary.cs index 5dbe7c335b..9c605af1d4 100644 --- a/GVFS/GVFS.Common/HealthCalculator/EnlistmentHydrationSummary.cs +++ b/GVFS/GVFS.Common/HealthCalculator/EnlistmentHydrationSummary.cs @@ -16,6 +16,7 @@ public class EnlistmentHydrationSummary public int TotalFileCount { get; private set; } public int TotalFolderCount { get; private set; } public Exception Error { get; private set; } = null; + public HydrationSummaryDiagnostics Diagnostics { get; private set; } public int HydratedFileCount => PlaceholderFileCount + ModifiedFileCount; public int HydratedFolderCount => PlaceholderFolderCount + ModifiedFolderCount; @@ -51,163 +52,176 @@ public static EnlistmentHydrationSummary CreateSummary( PhysicalFileSystem fileSystem, ITracer tracer, Func projectedFolderCountProvider, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + HydrationSummaryDiagnostics diagnostics = null) { - Stopwatch totalStopwatch = Stopwatch.StartNew(); - Stopwatch phaseStopwatch = new Stopwatch(); + return CreateSummary(enlistment, fileSystem, tracer, projectedFolderCountProvider, new EnlistmentPathData(), cancellationToken, diagnostics); + } + internal static EnlistmentHydrationSummary CreateSummary( + GVFSEnlistment enlistment, + PhysicalFileSystem fileSystem, + ITracer tracer, + Func projectedFolderCountProvider, + EnlistmentPathData pathData, + CancellationToken cancellationToken = default, + HydrationSummaryDiagnostics diagnostics = null) + { + diagnostics = diagnostics ?? new HydrationSummaryDiagnostics(); + EnlistmentHydrationSummary summary = new EnlistmentHydrationSummary { Diagnostics = diagnostics }; + Stopwatch totalStopwatch = Stopwatch.StartNew(); try { - /* Getting all the file paths from git index is slow and we only need the total count, - * so we read the index file header instead of calling GetPathsFromGitIndex */ - phaseStopwatch.Restart(); - int totalFileCount = GetIndexFileCount(enlistment, fileSystem); - long indexReadMs = phaseStopwatch.ElapsedMilliseconds; + diagnostics.StartPhase(HydrationSummaryPhase.IndexRead); + summary.TotalFileCount = GetIndexFileCount(enlistment, fileSystem, diagnostics); + diagnostics.TotalFileCount = summary.TotalFileCount >= 0 ? summary.TotalFileCount : null; + diagnostics.FinishPhase(summary.TotalFileCount >= 0 ? HydrationDataState.Complete : HydrationDataState.Failed); cancellationToken.ThrowIfCancellationRequested(); - EnlistmentPathData pathData = new EnlistmentPathData(); - - /* FUTURE: These could be optimized to only deal with counts instead of full path lists */ - phaseStopwatch.Restart(); - pathData.LoadPlaceholdersFromDatabase(enlistment); - long placeholderLoadMs = phaseStopwatch.ElapsedMilliseconds; + diagnostics.StartPhase(HydrationSummaryPhase.Placeholders); + pathData.LoadPlaceholdersFromDatabase(enlistment, diagnostics); + summary.PlaceholderFileCount = pathData.PlaceholderFilePaths.Count; + summary.PlaceholderFolderCount = pathData.PlaceholderFolderPaths.Count; + diagnostics.PlaceholderFileCount = summary.PlaceholderFileCount; + diagnostics.PlaceholderFolderCount = summary.PlaceholderFolderCount; + diagnostics.FinishPhase(HydrationDataState.Complete); cancellationToken.ThrowIfCancellationRequested(); - phaseStopwatch.Restart(); - pathData.LoadModifiedPaths(enlistment, tracer); - long modifiedPathsLoadMs = phaseStopwatch.ElapsedMilliseconds; + diagnostics.StartPhase(HydrationSummaryPhase.ModifiedPaths); + pathData.LoadModifiedPaths(enlistment, tracer, diagnostics); + summary.ModifiedFileCount = pathData.ModifiedFilePaths.Count; + summary.ModifiedFolderCount = pathData.ModifiedFolderPaths.Count; + diagnostics.ModifiedFileCount = diagnostics.ModifiedPathsState == HydrationDataState.Failed ? null : summary.ModifiedFileCount; + diagnostics.ModifiedFolderCount = diagnostics.ModifiedPathsState == HydrationDataState.Failed ? null : summary.ModifiedFolderCount; + diagnostics.FinishPhase(diagnostics.ModifiedPathsState == HydrationDataState.NotStarted ? HydrationDataState.Complete : diagnostics.ModifiedPathsState); cancellationToken.ThrowIfCancellationRequested(); - int placeholderFileCount = pathData.PlaceholderFilePaths.Count; - int placeholderFolderCount = pathData.PlaceholderFolderPaths.Count; - int modifiedFileCount = pathData.ModifiedFilePaths.Count; - int modifiedFolderCount = pathData.ModifiedFolderPaths.Count; - - /* Getting the head tree count (used for TotalFolderCount) is potentially slower than the other parts - * of the operation, so we do it last and check that the other parts would succeed before running it. - */ - var soFar = new EnlistmentHydrationSummary() - { - PlaceholderFileCount = placeholderFileCount, - PlaceholderFolderCount = placeholderFolderCount, - ModifiedFileCount = modifiedFileCount, - ModifiedFolderCount = modifiedFolderCount, - TotalFileCount = totalFileCount, - TotalFolderCount = placeholderFolderCount + modifiedFolderCount + 1, // Not calculated yet, use a dummy valid value. - }; - - if (!soFar.IsValid) + // Preserve the early-exit check without reporting its temporary folder total as an observation. + summary.TotalFolderCount = summary.HydratedFolderCount + 1; + if (!summary.IsValid) { - soFar.TotalFolderCount = 0; // Set to default invalid value to avoid confusion with the dummy value above. - tracer.RelatedWarning( - $"Hydration summary early exit: data invalid before tree count. " + - $"TotalFileCount={totalFileCount}, PlaceholderFileCount={placeholderFileCount}, " + - $"ModifiedFileCount={modifiedFileCount}, PlaceholderFolderCount={placeholderFolderCount}, " + - $"ModifiedFolderCount={modifiedFolderCount}"); - EmitDurationTelemetry(tracer, totalStopwatch.ElapsedMilliseconds, indexReadMs, placeholderLoadMs, modifiedPathsLoadMs, treeCountMs: 0, earlyExit: true); - return soFar; + summary.TotalFolderCount = 0; + diagnostics.EarlyExit = true; + diagnostics.Outcome = diagnostics.IndexState == HydrationDataState.Failed + ? HydrationSummaryOutcome.IncompleteData : HydrationSummaryOutcome.InvalidCounts; + return summary; } - /* Get the total folder count from the caller-provided function. - * In the mount process, this comes from the in-memory projection (essentially free). - * In gvfs health --status fallback, this parses the git index via GitIndexProjection. */ cancellationToken.ThrowIfCancellationRequested(); - phaseStopwatch.Restart(); - int totalFolderCount = projectedFolderCountProvider(); - long treeCountMs = phaseStopwatch.ElapsedMilliseconds; - - EmitDurationTelemetry(tracer, totalStopwatch.ElapsedMilliseconds, indexReadMs, placeholderLoadMs, modifiedPathsLoadMs, treeCountMs, earlyExit: false); - - return new EnlistmentHydrationSummary() - { - PlaceholderFileCount = placeholderFileCount, - PlaceholderFolderCount = placeholderFolderCount, - ModifiedFileCount = modifiedFileCount, - ModifiedFolderCount = modifiedFolderCount, - TotalFileCount = totalFileCount, - TotalFolderCount = totalFolderCount, - }; + diagnostics.StartPhase(HydrationSummaryPhase.FolderCount); + summary.TotalFolderCount = projectedFolderCountProvider(); + diagnostics.TotalFolderCount = summary.TotalFolderCount; + diagnostics.FinishPhase(HydrationDataState.Complete); + diagnostics.Outcome = !summary.IsValid || (diagnostics.GetValidationFailures() & ~HydrationValidationFailure.MissingCounts) != HydrationValidationFailure.None ? HydrationSummaryOutcome.InvalidCounts + : diagnostics.CountsComplete ? HydrationSummaryOutcome.Success : HydrationSummaryOutcome.IncompleteData; + return summary; } - catch (OperationCanceledException) + catch (OperationCanceledException e) { - tracer.RelatedInfo($"Hydration summary cancelled after {totalStopwatch.ElapsedMilliseconds}ms"); - return new EnlistmentHydrationSummary() - { - PlaceholderFileCount = -1, - PlaceholderFolderCount = -1, - ModifiedFileCount = -1, - ModifiedFolderCount = -1, - TotalFileCount = -1, - TotalFolderCount = -1, - }; + diagnostics.Outcome = HydrationSummaryOutcome.Cancelled; + diagnostics.CancellationRequested = cancellationToken.IsCancellationRequested; + diagnostics.ExceptionType = e.GetType().FullName; + diagnostics.ExceptionHResult = e.HResult; + diagnostics.FinishPhase(HydrationDataState.Cancelled); + CapturePartialCounts(pathData, diagnostics); + summary.InvalidateCounts(); + return summary; } catch (Exception e) { - tracer.RelatedError($"Hydration summary failed with exception after {totalStopwatch.ElapsedMilliseconds}ms: {e.Message}"); - return new EnlistmentHydrationSummary() - { - PlaceholderFileCount = -1, - PlaceholderFolderCount = -1, - ModifiedFileCount = -1, - ModifiedFolderCount = -1, - TotalFileCount = -1, - TotalFolderCount = -1, - Error = e, - }; + diagnostics.Outcome = HydrationSummaryOutcome.Failed; + diagnostics.ExceptionType = e.GetType().FullName; + diagnostics.ExceptionHResult = e.HResult; + diagnostics.FinishPhase(HydrationDataState.Failed); + CapturePartialCounts(pathData, diagnostics); + + tracer.RelatedWarning(new EventMetadata { ["Exception"] = e.ToString() }, "Hydration summary calculation failed.", Keywords.None); + summary.Error = e; + summary.InvalidateCounts(); + return summary; + } + finally + { + diagnostics.TotalMs = totalStopwatch.ElapsedMilliseconds; + diagnostics.IsValid = summary.IsValid; + tracer.RelatedEvent( + diagnostics.Outcome == HydrationSummaryOutcome.Success || diagnostics.Outcome == HydrationSummaryOutcome.Cancelled + ? EventLevel.Informational : EventLevel.Warning, + "HydrationSummaryDuration", + diagnostics.GetMetadata(), + Keywords.Telemetry); } } - private static void EmitDurationTelemetry( - ITracer tracer, - long totalMs, - long indexReadMs, - long placeholderLoadMs, - long modifiedPathsLoadMs, - long treeCountMs, - bool earlyExit) + private static void CapturePartialCounts(EnlistmentPathData pathData, HydrationSummaryDiagnostics diagnostics) { - EventMetadata metadata = new EventMetadata(); - metadata["TotalMs"] = totalMs; - metadata["IndexReadMs"] = indexReadMs; - metadata["PlaceholderLoadMs"] = placeholderLoadMs; - metadata["ModifiedPathsLoadMs"] = modifiedPathsLoadMs; - metadata["TreeCountMs"] = treeCountMs; - metadata["EarlyExit"] = earlyExit; - tracer.RelatedEvent( - EventLevel.Informational, - "HydrationSummaryDuration", - metadata, - Keywords.Telemetry); + if (diagnostics.Phase == HydrationSummaryPhase.Placeholders + && (pathData.PlaceholderFilePaths.Count > 0 || pathData.PlaceholderFolderPaths.Count > 0)) + { + diagnostics.PlaceholderFileCount = pathData.PlaceholderFilePaths.Count; + diagnostics.PlaceholderFolderCount = pathData.PlaceholderFolderPaths.Count; + if (diagnostics.PlaceholderState == HydrationDataState.Failed) + { + diagnostics.PlaceholderState = HydrationDataState.Partial; + } + } + + if (diagnostics.Phase == HydrationSummaryPhase.ModifiedPaths + && (pathData.ModifiedFilePaths.Count > 0 || pathData.ModifiedFolderPaths.Count > 0)) + { + diagnostics.ModifiedFileCount = pathData.ModifiedFilePaths.Count; + diagnostics.ModifiedFolderCount = pathData.ModifiedFolderPaths.Count; + if (diagnostics.ModifiedPathsState == HydrationDataState.Failed) + { + diagnostics.ModifiedPathsState = HydrationDataState.Partial; + } + } } /// /// Get the total number of files in the index. /// - internal static int GetIndexFileCount(GVFSEnlistment enlistment, PhysicalFileSystem fileSystem) + internal static int GetIndexFileCount(GVFSEnlistment enlistment, PhysicalFileSystem fileSystem, HydrationSummaryDiagnostics diagnostics = null) { string indexPath = enlistment.GitIndexPath; using (var indexFile = fileSystem.OpenFileStream(indexPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, callFlushFileBuffers: false)) { + if (diagnostics != null) + { + diagnostics.IndexLength = indexFile.Length; + } + if (indexFile.Length < 12) { + if (diagnostics != null) + { + diagnostics.IndexReadReason = "ShortIndex"; + } + return -1; } - /* The number of files in the index is a big-endian integer from - * the 4 bytes at offsets 8-11 of the index file. */ - indexFile.Position = 8; - var bytes = new byte[4]; - indexFile.ReadExactly( - bytes, // Destination buffer - offset: 0, // Offset in destination buffer, not in indexFile - count: 4); - if (BitConverter.IsLittleEndian) + byte[] header = new byte[12]; + indexFile.ReadExactly(header, 0, header.Length); + int count = System.Buffers.Binary.BinaryPrimitives.ReadInt32BigEndian(header.AsSpan(8, 4)); + if (diagnostics != null) { - Array.Reverse(bytes); + diagnostics.IndexVersion = System.Buffers.Binary.BinaryPrimitives.ReadInt32BigEndian(header.AsSpan(4, 4)); + diagnostics.IndexSignatureValid = header[0] == 'D' && header[1] == 'I' && header[2] == 'R' && header[3] == 'C'; + diagnostics.IndexReadReason = count < 0 ? "NegativeEntryCount" : "Success"; } - int count = BitConverter.ToInt32(bytes, 0); + return count; } } + private void InvalidateCounts() + { + this.PlaceholderFileCount = -1; + this.PlaceholderFolderCount = -1; + this.ModifiedFileCount = -1; + this.ModifiedFolderCount = -1; + this.TotalFileCount = -1; + this.TotalFolderCount = -1; + } } } diff --git a/GVFS/GVFS.Common/HealthCalculator/EnlistmentPathData.cs b/GVFS/GVFS.Common/HealthCalculator/EnlistmentPathData.cs index d56cfc8bd0..ed76e811d1 100644 --- a/GVFS/GVFS.Common/HealthCalculator/EnlistmentPathData.cs +++ b/GVFS/GVFS.Common/HealthCalculator/EnlistmentPathData.cs @@ -49,19 +49,37 @@ public void NormalizeAllPaths() /// Goes to the SQLite database for the placeholder lists /// /// The current GVFS enlistment being operated on - public void LoadPlaceholdersFromDatabase(GVFSEnlistment enlistment) + public virtual void LoadPlaceholdersFromDatabase(GVFSEnlistment enlistment, HydrationSummaryDiagnostics diagnostics = null) { - List filePlaceholders = new List(); - List folderPlaceholders = new List(); + this.ReadPlaceholderEntries(enlistment, out List filePlaceholders, out List folderPlaceholders); - using (GVFSDatabase database = new GVFSDatabase(new PhysicalFileSystem(), enlistment.DotGVFSRoot, new SqliteDatabase())) + this.PlaceholderFilePaths.AddRange(filePlaceholders.Select(placeholderData => placeholderData.Path)); + if (diagnostics != null) { - PlaceholderTable placeholderTable = new PlaceholderTable(database); - placeholderTable.GetAllEntries(out filePlaceholders, out folderPlaceholders); + diagnostics.PartialFolderCount = 0; + diagnostics.ExpandedFolderCount = 0; + diagnostics.PossibleTombstoneFolderCount = 0; } - this.PlaceholderFilePaths.AddRange(filePlaceholders.Select(placeholderData => placeholderData.Path)); - this.PlaceholderFolderPaths.AddRange(folderPlaceholders.Select(placeholderData => placeholderData.Path)); + foreach (IPlaceholderData placeholder in folderPlaceholders) + { + this.PlaceholderFolderPaths.Add(placeholder.Path); + if (diagnostics != null) + { + if (placeholder.IsExpandedFolder) + { + diagnostics.ExpandedFolderCount++; + } + else if (placeholder.IsPossibleTombstoneFolder) + { + diagnostics.PossibleTombstoneFolderCount++; + } + else + { + diagnostics.PartialFolderCount++; + } + } + } } /// @@ -95,10 +113,15 @@ public void LoadPathsFromGitIndex(GVFSEnlistment enlistment) this.GitTrackingPaths.AddRange(skipWorktreeFiles); } - public void LoadModifiedPaths(GVFSEnlistment enlistment, ITracer tracer) + public virtual void LoadModifiedPaths(GVFSEnlistment enlistment, ITracer tracer, HydrationSummaryDiagnostics diagnostics = null) { - if (TryLoadModifiedPathsFromPipe(enlistment, tracer)) + if (TryLoadModifiedPathsFromPipe(enlistment, tracer, diagnostics)) { + if (diagnostics != null) + { + diagnostics.ModifiedPathsState = HydrationDataState.Complete; + } + return; } @@ -106,19 +129,54 @@ public void LoadModifiedPaths(GVFSEnlistment enlistment, ITracer tracer) string filePath = Path.Combine(enlistment.DotGVFSRoot, GVFSConstants.DotGVFS.Databases.ModifiedPaths); try { - using (FileStream file = File.Open(filePath, FileMode.OpenOrCreate, FileAccess.Read, FileShare.Read)) - using (StreamReader reader = new StreamReader(file)) + AddModifiedPaths(this.ReadModifiedPathsFile(filePath, diagnostics)); + if (diagnostics != null) { - AddModifiedPaths(ReadModifiedPathDatabaseLines(reader)); + diagnostics.ModifiedPathsDiskOutcome = HydrationPathSourceOutcome.Success; + diagnostics.ModifiedPathsState = HydrationDataState.Complete; } } catch (Exception ex) { - tracer.RelatedWarning($"Failed to read modified paths file at {filePath}: {ex.Message}"); + if (diagnostics != null) + { + diagnostics.ModifiedPathsDiskOutcome = HydrationPathSourceOutcome.Failed; + diagnostics.ModifiedPathsState = this.ModifiedFilePaths.Count > 0 || this.ModifiedFolderPaths.Count > 0 + ? HydrationDataState.Partial : HydrationDataState.Failed; + diagnostics.ModifiedPathsExceptionType = ex.GetType().FullName; + diagnostics.ModifiedPathsExceptionHResult = ex.HResult; + } + + tracer.RelatedWarning($"Failed to read modified paths file at {filePath}: {ex}"); + } + } + + protected virtual void ReadPlaceholderEntries(GVFSEnlistment enlistment, out List files, out List folders) + { + using (GVFSDatabase database = new GVFSDatabase(new PhysicalFileSystem(), enlistment.DotGVFSRoot, new SqliteDatabase())) + { + new PlaceholderTable(database).GetAllEntries(out files, out folders); } } - private IEnumerable ReadModifiedPathDatabaseLines(StreamReader r) + protected virtual IEnumerable ReadModifiedPathsFile(string filePath, HydrationSummaryDiagnostics diagnostics) + { + using (FileStream file = File.Open(filePath, FileMode.OpenOrCreate, FileAccess.Read, FileShare.Read)) + using (StreamReader reader = new StreamReader(file)) + { + foreach (string path in this.ReadModifiedPathDatabaseLines(reader, diagnostics)) + { + yield return path; + } + } + } + + protected virtual NamedPipeClient CreateModifiedPathsPipeClient(GVFSEnlistment enlistment) + { + return new NamedPipeClient(enlistment.NamedPipeName); + } + + internal IEnumerable ReadModifiedPathDatabaseLines(StreamReader r, HydrationSummaryDiagnostics diagnostics = null) { while (!r.EndOfStream) { @@ -131,8 +189,25 @@ private IEnumerable ReadModifiedPathDatabaseLines(StreamReader r) const string LinePrefix = "A "; if (line.StartsWith(LinePrefix)) { + if (diagnostics != null) + { + diagnostics.ModifiedPathsAddRecords++; + } + line = line.Substring(LinePrefix.Length); } + else if (diagnostics != null) + { + if (line.StartsWith("D ")) + { + diagnostics.ModifiedPathsDeleteRecords++; + } + else + { + diagnostics.ModifiedPathsOtherRecords++; + } + } + yield return line; } } @@ -143,14 +218,22 @@ private IEnumerable ReadModifiedPathDatabaseLines(StreamReader r) /// If/when modified paths are moved to SQLite go there instead /// The enlistment being operated on /// An array containing all of the modified paths in string format - private bool TryLoadModifiedPathsFromPipe(GVFSEnlistment enlistment, ITracer tracer) + private bool TryLoadModifiedPathsFromPipe(GVFSEnlistment enlistment, ITracer tracer, HydrationSummaryDiagnostics diagnostics) { - using (NamedPipeClient pipeClient = new NamedPipeClient(enlistment.NamedPipeName)) + using (NamedPipeClient pipeClient = this.CreateModifiedPathsPipeClient(enlistment)) { string[] modifiedPathsList = Array.Empty(); - if (!pipeClient.Connect()) + if (!pipeClient.Connect(timeoutMilliseconds: 3000, out Exception connectError)) { + if (diagnostics != null) + { + diagnostics.ModifiedPathsPipeOutcome = connectError is TimeoutException + ? HydrationPathSourceOutcome.ConnectTimeout : HydrationPathSourceOutcome.ConnectFailed; + diagnostics.ModifiedPathsPipeExceptionType = connectError?.GetType().FullName; + diagnostics.ModifiedPathsPipeExceptionHResult = connectError?.HResult; + } + return false; } @@ -162,6 +245,14 @@ private bool TryLoadModifiedPathsFromPipe(GVFSEnlistment enlistment, ITracer tra NamedPipeMessages.Message modifiedPathsResponse = pipeClient.ReadResponse(); if (!modifiedPathsResponse.Header.Equals(NamedPipeMessages.ModifiedPaths.SuccessResult)) { + if (diagnostics != null) + { + diagnostics.ModifiedPathsPipeOutcome = modifiedPathsResponse.Header == NamedPipeMessages.MountNotReadyResult + ? HydrationPathSourceOutcome.MountNotReady + : modifiedPathsResponse.Header == NamedPipeMessages.ModifiedPaths.InvalidVersion + ? HydrationPathSourceOutcome.InvalidVersion : HydrationPathSourceOutcome.UnexpectedResponse; + } + return false; } @@ -169,11 +260,23 @@ private bool TryLoadModifiedPathsFromPipe(GVFSEnlistment enlistment, ITracer tra } catch (Exception ex) { - tracer.RelatedWarning($"Failed to load modified paths via named pipe: {ex.Message}"); + if (diagnostics != null) + { + diagnostics.ModifiedPathsPipeOutcome = HydrationPathSourceOutcome.Failed; + diagnostics.ModifiedPathsPipeExceptionType = ex.GetType().FullName; + diagnostics.ModifiedPathsPipeExceptionHResult = ex.HResult; + } + + tracer.RelatedWarning($"Failed to load modified paths via named pipe: {ex}"); return false; } AddModifiedPaths(modifiedPathsList); + if (diagnostics != null) + { + diagnostics.ModifiedPathsPipeOutcome = HydrationPathSourceOutcome.Success; + } + return true; } } diff --git a/GVFS/GVFS.Common/HealthCalculator/HydrationStatusCircuitBreaker.cs b/GVFS/GVFS.Common/HealthCalculator/HydrationStatusCircuitBreaker.cs index 315b4d6063..19392ee6cf 100644 --- a/GVFS/GVFS.Common/HealthCalculator/HydrationStatusCircuitBreaker.cs +++ b/GVFS/GVFS.Common/HealthCalculator/HydrationStatusCircuitBreaker.cs @@ -36,7 +36,7 @@ public HydrationStatusCircuitBreaker( /// Returns true if the hydration status feature should be skipped due to /// too many recent failures. /// - public bool IsDisabled() + public virtual bool IsDisabled() { try { @@ -75,7 +75,7 @@ public bool IsDisabled() /// the circuit breaker trips and returns true. /// Uses exclusive file access to prevent concurrent processes from losing counts. /// - public void RecordFailure() + public virtual void RecordFailure() { try { diff --git a/GVFS/GVFS.Common/HealthCalculator/HydrationSummaryDiagnostics.cs b/GVFS/GVFS.Common/HealthCalculator/HydrationSummaryDiagnostics.cs new file mode 100644 index 0000000000..eecbb7c0c3 --- /dev/null +++ b/GVFS/GVFS.Common/HealthCalculator/HydrationSummaryDiagnostics.cs @@ -0,0 +1,261 @@ +using GVFS.Common.Tracing; +using System; +using System.Diagnostics; + +namespace GVFS.Common +{ + public enum HydrationSummaryOutcome + { + NotStarted, + Success, + InvalidCounts, + IncompleteData, + Cancelled, + Failed, + } + + public enum HydrationDataState + { + NotStarted, + Complete, + Partial, + Failed, + Cancelled, + } + + public enum HydrationSummaryPhase + { + None, + IndexRead, + Placeholders, + ModifiedPaths, + FolderCount, + } + + public enum HydrationFolderCountSource + { + Unknown, + Projection, + IndexParser, + } + + public enum HydrationSummaryCaller + { + Unknown, + BackgroundCache, + HealthFallback, + } + + public enum HydrationPathSourceOutcome + { + NotAttempted, + Success, + ConnectTimeout, + ConnectFailed, + MountNotReady, + InvalidVersion, + UnexpectedResponse, + Failed, + } + + [Flags] + public enum HydrationValidationFailure + { + None = 0, + MissingCounts = 1, + NegativeCounts = 2, + HydratedFilesExceedTotal = 4, + HydratedFoldersExceedTotal = 8, + FileSumOverflow = 16, + FolderSumOverflow = 32, + } + + public sealed class HydrationSummaryDiagnostics + { + private readonly Stopwatch phaseStopwatch = new Stopwatch(); + private bool phaseActive; + + public Guid CalculationId { get; } = Guid.NewGuid(); + public Guid? CommandId { get; set; } + public HydrationSummaryCaller Caller { get; set; } + public HydrationFolderCountSource FolderCountSource { get; set; } + public HydrationSummaryOutcome Outcome { get; internal set; } + public HydrationSummaryPhase Phase { get; private set; } + public HydrationDataState IndexState { get; internal set; } + public HydrationDataState PlaceholderState { get; internal set; } + public HydrationDataState ModifiedPathsState { get; internal set; } + public HydrationDataState FolderCountState { get; internal set; } + public int? PlaceholderFileCount { get; internal set; } + public int? PlaceholderFolderCount { get; internal set; } + public int? ModifiedFileCount { get; internal set; } + public int? ModifiedFolderCount { get; internal set; } + public int? TotalFileCount { get; internal set; } + public int? TotalFolderCount { get; internal set; } + public int? PartialFolderCount { get; internal set; } + public int? ExpandedFolderCount { get; internal set; } + public int? PossibleTombstoneFolderCount { get; internal set; } + public long? IndexLength { get; internal set; } + public int? IndexVersion { get; internal set; } + public bool? IndexSignatureValid { get; internal set; } + public string IndexReadReason { get; internal set; } + public HydrationPathSourceOutcome ModifiedPathsPipeOutcome { get; internal set; } + public HydrationPathSourceOutcome ModifiedPathsDiskOutcome { get; internal set; } + public long ModifiedPathsAddRecords { get; internal set; } + public long ModifiedPathsDeleteRecords { get; internal set; } + public long ModifiedPathsOtherRecords { get; internal set; } + public string ModifiedPathsExceptionType { get; internal set; } + public int? ModifiedPathsExceptionHResult { get; internal set; } + public string ModifiedPathsPipeExceptionType { get; internal set; } + public int? ModifiedPathsPipeExceptionHResult { get; internal set; } + public long? IndexReadMs { get; private set; } + public long? PlaceholderLoadMs { get; private set; } + public long? ModifiedPathsLoadMs { get; private set; } + public long? TreeCountMs { get; private set; } + public long TotalMs { get; internal set; } + public bool EarlyExit { get; internal set; } + public bool? CancellationRequested { get; internal set; } + public bool IsValid { get; internal set; } + public string ExceptionType { get; internal set; } + public int? ExceptionHResult { get; internal set; } + + public bool CountsComplete => + this.IndexState == HydrationDataState.Complete + && this.PlaceholderState == HydrationDataState.Complete + && this.ModifiedPathsState == HydrationDataState.Complete + && this.FolderCountState == HydrationDataState.Complete; + + public HydrationValidationFailure GetValidationFailures() + { + HydrationValidationFailure failures = HydrationValidationFailure.None; + if (!this.PlaceholderFileCount.HasValue || !this.PlaceholderFolderCount.HasValue + || !this.ModifiedFileCount.HasValue || !this.ModifiedFolderCount.HasValue + || !this.TotalFileCount.HasValue || !this.TotalFolderCount.HasValue) + { + failures |= HydrationValidationFailure.MissingCounts; + } + + if (this.PlaceholderFileCount < 0 || this.PlaceholderFolderCount < 0 + || this.ModifiedFileCount < 0 || this.ModifiedFolderCount < 0 + || this.TotalFileCount < 0 || this.TotalFolderCount < 0) + { + failures |= HydrationValidationFailure.NegativeCounts; + } + + long? files = (long?)this.PlaceholderFileCount + this.ModifiedFileCount; + long? folders = (long?)this.PlaceholderFolderCount + this.ModifiedFolderCount; + if (files > this.TotalFileCount) + { + failures |= HydrationValidationFailure.HydratedFilesExceedTotal; + } + + if (folders > this.TotalFolderCount) + { + failures |= HydrationValidationFailure.HydratedFoldersExceedTotal; + } + + if (files > int.MaxValue) + { + failures |= HydrationValidationFailure.FileSumOverflow; + } + + if (folders > int.MaxValue) + { + failures |= HydrationValidationFailure.FolderSumOverflow; + } + + return failures; + } + + public EventMetadata GetMetadata() + { + return new EventMetadata + { + ["SchemaVersion"] = 1, + [nameof(this.CalculationId)] = this.CalculationId.ToString("N"), + [nameof(this.CommandId)] = this.CommandId?.ToString("N"), + [nameof(this.Caller)] = this.Caller, + [nameof(this.Outcome)] = this.Outcome, + [nameof(this.Phase)] = this.Phase, + [nameof(this.IsValid)] = this.IsValid, + [nameof(this.CountsComplete)] = this.CountsComplete, + ["ValidationFailures"] = this.GetValidationFailures(), + [nameof(this.PlaceholderFileCount)] = this.PlaceholderFileCount, + [nameof(this.PlaceholderFolderCount)] = this.PlaceholderFolderCount, + [nameof(this.ModifiedFileCount)] = this.ModifiedFileCount, + [nameof(this.ModifiedFolderCount)] = this.ModifiedFolderCount, + [nameof(this.TotalFileCount)] = this.TotalFileCount, + [nameof(this.TotalFolderCount)] = this.TotalFolderCount, + ["HydratedFileCount"] = (long?)this.PlaceholderFileCount + this.ModifiedFileCount, + ["HydratedFolderCount"] = (long?)this.PlaceholderFolderCount + this.ModifiedFolderCount, + [nameof(this.IndexState)] = this.IndexState, + [nameof(this.PlaceholderState)] = this.PlaceholderState, + [nameof(this.ModifiedPathsState)] = this.ModifiedPathsState, + [nameof(this.FolderCountState)] = this.FolderCountState, + [nameof(this.FolderCountSource)] = this.FolderCountSource, + [nameof(this.IndexLength)] = this.IndexLength, + [nameof(this.IndexVersion)] = this.IndexVersion, + [nameof(this.IndexSignatureValid)] = this.IndexSignatureValid, + [nameof(this.IndexReadReason)] = this.IndexReadReason, + [nameof(this.PartialFolderCount)] = this.PartialFolderCount, + [nameof(this.ExpandedFolderCount)] = this.ExpandedFolderCount, + [nameof(this.PossibleTombstoneFolderCount)] = this.PossibleTombstoneFolderCount, + [nameof(this.ModifiedPathsPipeOutcome)] = this.ModifiedPathsPipeOutcome, + [nameof(this.ModifiedPathsDiskOutcome)] = this.ModifiedPathsDiskOutcome, + [nameof(this.ModifiedPathsAddRecords)] = this.ModifiedPathsAddRecords, + [nameof(this.ModifiedPathsDeleteRecords)] = this.ModifiedPathsDeleteRecords, + [nameof(this.ModifiedPathsOtherRecords)] = this.ModifiedPathsOtherRecords, + [nameof(this.ModifiedPathsExceptionType)] = this.ModifiedPathsExceptionType, + [nameof(this.ModifiedPathsExceptionHResult)] = this.ModifiedPathsExceptionHResult, + [nameof(this.ModifiedPathsPipeExceptionType)] = this.ModifiedPathsPipeExceptionType, + [nameof(this.ModifiedPathsPipeExceptionHResult)] = this.ModifiedPathsPipeExceptionHResult, + [nameof(this.TotalMs)] = this.TotalMs, + [nameof(this.IndexReadMs)] = this.IndexReadMs, + [nameof(this.PlaceholderLoadMs)] = this.PlaceholderLoadMs, + [nameof(this.ModifiedPathsLoadMs)] = this.ModifiedPathsLoadMs, + [nameof(this.TreeCountMs)] = this.TreeCountMs, + [nameof(this.EarlyExit)] = this.EarlyExit, + [nameof(this.CancellationRequested)] = this.CancellationRequested, + [nameof(this.ExceptionType)] = this.ExceptionType, + [nameof(this.ExceptionHResult)] = this.ExceptionHResult, + }; + } + + internal void StartPhase(HydrationSummaryPhase phase) + { + this.Phase = phase; + this.phaseActive = true; + this.phaseStopwatch.Restart(); + } + + internal void FinishPhase(HydrationDataState state) + { + if (!this.phaseActive) + { + return; + } + + long elapsedMs = this.phaseStopwatch.ElapsedMilliseconds; + switch (this.Phase) + { + case HydrationSummaryPhase.IndexRead: + this.IndexReadMs = elapsedMs; + this.IndexState = state; + break; + case HydrationSummaryPhase.Placeholders: + this.PlaceholderLoadMs = elapsedMs; + this.PlaceholderState = state; + break; + case HydrationSummaryPhase.ModifiedPaths: + this.ModifiedPathsLoadMs = elapsedMs; + this.ModifiedPathsState = state; + break; + case HydrationSummaryPhase.FolderCount: + this.TreeCountMs = elapsedMs; + this.FolderCountState = state; + break; + } + + this.phaseActive = false; + } + } +} diff --git a/GVFS/GVFS.Common/NamedPipes/HydrationStatusNamedPipeMessages.cs b/GVFS/GVFS.Common/NamedPipes/HydrationStatusNamedPipeMessages.cs index 99ab4bfbd4..53259fc2cf 100644 --- a/GVFS/GVFS.Common/NamedPipes/HydrationStatusNamedPipeMessages.cs +++ b/GVFS/GVFS.Common/NamedPipes/HydrationStatusNamedPipeMessages.cs @@ -1,4 +1,5 @@ using System; +using System.Globalization; namespace GVFS.Common.NamedPipes { @@ -10,8 +11,80 @@ public static class HydrationStatus public const string SuccessResult = "S"; public const string NotAvailableResult = "NA"; + public enum CacheState + { + NotComputed = 0, + Available = 1, + ProviderUnavailable = 2, + FeatureDisabled = 3, + CircuitBreakerDisabled = 4, + InvalidCounts = 5, + IncompleteData = 6, + Cancelled = 7, + Failed = 8, + } + + public enum ParseOutcome + { + Success, + Empty, + TooFewFields, + InvalidNumber, + InvalidCounts, + } + + public sealed class CacheInfo + { + public CacheState State { get; set; } + public Guid? CalculationId { get; set; } + public long AgeMs { get; set; } = -1; + public bool Invalidated { get; set; } + public bool CountsComplete { get; set; } + + public string ToBody() + { + return string.Join(",", "D1", ((int)this.State).ToString(CultureInfo.InvariantCulture), + this.CalculationId?.ToString("N") ?? "-", this.AgeMs.ToString(CultureInfo.InvariantCulture), + this.Invalidated ? "1" : "0", this.CountsComplete ? "1" : "0"); + } + + public static bool TryParse(string body, out CacheInfo info, bool hasCounts = false) + { + info = null; + if (body == null) + { + return false; + } + + string[] parts = body.Split(','); + int offset = hasCounts ? 6 : 0; + if (parts.Length < offset + 6 || parts[offset] != "D1" + || !int.TryParse(parts[offset + 1], NumberStyles.Integer, CultureInfo.InvariantCulture, out int state) + || !Enum.IsDefined(typeof(CacheState), state) + || (parts[offset + 2] != "-" && !Guid.TryParseExact(parts[offset + 2], "N", out _)) + || !long.TryParse(parts[offset + 3], NumberStyles.Integer, CultureInfo.InvariantCulture, out long ageMs) + || ageMs < -1 + || (parts[offset + 4] != "0" && parts[offset + 4] != "1") + || (parts[offset + 5] != "0" && parts[offset + 5] != "1")) + { + return false; + } + + info = new CacheInfo + { + State = (CacheState)state, + CalculationId = parts[offset + 2] == "-" ? null : Guid.ParseExact(parts[offset + 2], "N"), + AgeMs = ageMs, + Invalidated = parts[offset + 4] == "1", + CountsComplete = parts[offset + 5] == "1", + }; + return true; + } + } + /// /// Wire format: PlaceholderFileCount,PlaceholderFolderCount,ModifiedFileCount,ModifiedFolderCount,TotalFileCount,TotalFolderCount + /// An optional D1 extension follows the six counts. CacheInfo defines the extension fields. /// public class Response { @@ -45,20 +118,27 @@ public string ToDisplayMessage() return $"{filePercent}% of files and {folderPercent}% of folders hydrated. Run 'gvfs health' at the repository root for details."; } - public string ToBody() + public string ToBody(CacheInfo info = null) { - return string.Join(",", + string counts = string.Join(",", this.PlaceholderFileCount, this.PlaceholderFolderCount, this.ModifiedFileCount, this.ModifiedFolderCount, this.TotalFileCount, this.TotalFolderCount); + return info == null ? counts : counts + "," + info.ToBody(); } public static bool TryParse(string body, out Response response) + { + return TryParse(body, out response, out _); + } + + public static bool TryParse(string body, out Response response, out ParseOutcome outcome) { response = null; + outcome = ParseOutcome.Empty; if (string.IsNullOrEmpty(body)) { return false; @@ -67,6 +147,7 @@ public static bool TryParse(string body, out Response response) string[] parts = body.Split(','); if (parts.Length < 6) { + outcome = ParseOutcome.TooFewFields; return false; } @@ -77,6 +158,7 @@ public static bool TryParse(string body, out Response response) || !int.TryParse(parts[4], out int totalFileCount) || !int.TryParse(parts[5], out int totalFolderCount)) { + outcome = ParseOutcome.InvalidNumber; return false; } @@ -90,6 +172,7 @@ public static bool TryParse(string body, out Response response) TotalFolderCount = totalFolderCount, }; + outcome = response.IsValid ? ParseOutcome.Success : ParseOutcome.InvalidCounts; return response.IsValid; } } diff --git a/GVFS/GVFS.Common/NamedPipes/MountFailureEnvironment.cs b/GVFS/GVFS.Common/NamedPipes/MountFailureEnvironment.cs new file mode 100644 index 0000000000..66396ea3f2 --- /dev/null +++ b/GVFS/GVFS.Common/NamedPipes/MountFailureEnvironment.cs @@ -0,0 +1,48 @@ +using System; +using System.Threading; + +namespace GVFS.Common.NamedPipes +{ + /// + /// The process-level effects depends on: waiting, + /// sleeping, and ending the process. Injected so unit tests can observe what was + /// requested without real delays and without ending the test host. + /// + internal interface IMountFailureEnvironment + { + /// True if was set before the timeout. + bool WaitForSignal(WaitHandle signal, int timeoutMs); + + void Sleep(int durationMs); + + void ExitProcess(int exitCode); + + /// + /// Parks the calling thread until another thread ends the process. + /// + void BlockUntilProcessExits(); + } + + internal sealed class ProcessMountFailureEnvironment : IMountFailureEnvironment + { + public bool WaitForSignal(WaitHandle signal, int timeoutMs) + { + return signal.WaitOne(timeoutMs); + } + + public void Sleep(int durationMs) + { + Thread.Sleep(durationMs); + } + + public void ExitProcess(int exitCode) + { + Environment.Exit(exitCode); + } + + public void BlockUntilProcessExits() + { + Thread.Sleep(Timeout.Infinite); + } + } +} diff --git a/GVFS/GVFS.Common/NamedPipes/MountFailureReporter.cs b/GVFS/GVFS.Common/NamedPipes/MountFailureReporter.cs new file mode 100644 index 0000000000..3cbcb64962 --- /dev/null +++ b/GVFS/GVFS.Common/NamedPipes/MountFailureReporter.cs @@ -0,0 +1,200 @@ +using GVFS.Common.Tracing; +using System; +using System.Threading; + +namespace GVFS.Common.NamedPipes +{ + /// + /// Server half of the mount-failure handshake. A failing mount process stays alive + /// until a client has read the reason over the named pipe, or until a short timeout + /// elapses, so the failure response reaches the client before the process exits. + /// + /// + /// Until the process exits it holds the mount lock and the named pipe, so the wait is + /// bounded and short. This type owns only the handshake state. The caller decides how + /// and when the process exits. The client half of the protocol is + /// . + /// + public sealed class MountFailureReporter + { + /// + /// Cap on how long to wait once a client is known to be polling. + /// + internal const int DefaultReportTimeoutMs = 2000; + + /// + /// Cap used when no client has received a status yet. It only has to cover the + /// gap between the pipe opening and the client's first poll. + /// + internal const int DefaultNoClientTimeoutMs = 500; + + /// + /// Time to stay alive after a client reads the failure. Any client can satisfy the + /// wait, and it is not necessarily the mounting one: a concurrent "gvfs status" + /// polls the same pipe. The mounting client must get at least one more poll before + /// the process exits, so this is longer than the client's poll interval. + /// + internal const int DefaultDrainMs = GVFSEnlistment.MountStatusPollIntervalMs * 3 / 2; + + private readonly ITracer tracer; + private readonly IMountFailureEnvironment environment; + + // Held for the life of the process, which ends through Environment.Exit. + private readonly ManualResetEvent failureReported = new ManualResetEvent(false); + + private volatile string failureMessage; + private volatile bool namedPipeReady; + private volatile bool clientReceivedStatus; + + // 0 means no thread owns the failure path yet. + private int owningThreadId; + + public MountFailureReporter(ITracer tracer) + : this(tracer, new ProcessMountFailureEnvironment()) + { + } + + internal MountFailureReporter(ITracer tracer, IMountFailureEnvironment environment) + { + this.tracer = tracer; + this.environment = environment; + } + + /// + /// The reason the mount failed, or null if the mount has not failed. Sent to the + /// client in the GetStatus response. + /// + public string FailureMessage + { + get { return this.failureMessage; } + } + + /// + /// Formats a failure message without letting the formatting itself throw. + /// + /// + /// Callers pass messages built from external text (exception strings, server + /// responses) that can contain unbalanced braces. A FormatException here would + /// mask the failure being reported, so fall back to the unformatted string. + /// + public static string FormatFailure(string error, object[] args) + { + if (args == null || args.Length == 0) + { + return error; + } + + try + { + return string.Format(error, args); + } + catch (FormatException) + { + return error; + } + } + + /// + /// Records that the named pipe server is accepting requests. A failure before this + /// point cannot be reported over the pipe, so it does not wait for a client. + /// + public void OnNamedPipeReady() + { + this.namedPipeReady = true; + } + + /// + /// Takes ownership of the mount-failure path. + /// + /// + /// True if the calling thread now owns reporting and must drive it to process exit. + /// False if it does not. Both false outcomes end in process exit, so the caller + /// does not continue in production: + /// + /// The calling thread already owns reporting, because something threw while it + /// reported and an outer catch block called back in. It exits now. Waiting again + /// would block on a signal that this same thread has to observe. + /// Another thread owns reporting. The calling thread blocks until that thread + /// ends the process, so it does not carry on with a half-initialized mount. + /// + /// + public bool TryBeginFailure(int exitCode, string message) + { + int currentThreadId = Environment.CurrentManagedThreadId; + int previousOwner = Interlocked.CompareExchange(ref this.owningThreadId, currentThreadId, 0); + + if (previousOwner == currentThreadId) + { + this.tracer.RelatedWarning( + null, + $"{nameof(this.TryBeginFailure)}: Re-entered while reporting a mount failure: {message}", + Keywords.Telemetry); + this.environment.ExitProcess(exitCode); + return false; + } + + if (previousOwner != 0) + { + this.environment.BlockUntilProcessExits(); + return false; + } + + this.failureMessage = message; + return true; + } + + /// + /// Sends a status response, and records the outcome only if the send succeeded. + /// + /// Sends the response. Returns true if it reached the client. + /// True if the response reports MountFailed. + /// + /// The send must come first. Recording a client that never received the response + /// would release the wait and let the process exit before any client read the + /// failure. + /// + public bool TrySendStatus(Func trySend, bool carriesMountFailure) + { + if (!trySend()) + { + return false; + } + + this.clientReceivedStatus = true; + + if (carriesMountFailure) + { + this.failureReported.Set(); + } + + return true; + } + + /// + /// Blocks until a client has read the failure, or until a short timeout elapses. + /// Returns immediately when no client could read it. + /// + /// True if a client read the failure. + public bool WaitForClientToReadFailure() + { + if (!this.namedPipeReady) + { + return false; + } + + int timeoutMs = this.clientReceivedStatus ? DefaultReportTimeoutMs : DefaultNoClientTimeoutMs; + + if (this.environment.WaitForSignal(this.failureReported, timeoutMs)) + { + this.environment.Sleep(DefaultDrainMs); + return true; + } + + this.tracer.RelatedWarning( + null, + $"{nameof(this.WaitForClientToReadFailure)}: No client read the mount failure within {timeoutMs}ms. Exiting anyway.", + Keywords.Telemetry); + return false; + } + } +} diff --git a/GVFS/GVFS.Common/NamedPipes/NamedPipeClient.cs b/GVFS/GVFS.Common/NamedPipes/NamedPipeClient.cs index 18745788fa..54c282ae49 100644 --- a/GVFS/GVFS.Common/NamedPipes/NamedPipeClient.cs +++ b/GVFS/GVFS.Common/NamedPipes/NamedPipeClient.cs @@ -18,6 +18,12 @@ public NamedPipeClient(string pipeName) public bool Connect(int timeoutMilliseconds = 3000) { + return this.Connect(timeoutMilliseconds, out _); + } + + public virtual bool Connect(int timeoutMilliseconds, out Exception error) + { + error = null; if (this.clientStream != null) { throw new InvalidOperationException(); @@ -28,12 +34,14 @@ public bool Connect(int timeoutMilliseconds = 3000) this.clientStream = new NamedPipeClientStream(this.pipeName); this.clientStream.Connect(timeoutMilliseconds); } - catch (TimeoutException) + catch (TimeoutException ex) { + error = ex; return false; } - catch (IOException) + catch (IOException ex) { + error = ex; return false; } @@ -57,7 +65,7 @@ public bool TrySendRequest(NamedPipeMessages.Message message) return false; } - public void SendRequest(NamedPipeMessages.Message message) + public virtual void SendRequest(NamedPipeMessages.Message message) { this.SendRequest(message.ToString()); } @@ -94,7 +102,7 @@ public string ReadRawResponse() } } - public NamedPipeMessages.Message ReadResponse() + public virtual NamedPipeMessages.Message ReadResponse() { return NamedPipeMessages.Message.FromString(this.ReadRawResponse()); } @@ -113,7 +121,7 @@ public bool TryReadResponse(out NamedPipeMessages.Message message) } } - public void Dispose() + public virtual void Dispose() { this.ValidateConnection(); diff --git a/GVFS/GVFS.Common/NamedPipes/NamedPipeMessages.cs b/GVFS/GVFS.Common/NamedPipes/NamedPipeMessages.cs index 62447659ee..5d1e4794a4 100644 --- a/GVFS/GVFS.Common/NamedPipes/NamedPipeMessages.cs +++ b/GVFS/GVFS.Common/NamedPipes/NamedPipeMessages.cs @@ -36,6 +36,14 @@ public class Response { public string MountStatus { get; set; } public string MountProgress { get; set; } + + /// + /// Why the mount failed. Set only when is + /// , so the client can report the real cause + /// instead of a generic failure message. + /// + public string MountError { get; set; } + public string EnlistmentRoot { get; set; } public string LocalCacheRoot { get; set; } public string RepoUrl { get; set; } diff --git a/GVFS/GVFS.Common/Prefetch/Pipeline/HydrateFilesStage.cs b/GVFS/GVFS.Common/Prefetch/Pipeline/HydrateFilesStage.cs index f62360c5a3..c27748976c 100644 --- a/GVFS/GVFS.Common/Prefetch/Pipeline/HydrateFilesStage.cs +++ b/GVFS/GVFS.Common/Prefetch/Pipeline/HydrateFilesStage.cs @@ -1,8 +1,10 @@ -using GVFS.Common.Prefetch.Git; +using GVFS.Common.FileSystem; +using GVFS.Common.Prefetch.Git; using GVFS.Common.Tracing; +using System; using System.Collections.Concurrent; using System.Collections.Generic; -using System.IO; +using System.IO; using System.Threading; namespace GVFS.Common.Prefetch.Pipeline @@ -33,18 +35,26 @@ public int ReadFileCount protected override void DoWork() { - using (ITracer activity = this.tracer.StartActivity("ReadFiles", EventLevel.Informational)) + // Keywords.Telemetry on the Stop event is required so the FailureSignatureCounts + // summary (added below) actually reaches the telemetry pipe — without it, the + // activity's Stop event is filtered out before it gets there. + using (ITracer activity = this.tracer.StartActivity("ReadFiles", EventLevel.Informational, Keywords.Telemetry, null)) { int readFilesCurrentThread = 0; int failedFilesCurrentThread = 0; + // Keyed by HydrationFailureDiagnostics.GetFailureSignature(...) so a large failed + // prefetch reports distinct failure kinds once in the summary, rather than only via + // the (already per-file) "Failed to read" events below. + Dictionary failureSignatureCounts = null; + byte[] buffer = new byte[1]; string blobId; while (this.availableBlobs.TryTake(out blobId, Timeout.Infinite)) { foreach (PathWithMode modeAndPath in this.blobIdToPaths[blobId]) { - bool succeeded = GVFSPlatform.Instance.FileSystem.HydrateFile(Path.Combine(this.workingDirectoryRoot, modeAndPath.Path), buffer); + bool succeeded = GVFSPlatform.Instance.FileSystem.HydrateFile(Path.Combine(this.workingDirectoryRoot, modeAndPath.Path), buffer, out Exception failure); if (succeeded) { Interlocked.Increment(ref this.readFileCount); @@ -52,20 +62,32 @@ protected override void DoWork() } else { - activity.RelatedError("Failed to read " + modeAndPath.Path); + EventMetadata metadata = HydrationFailureDiagnostics.BuildMetadata(modeAndPath.Path, failure); + activity.RelatedError(metadata, "Failed to read " + modeAndPath.Path); failedFilesCurrentThread++; this.HasFailures = true; + + failureSignatureCounts ??= new Dictionary(); + string signature = HydrationFailureDiagnostics.GetFailureSignature(failure); + failureSignatureCounts.TryGetValue(signature, out int signatureCount); + failureSignatureCounts[signature] = signatureCount + 1; } - } + } } - activity.Stop( - new EventMetadata - { - { "FilesRead", readFilesCurrentThread }, - { "Failures", failedFilesCurrentThread }, - }); + EventMetadata stopMetadata = new EventMetadata + { + { "FilesRead", readFilesCurrentThread }, + { "Failures", failedFilesCurrentThread }, + }; + + if (failureSignatureCounts != null) + { + stopMetadata.Add("FailureSignatureCounts", HydrationFailureDiagnostics.FormatSignatureCounts(failureSignatureCounts)); + } + + activity.Stop(stopMetadata); } } } diff --git a/GVFS/GVFS.Common/RepoMetadata.cs b/GVFS/GVFS.Common/RepoMetadata.cs index ea3d3ebf1e..00854fc69b 100644 --- a/GVFS/GVFS.Common/RepoMetadata.cs +++ b/GVFS/GVFS.Common/RepoMetadata.cs @@ -8,6 +8,8 @@ namespace GVFS.Common { public class RepoMetadata { + public const string MissingDiskLayoutVersionMessage = "Enlistment disk layout version not found. The .gvfs metadata is incomplete. Make sure your work is pushed or backed up, then reclone this enlistment."; + private FileBasedDictionary repoMetadata; private ITracer tracer; @@ -90,16 +92,24 @@ public static void Shutdown() } public bool TryGetOnDiskLayoutVersion(out int majorVersion, out int minorVersion, out string error) + { + ReturnCode returnCode; + return this.TryGetOnDiskLayoutVersion(out majorVersion, out minorVersion, out error, out returnCode); + } + + public bool TryGetOnDiskLayoutVersion(out int majorVersion, out int minorVersion, out string error, out ReturnCode returnCode) { majorVersion = 0; minorVersion = 0; + returnCode = ReturnCode.GenericError; try { string value; if (!this.repoMetadata.TryGetValue(Keys.DiskLayoutMajorVersion, out value)) { - error = "Enlistment disk layout version not found, check if a breaking change has been made to GVFS since cloning this enlistment."; + error = MissingDiskLayoutVersionMessage; + returnCode = ReturnCode.MissingDiskLayoutVersion; return false; } @@ -125,6 +135,7 @@ public bool TryGetOnDiskLayoutVersion(out int majorVersion, out int minorVersion } error = null; + returnCode = ReturnCode.Success; return true; } diff --git a/GVFS/GVFS.Common/ReturnCode.cs b/GVFS/GVFS.Common/ReturnCode.cs index 31842f396f..4c5347cc67 100644 --- a/GVFS/GVFS.Common/ReturnCode.cs +++ b/GVFS/GVFS.Common/ReturnCode.cs @@ -14,5 +14,6 @@ public enum ReturnCode AuthenticationError = 9, CredentialTimeout = 10, RemoteGvfsConfigError = 11, + MissingDiskLayoutVersion = 12, } } diff --git a/GVFS/GVFS.Common/Tracing/TelemetryDaemonEventListener.cs b/GVFS/GVFS.Common/Tracing/TelemetryDaemonEventListener.cs index 69e50d3fae..b293898104 100644 --- a/GVFS/GVFS.Common/Tracing/TelemetryDaemonEventListener.cs +++ b/GVFS/GVFS.Common/Tracing/TelemetryDaemonEventListener.cs @@ -16,7 +16,7 @@ public class TelemetryDaemonEventListener : EventListener, IQueuedPipeStringWrit private QueuedPipeStringWriter pipeWriter; - private TelemetryDaemonEventListener( + internal TelemetryDaemonEventListener( string providerName, string enlistmentId, string mountId, diff --git a/GVFS/GVFS.FunctionalTests.LockHolder/Program.cs b/GVFS/GVFS.FunctionalTests.LockHolder/Program.cs index ed0693cba3..b8362179c7 100644 --- a/GVFS/GVFS.FunctionalTests.LockHolder/Program.cs +++ b/GVFS/GVFS.FunctionalTests.LockHolder/Program.cs @@ -1,4 +1,6 @@ -using System.CommandLine; +using System; +using System.CommandLine; +using System.Threading; namespace GVFS.FunctionalTests.LockHolder { @@ -6,6 +8,29 @@ public class Program { public static void Main(string[] args) { + if (args.Length > 0 && args[0] == "--wait-for-event") + { + string eventName = Environment.GetEnvironmentVariable("GVFS_TEST_HOOK_WAIT_EVENT_NAME"); + if (string.IsNullOrEmpty(eventName)) + { + Console.Error.WriteLine("The test did not supply the hook event name."); + Environment.Exit(1); + } + + using (EventWaitHandle ready = EventWaitHandle.OpenExisting(eventName + ".Ready")) + using (EventWaitHandle release = EventWaitHandle.OpenExisting(eventName + ".Release")) + { + ready.Set(); + if (!release.WaitOne(TimeSpan.FromSeconds(30))) + { + Console.Error.WriteLine("Timed out before the test released the command hook."); + Environment.Exit(1); + } + } + + return; + } + RootCommand rootCommand = AcquireGVFSLockVerb.BuildRootCommand(); rootCommand.Parse(args).Invoke(); } diff --git a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/GitReadAndGitLockTests.cs b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/GitReadAndGitLockTests.cs index cb7af7571d..af26622b0b 100644 --- a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/GitReadAndGitLockTests.cs +++ b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/GitReadAndGitLockTests.cs @@ -4,6 +4,7 @@ using GVFS.Tests.Should; using NUnit.Framework; using System; +using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Threading; @@ -48,14 +49,82 @@ public void GitBranch() "remotes/origin/" + Properties.Settings.Default.Commitish); } - [TestCase, Order(4)] - public void GitCommandWaitsWhileAnotherIsRunning() + [TestCase("status")] + [TestCase("for-each-repo")] + [Order(4)] + public void GitCommandWaitsWhileAnotherIsRunning(string command) { - int pid; - GitHelpers.AcquireGVFSLock(this.Enlistment, out pid, resetTimeout: 3000); + string emptyConfigPath = null; + Dictionary environmentVariables = null; + try + { + if (command == "for-each-repo") + { + emptyConfigPath = Path.Combine(this.Enlistment.EnlistmentRoot, "empty-git-config-" + Guid.NewGuid().ToString("N")); + File.WriteAllText(emptyConfigPath, string.Empty); + environmentVariables = new Dictionary + { + { "GIT_CONFIG_GLOBAL", emptyConfigPath }, + { "GIT_CONFIG_NOSYSTEM", "1" }, + { "GIT_CONFIG_COUNT", "0" }, + { "GIT_CONFIG_PARAMETERS", string.Empty }, + }; + + ProcessResult alias = GitProcess.InvokeProcess( + this.Enlistment.RepoRoot, + "config --get alias.for-each-repo", + environmentVariables); + alias.ExitCode.ShouldEqual(1); + alias.Output.ShouldBeEmpty(); + + command = "for-each-repo --config=gvfs.test-lock-policy-repos-" + Guid.NewGuid().ToString("N"); + } - ProcessResult statusWait = GitHelpers.InvokeGitAgainstGVFSRepo(this.Enlistment.RepoRoot, "status", removeWaitingMessages: false); - statusWait.Errors.ShouldContain(ExpectedStatusWaitingText); + int pid; + GitHelpers.AcquireGVFSLock(this.Enlistment, out pid, resetTimeout: 3000); + + ProcessResult result = GitHelpers.InvokeGitAgainstGVFSRepo( + this.Enlistment.RepoRoot, + command, + environmentVariables, + removeWaitingMessages: false); + result.ExitCode.ShouldEqual(0); + result.Errors.ShouldContain(ExpectedStatusWaitingText); + } + finally + { + if (emptyConfigPath != null) + { + File.Delete(emptyConfigPath); + } + } + } + + [TestCase("worktree list")] + [TestCase("worktree list --porcelain")] + [TestCase("worktree list --porcelain -z")] + [TestCase("annotate HEAD -- Readme.md")] + [TestCase("check-ref-format --branch topic")] + [TestCase("cherry HEAD HEAD")] + [TestCase("count-objects -vH")] + [TestCase("ls-remote --get-url origin")] + [TestCase("range-diff HEAD~1..HEAD HEAD~1..HEAD")] + [TestCase("shortlog -sn HEAD")] + [TestCase("show-branch --list")] + public void ReadOnlyCommandDoesNotWaitForLock(string command) + { + GitHelpers.RunWithGVFSLock( + this.Enlistment, + () => + { + ProcessResult result = GitProcess.InvokeProcess( + this.Enlistment.RepoRoot, + command, + timeoutMs: AcquireGVFSLockTimeout); + + result.ExitCode.ShouldEqual(0); + result.Errors.ShouldNotContain(false, ExpectedStatusWaitingText); + }); } [TestCase, Order(5)] diff --git a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/HealthTests.cs b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/HealthTests.cs index e469991750..f8c122861b 100644 --- a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/HealthTests.cs +++ b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/HealthTests.cs @@ -8,6 +8,9 @@ using System.Globalization; using System.IO; using System.Text.RegularExpressions; +using System.Linq; +using System.Text.Json; +using System.Threading; namespace GVFS.FunctionalTests.Tests.EnlistmentPerFixture { @@ -137,6 +140,110 @@ public void FilterIntoDirectory() enlistmentHealthStatus: "Highly Hydrated"); } + [TestCase(0), Order(5)] + [TestCase(5)] + public void StatusRecordsTheDisplayedResultAndFallbackDiagnostics(int backoffSeconds) + { + this.RemountWithHydrationStatus(false, backoffSeconds); + JsonElement root = this.WaitForHydrationCacheState("FeatureDisabled", out string output, out string[] lines); + Assert.AreEqual("Status", root.GetProperty("Mode").GetString()); + Guid.ParseExact(root.GetProperty("CommandId").GetString(), "N").ShouldNotEqual(Guid.Empty); + JsonElement cache = root.GetProperty("Cache"); + cache.GetProperty("Outcome").GetString().ShouldEqual("NotAvailable"); + cache.GetProperty("DiagnosticsStatus").GetString().ShouldEqual("Parsed"); + Assert.IsTrue(cache.GetProperty("DiagnosticsAvailable").GetBoolean()); + cache.GetProperty("State").GetString().ShouldEqual("FeatureDisabled"); + string displayOutcome = root.GetProperty("DisplayOutcome").GetString(); + output.ShouldContain(displayOutcome == "Percentage" ? "% of files and " : "Error calculating hydration summary."); + root.GetProperty("ResultSource").GetString().ShouldEqual("Fallback"); + string calculation = lines.Single(line => line.Contains("HydrationSummaryDuration ")); + using (JsonDocument calculationDocument = JsonDocument.Parse(calculation.Substring(calculation.IndexOf('{')))) + { + JsonElement summary = calculationDocument.RootElement; + summary.GetProperty("CommandId").GetString().ShouldEqual(root.GetProperty("CommandId").GetString()); + summary.GetProperty("CalculationId").GetString().ShouldEqual(root.GetProperty("CalculationId").GetString()); + summary.GetProperty("FolderCountSource").GetString().ShouldEqual("IndexParser"); + Assert.AreEqual(JsonValueKind.Number, summary.GetProperty("TotalFileCount").ValueKind); + Assert.AreEqual(JsonValueKind.Number, summary.GetProperty("PlaceholderFolderCount").ValueKind); + } + } + + [TestCase, Order(6)] + public void DetailedHealthRecordsItsCommandMode() + { + HashSet previous = new HashSet(Directory.GetFiles(this.Enlistment.GVFSLogsRoot, "gvfs_health_*.log")); + this.Enlistment.Health(); + using (JsonDocument completion = this.ReadHealthCompletion(previous, out _)) + { + completion.RootElement.GetProperty("Mode").GetString().ShouldEqual("Detailed"); + completion.RootElement.GetProperty("Outcome").GetString().ShouldEqual("Success"); + Assert.IsFalse(completion.RootElement.TryGetProperty("Cache", out _)); + } + } + + [TestCase, Order(7)] + public void CachedStatusRecordsThePublishedCalculationIdentity() + { + this.RemountWithHydrationStatus(true); + JsonElement root = this.WaitForHydrationCacheState("Available", out string output, out _); + root.GetProperty("ResultSource").GetString().ShouldEqual("Cache"); + JsonElement cache = root.GetProperty("Cache"); + cache.GetProperty("Outcome").GetString().ShouldEqual("Success"); + cache.GetProperty("DiagnosticsStatus").GetString().ShouldEqual("Parsed"); + cache.GetProperty("State").GetString().ShouldEqual("Available"); + Assert.IsTrue(cache.GetProperty("CountsComplete").GetBoolean()); + Assert.That(cache.GetProperty("AgeMs").GetInt64(), Is.GreaterThanOrEqualTo(0)); + Guid.ParseExact(cache.GetProperty("CalculationId").GetString(), "N").ShouldNotEqual(Guid.Empty); + root.GetProperty("CalculationId").GetString().ShouldEqual(cache.GetProperty("CalculationId").GetString()); + root.GetProperty("Outcome").GetString().ShouldEqual("Success"); + root.GetProperty("CalculationOutcome").GetString().ShouldEqual("Success"); + output.ShouldContain("% of files and "); + } + + private JsonElement WaitForHydrationCacheState(string expectedState, out string output, out string[] lines) + { + Stopwatch deadline = Stopwatch.StartNew(); + string lastCompletion = string.Empty; + output = string.Empty; + lines = Array.Empty(); + while (deadline.Elapsed < TimeSpan.FromSeconds(30)) + { + HashSet previous = new HashSet(Directory.GetFiles(this.Enlistment.GVFSLogsRoot, "gvfs_health_*.log")); + output = this.Enlistment.Health(statusOnly: true); + using (JsonDocument completion = this.ReadHealthCompletion(previous, out lines)) + { + JsonElement root = completion.RootElement; + lastCompletion = root.GetRawText(); + if (root.GetProperty("Cache").GetProperty("State").GetString() == expectedState) + { + return root.Clone(); + } + } + + Thread.Sleep(100); + } + + throw new AssertionException("The hydration cache did not reach " + expectedState + ". Last completion: " + lastCompletion); + } + + private void RemountWithHydrationStatus(bool enabled, int backoffSeconds = 2) + { + this.Enlistment.UnmountGVFS(); + GitProcess.InvokeProcess(this.Enlistment.RepoRoot, "config --local gvfs.show-hydration-status " + (enabled ? "true" : "false")).ExitCode.ShouldEqual(0); + GitProcess.InvokeProcess(this.Enlistment.RepoRoot, "config --local gvfs.status-cache-backoff-seconds " + backoffSeconds).ExitCode.ShouldEqual(0); + this.Enlistment.MountGVFS(); + GitProcess.InvokeProcess(this.Enlistment.RepoRoot, "status --porcelain").ExitCode.ShouldEqual(0); + this.Enlistment.WaitForBackgroundOperations(); + } + + private JsonDocument ReadHealthCompletion(HashSet previous, out string[] lines) + { + string log = Directory.GetFiles(this.Enlistment.GVFSLogsRoot, "gvfs_health_*.log").Single(path => !previous.Contains(path)); + lines = File.ReadAllLines(log); + string line = lines.Single(value => value.Contains("health (Stop)")); + return JsonDocument.Parse(line.Substring(line.IndexOf('{'))); + } + private void HydratePlaceholder(string filePath) { File.ReadAllText(filePath); diff --git a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/MountTests.cs b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/MountTests.cs index 576534d1cc..963bb449f3 100644 --- a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/MountTests.cs +++ b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/MountTests.cs @@ -18,6 +18,10 @@ namespace GVFS.FunctionalTests.Tests.EnlistmentPerFixture public class MountTests : TestsWithEnlistmentPerFixture { private const int GVFSGenericError = 3; + // These values are a shipped command-line contract. Do not bind them to product constants. + private const int GVFSMissingDiskLayoutVersion = 12; + private const string MissingDiskLayoutVersionMessage = "The .gvfs metadata is incomplete"; + private const string GVFSLogMessage = "Run 'gvfs log"; private const uint GenericRead = 2147483648; private const uint FileFlagBackupSemantics = 3355443; private readonly int fileDeletedBackgroundOperationCode; @@ -99,6 +103,34 @@ public void MountChangesMountId() .ShouldNotEqual(mountId, "gvfs.mount-id should change on every mount"); } + [TestCase] + public void MountFailsWithUniqueExitCodeWhenDiskLayoutVersionIsMissing() + { + this.Enlistment.UnmountGVFS(); + + string majorVersion; + string minorVersion; + GVFSHelpers.GetPersistedDiskLayoutVersion(this.Enlistment.DotGVFSRoot, out majorVersion, out minorVersion); + majorVersion.ShouldNotBeNull(); + minorVersion.ShouldNotBeNull(); + + try + { + GVFSHelpers.DeletePersistedDiskLayoutMajorVersion(this.Enlistment.DotGVFSRoot); + + this.MountShouldFail( + GVFSMissingDiskLayoutVersion, + MissingDiskLayoutVersionMessage, + additionalExpectedErrorMessage: GVFSLogMessage); + } + finally + { + GVFSHelpers.SaveDiskLayoutVersion(this.Enlistment.DotGVFSRoot, majorVersion, minorVersion); + } + + this.Enlistment.MountGVFS(); + } + [TestCase] public void MountFailsWhenNoOnDiskVersion() { @@ -126,7 +158,10 @@ public void MountFailsWhenNoOnDiskVersion() this.fileSystem.MoveFile(versionDatabasePath, tempDatabasePath); versionDatabasePath.ShouldNotExistOnDisk(this.fileSystem); - this.MountShouldFail("Failed to upgrade repo disk layout"); + this.MountShouldFail( + GVFSMissingDiskLayoutVersion, + MissingDiskLayoutVersionMessage, + additionalExpectedErrorMessage: GVFSLogMessage); } finally { @@ -141,6 +176,43 @@ public void MountFailsWhenNoOnDiskVersion() this.Enlistment.MountGVFS(); } + [TestCase] + public void MountReportsUpgradeExceptionWithoutStackTrace() + { + this.Enlistment.UnmountGVFS(); + + string metadataPath = Path.Combine(this.Enlistment.DotGVFSRoot, GVFSHelpers.RepoMetadataName); + string originalMetadata = File.ReadAllText(metadataPath); + + try + { + File.WriteAllText(metadataPath, "\r\n"); + + string output = this.MountShouldFail( + GVFSGenericError, + "Failed to upgrade repo disk layout.", + additionalExpectedErrorMessage: GVFSLogMessage); + + output.ShouldContain("Index was outside the bounds of the array."); + output.ShouldNotContain( + ignoreCase: false, + "System.IndexOutOfRangeException", + "FileBasedCollectionException", + "TryLoadFromDisk", + "TryRunAllUpgrades"); + + this.LatestGVFSLogShouldContain("System.IndexOutOfRangeException"); + this.LatestGVFSLogShouldContain("TryLoadFromDisk"); + this.LatestGVFSLogShouldContain("TryRunAllUpgrades"); + } + finally + { + File.WriteAllText(metadataPath, originalMetadata); + } + + this.Enlistment.MountGVFS(); + } + [TestCase] public void MountFailsWhenNoLocalCacheRootInRepoMetadata() { @@ -359,7 +431,7 @@ public void ProjFS_CMDHangNoneActiveInstance() this.Enlistment.MountGVFS(); } - private void MountShouldFail(int expectedExitCode, string expectedErrorMessage, string mountWorkingDirectory = null) + private string MountShouldFail(int expectedExitCode, string expectedErrorMessage, string mountWorkingDirectory = null, string additionalExpectedErrorMessage = null) { string enlistmentRoot = this.Enlistment.EnlistmentRoot; @@ -379,7 +451,14 @@ private void MountShouldFail(int expectedExitCode, string expectedErrorMessage, { string combinedOutput = result.Output + "\n" + result.Errors; combinedOutput.ShouldContain(expectedErrorMessage); + + if (additionalExpectedErrorMessage != null) + { + combinedOutput.ShouldContain(additionalExpectedErrorMessage); + } } + + return result.Output + "\n" + result.Errors; } private void MountShouldFail(string expectedErrorMessage, string mountWorkingDirectory = null) diff --git a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs index 7de8bb680a..1ab25b7080 100644 --- a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs +++ b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs @@ -9,6 +9,7 @@ using System.Linq; using System.Text; using System.Threading; +using ProcessHelper = GVFS.FunctionalTests.Tools.ProcessHelper; using ProcessResult = GVFS.FunctionalTests.Tools.ProcessResult; namespace GVFS.FunctionalTests.Tests.EnlistmentPerFixture @@ -363,6 +364,230 @@ public void WorktreeUsesPerWorktreePlaceholderDatabase() } } + [TestCase(false)] + [TestCase(true)] + public void WorktreeDehydratePreservesPrimaryIndex(bool trailingSeparator) + { + string suffix = Guid.NewGuid().ToString("N").Substring(0, 8); + string worktreeName = $"index-dehydrate-{suffix}"; + string worktreePath = Path.Combine(this.Enlistment.EnlistmentRoot, worktreeName); + string branchName = $"index-dehydrate-branch-{suffix}"; + string backupParent = Path.Combine(this.Enlistment.EnlistmentRoot, "dehydrate_backup", worktreeName); + + try + { + ProcessResult addResult = GitHelpers.InvokeGitAgainstGVFSRepo( + this.Enlistment.RepoRoot, + $"worktree add -b {branchName} \"{worktreePath}\""); + addResult.ExitCode.ShouldEqual(0, addResult.Errors); + this.AssertWorktreeMounted(worktreePath, "index dehydrate worktree"); + + File.WriteAllText(Path.Combine(worktreePath, "worktree-index-only.txt"), "Linked worktree content"); + GitHelpers.InvokeGitAgainstGVFSRepo(worktreePath, "add worktree-index-only.txt").ExitCode.ShouldEqual(0); + GitHelpers.InvokeGitAgainstGVFSRepo(worktreePath, "commit -m \"Add worktree-only index entry\"").ExitCode.ShouldEqual(0); + + string worktreeIndex = this.GetGitIndexPath(worktreePath); + string primaryIndex = this.GetGitIndexPath(this.Enlistment.RepoRoot); + byte[] primaryIndexBefore = File.ReadAllBytes(primaryIndex); + byte[] worktreeIndexBefore = File.ReadAllBytes(worktreeIndex); + primaryIndexBefore.SequenceEqual(worktreeIndexBefore).ShouldBeFalse("The indexes must differ so the backup assertion detects the wrong source."); + + ProcessStartInfo startInfo = new ProcessStartInfo(GVFSTestConfig.PathToGVFS) + { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + WorkingDirectory = this.Enlistment.EnlistmentRoot, + }; + startInfo.ArgumentList.Add("dehydrate"); + startInfo.ArgumentList.Add(worktreePath + (trailingSeparator ? Path.DirectorySeparatorChar.ToString() : string.Empty)); + startInfo.ArgumentList.Add("--confirm"); + startInfo.ArgumentList.Add("--folders"); + startInfo.ArgumentList.Add("GVFS"); + startInfo.ArgumentList.Add(TestConstants.InternalUseOnlyFlag); + startInfo.ArgumentList.Add("{\"ServiceName\":\"" + this.Enlistment.ServiceName + "\"}"); + ProcessResult dehydrate = ProcessHelper.Run(startInfo, timeoutMs: 120000); + dehydrate.ExitCode.ShouldEqual(0, dehydrate.Output + dehydrate.Errors); + dehydrate.Output.ShouldContain("GVFS folder dehydrate successful."); + CollectionAssert.AreEqual(primaryIndexBefore, File.ReadAllBytes(primaryIndex), "The primary index must not change."); + + string[] backups = Directory.GetDirectories(backupParent); + backups.Length.ShouldEqual(1); + CollectionAssert.AreEqual( + worktreeIndexBefore, + File.ReadAllBytes(Path.Combine(backups[0], ".git", "index")), + "The backup must contain the linked worktree index."); + } + finally + { + this.ForceCleanupWorktree(worktreePath, branchName); + if (Directory.Exists(backupParent)) + { + Directory.Delete(backupParent, recursive: true); + } + } + } + + [TestCase(false)] + [TestCase(true)] + public void FullDehydrateRejectsLinkedWorktreesBeforeMutation(bool fromWorktree) + { + string suffix = Guid.NewGuid().ToString("N").Substring(0, 8); + string worktreePath = Path.Combine(this.Enlistment.EnlistmentRoot, $"full-guard-{suffix}"); + string branchName = $"full-guard-branch-{suffix}"; + try + { + ProcessResult addResult = GitHelpers.InvokeGitAgainstGVFSRepo( + this.Enlistment.RepoRoot, + $"worktree add -b {branchName} \"{worktreePath}\""); + addResult.ExitCode.ShouldEqual(0, addResult.Errors); + this.AssertWorktreeMounted(worktreePath, "full dehydrate guard worktree"); + + string primaryIndex = this.GetGitIndexPath(this.Enlistment.RepoRoot); + string worktreeIndex = this.GetGitIndexPath(worktreePath); + byte[] primaryIndexBefore = File.ReadAllBytes(primaryIndex); + byte[] worktreeIndexBefore = File.ReadAllBytes(worktreeIndex); + string dehydratePath = fromWorktree ? worktreePath : this.Enlistment.EnlistmentRoot; + ProcessResult result = ProcessHelper.Run( + GVFSTestConfig.PathToGVFS, + $"dehydrate \"{dehydratePath}\" --full --confirm {TestConstants.InternalUseOnlyFlag} " + + GVFSHelpers.GetInternalParameter(serviceName: this.Enlistment.ServiceName), + workingDirectory: this.Enlistment.EnlistmentRoot, + timeoutMs: 120000); + + result.ExitCode.ShouldEqual(3, result.Output + result.Errors); + result.Output.ShouldContain(fromWorktree + ? "Dehydrate --full is not supported for git worktrees." + : "Dehydrate --full is not supported while linked worktrees exist."); + CollectionAssert.AreEqual(primaryIndexBefore, File.ReadAllBytes(primaryIndex)); + CollectionAssert.AreEqual(worktreeIndexBefore, File.ReadAllBytes(worktreeIndex)); + this.AssertWorktreeMounted(worktreePath, "worktree after rejected full dehydrate"); + } + finally + { + this.ForceCleanupWorktree(worktreePath, branchName); + } + } + + [TestCase("missing")] + [TestCase("empty")] + [TestCase("unreadable")] + public void FullDehydrateRejectsUsableWorktreeOmittedByGit(string pointerState) + { + string suffix = Guid.NewGuid().ToString("N").Substring(0, 8); + string worktreePath = Path.Combine(this.Enlistment.EnlistmentRoot, $"hidden-guard-{suffix}"); + string branchName = $"hidden-guard-{suffix}"; + string reversePointer = null; + byte[] originalPointer = null; + FileStream lockedPointer = null; + try + { + ProcessResult addResult = GitHelpers.InvokeGitAgainstGVFSRepo( + this.Enlistment.RepoRoot, $"worktree add -b {branchName} \"{worktreePath}\""); + addResult.ExitCode.ShouldEqual(0, addResult.Errors); + this.AssertWorktreeMounted(worktreePath, "worktree with incomplete reverse pointer"); + string primaryIndex = this.GetGitIndexPath(this.Enlistment.RepoRoot); + string worktreeIndex = this.GetGitIndexPath(worktreePath); + reversePointer = Path.Combine(Path.GetDirectoryName(worktreeIndex), "gitdir"); + originalPointer = File.ReadAllBytes(reversePointer); + if (pointerState == "missing") + { + File.Delete(reversePointer); + } + else if (pointerState == "empty") + { + File.WriteAllBytes(reversePointer, Array.Empty()); + } + else + { + lockedPointer = new FileStream(reversePointer, FileMode.Open, FileAccess.Read, FileShare.None); + } + + ProcessResult list = GitHelpers.InvokeGitAgainstGVFSRepo(this.Enlistment.RepoRoot, "worktree list --porcelain"); + list.ExitCode.ShouldEqual(0, list.Errors); + list.Output.Contains(worktreePath.Replace('\\', '/')).ShouldBeFalse("Git must omit the worktree in this regression scenario."); + ProcessResult status = GitHelpers.InvokeGitAgainstGVFSRepo(worktreePath, "status --porcelain=v1"); + status.ExitCode.ShouldEqual(0, status.Errors); + + byte[] primaryIndexBefore = File.ReadAllBytes(primaryIndex); + byte[] worktreeIndexBefore = File.ReadAllBytes(worktreeIndex); + ProcessResult result = ProcessHelper.Run( + GVFSTestConfig.PathToGVFS, + $"dehydrate \"{this.Enlistment.EnlistmentRoot}\" --full --confirm {TestConstants.InternalUseOnlyFlag} " + + GVFSHelpers.GetInternalParameter(serviceName: this.Enlistment.ServiceName), + workingDirectory: this.Enlistment.EnlistmentRoot, + timeoutMs: 120000); + result.ExitCode.ShouldEqual(3, result.Output + result.Errors); + result.Output.ShouldContain("Full dehydrate is blocked."); + result.Output.ShouldContain(reversePointer); + CollectionAssert.AreEqual(primaryIndexBefore, File.ReadAllBytes(primaryIndex)); + CollectionAssert.AreEqual(worktreeIndexBefore, File.ReadAllBytes(worktreeIndex)); + this.AssertWorktreeMounted(worktreePath, "worktree after blocked full dehydrate"); + } + finally + { + lockedPointer?.Dispose(); + if (originalPointer != null) + { + File.WriteAllBytes(reversePointer, originalPointer); + } + + this.ForceCleanupWorktree(worktreePath, branchName); + } + } + + [TestCase] + public void WorktreeDehydrateReportsBackupFailure() + { + string suffix = Guid.NewGuid().ToString("N").Substring(0, 8); + string worktreeName = $"backup-failure-{suffix}"; + string worktreePath = Path.Combine(this.Enlistment.EnlistmentRoot, worktreeName); + string branchName = $"backup-failure-branch-{suffix}"; + string backupParent = Path.Combine(this.Enlistment.EnlistmentRoot, "dehydrate_backup", worktreeName); + try + { + ProcessResult addResult = GitHelpers.InvokeGitAgainstGVFSRepo( + this.Enlistment.RepoRoot, + $"worktree add -b {branchName} \"{worktreePath}\""); + addResult.ExitCode.ShouldEqual(0, addResult.Errors); + this.AssertWorktreeMounted(worktreePath, "backup failure worktree"); + string primaryIndex = this.GetGitIndexPath(this.Enlistment.RepoRoot); + byte[] primaryIndexBefore = File.ReadAllBytes(primaryIndex); + string worktreeIndex = this.GetGitIndexPath(worktreePath); + using (FileStream locked = new FileStream(worktreeIndex, FileMode.Open, FileAccess.Read, FileShare.None)) + { + ProcessResult result = ProcessHelper.Run( + GVFSTestConfig.PathToGVFS, + $"dehydrate \"{worktreePath}\" --confirm --no-status --folders GVFS {TestConstants.InternalUseOnlyFlag} " + + GVFSHelpers.GetInternalParameter(serviceName: this.Enlistment.ServiceName), + workingDirectory: this.Enlistment.EnlistmentRoot, + timeoutMs: 120000); + result.ExitCode.ShouldEqual(3, result.Output + result.Errors); + result.Output.ShouldContain("Failed to back up"); + result.Output.ShouldContain(worktreeIndex); + } + + CollectionAssert.AreEqual(primaryIndexBefore, File.ReadAllBytes(primaryIndex)); + } + finally + { + this.ForceCleanupWorktree(worktreePath, branchName); + if (Directory.Exists(backupParent)) + { + Directory.Delete(backupParent, recursive: true); + } + } + } + + private string GetGitIndexPath(string workingDirectory) + { + ProcessResult result = GitHelpers.InvokeGitAgainstGVFSRepo( + workingDirectory, + "rev-parse --path-format=absolute --git-path index"); + result.ExitCode.ShouldEqual(0, result.Errors); + return Path.GetFullPath(result.Output.Trim()); + } + private void InitWorktreeArrays(int count, out string[] paths, out string[] branches) { paths = new string[count]; diff --git a/GVFS/GVFS.FunctionalTests/Tests/GitCommands/CreatePlaceholderTests.cs b/GVFS/GVFS.FunctionalTests/Tests/GitCommands/CreatePlaceholderTests.cs index ccf231e246..01b9ce3517 100644 --- a/GVFS/GVFS.FunctionalTests/Tests/GitCommands/CreatePlaceholderTests.cs +++ b/GVFS/GVFS.FunctionalTests/Tests/GitCommands/CreatePlaceholderTests.cs @@ -1,7 +1,10 @@ using GVFS.FunctionalTests.Properties; using GVFS.FunctionalTests.Should; using GVFS.FunctionalTests.Tools; +using GVFS.Tests.Should; using NUnit.Framework; +using System; +using System.Diagnostics; using System.IO; using System.Runtime.InteropServices; using System.Threading; @@ -19,6 +22,18 @@ public CreatePlaceholderTests(Settings.ValidateWorkingTreeMode validateWorkingTr { } + [TestCase] + public void AllowsPlaceholderCreationForDefaultAnnotate() + { + ProcessResult result = GitHelpers.InvokeGitAgainstGVFSRepo( + this.Enlistment.RepoRoot, + $"annotate -- \"{GitHelpers.ConvertPathToGitFormat(FileToRead)}\""); + + result.ExitCode.ShouldEqual(0); + result.Errors.ShouldBeEmpty(); + string.IsNullOrWhiteSpace(result.Output).ShouldBeFalse("Annotate did not return file annotations."); + } + [TestCase("check-attr --stdin --all")] [TestCase("check-ignore --stdin")] [TestCase("check-mailmap --stdin")] @@ -44,6 +59,67 @@ public void BlocksPlaceholderCreationWhileGitCommandIsRunning(string commandToRu this.CheckPlaceholderCreation(commandToRun, shouldAllow: false); } + [TestCase("worktree list")] + [TestCase("worktree list --porcelain")] + [TestCase("worktree list --porcelain -z")] + public void AllowsPlaceholderCreationWhileWorktreeListIsRunning(string command) + { + string hookListPath = this.Enlistment.GetVirtualPathTo(".git", "hooks", "post-command.hooks"); + string originalHookList = File.ReadAllText(hookListPath); + string eventName = "GVFS.WorktreeList." + Guid.NewGuid().ToString("N"); + string expectedContent = File.ReadAllText(Path.Combine(this.ControlGitRepo.RootPath, FileToRead)); + + using (EventWaitHandle ready = new EventWaitHandle(false, EventResetMode.ManualReset, eventName + ".Ready")) + using (EventWaitHandle release = new EventWaitHandle(false, EventResetMode.ManualReset, eventName + ".Release")) + using (Process git = new Process()) + { + git.StartInfo = new ProcessStartInfo(Settings.Default.PathToGit, command) + { + WorkingDirectory = this.Enlistment.RepoRoot, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + git.StartInfo.Environment["GVFS_TEST_HOOK_WAIT_EVENT_NAME"] = eventName; + + bool started = false; + try + { + // Pause the post-command chain before GVFS can release an incorrectly acquired lock. + string waitHook = $"\"{GitHelpers.LockHolderCommandPath}\" --wait-for-event"; + File.WriteAllText(hookListPath, waitHook + Environment.NewLine + originalHookList); + + started = git.Start(); + started.ShouldBeTrue(); + bool reachedHook = ready.WaitOne(TimeSpan.FromSeconds(30)); + if (!reachedHook && git.HasExited) + { + Assert.Fail($"Git exited before the wait hook. Exit code: {git.ExitCode}. Errors: {git.StandardError.ReadToEnd()}"); + } + + reachedHook.ShouldBeTrue("Git did not reach the post-command hook."); + + File.ReadAllText(this.Enlistment.GetVirtualPathTo(FileToRead)).ShouldEqual(expectedContent); + + release.Set(); + git.WaitForExit(10000).ShouldBeTrue("Git did not exit after the test released the hook."); + git.ExitCode.ShouldEqual(0); + git.StandardError.ReadToEnd().ShouldBeEmpty(); + } + finally + { + release.Set(); + if (started && !git.WaitForExit(10000)) + { + git.Kill(entireProcessTree: true); + git.WaitForExit(); + } + + File.WriteAllText(hookListPath, originalHookList); + } + } + } + private void CheckPlaceholderCreation(string command, bool shouldAllow) { string eofCharacter = "\x04"; diff --git a/GVFS/GVFS.FunctionalTests/Tools/GVFSFunctionalTestEnlistment.cs b/GVFS/GVFS.FunctionalTests/Tools/GVFSFunctionalTestEnlistment.cs index d352a53191..5a3f34b4b0 100644 --- a/GVFS/GVFS.FunctionalTests/Tools/GVFSFunctionalTestEnlistment.cs +++ b/GVFS/GVFS.FunctionalTests/Tools/GVFSFunctionalTestEnlistment.cs @@ -360,9 +360,9 @@ public string Status(string trace = null) return this.gvfsProcess.Status(trace); } - public string Health(string directory = null) + public string Health(string directory = null, bool statusOnly = false) { - return this.gvfsProcess.Health(directory); + return this.gvfsProcess.Health(directory, statusOnly); } public bool WaitForBackgroundOperations(int maxWaitMilliseconds = DefaultMaxWaitMSForStatusCheck) diff --git a/GVFS/GVFS.FunctionalTests/Tools/GVFSHelpers.cs b/GVFS/GVFS.FunctionalTests/Tools/GVFSHelpers.cs index d1b356f3d8..632a5869f7 100644 --- a/GVFS/GVFS.FunctionalTests/Tools/GVFSHelpers.cs +++ b/GVFS/GVFS.FunctionalTests/Tools/GVFSHelpers.cs @@ -50,6 +50,11 @@ public static void SaveDiskLayoutVersion(string dotGVFSRoot, string majorVersion SavePersistedValue(dotGVFSRoot, DiskLayoutMinorVersionKey, minorVersion); } + public static void DeletePersistedDiskLayoutMajorVersion(string dotGVFSRoot) + { + DeletePersistedValue(dotGVFSRoot, DiskLayoutMajorVersionKey); + } + public static void GetPersistedDiskLayoutVersion(string dotGVFSRoot, out string majorVersion, out string minorVersion) { majorVersion = GetPersistedValue(dotGVFSRoot, DiskLayoutMajorVersionKey); @@ -314,31 +319,29 @@ private static byte CharToByte(char c) private static string GetPersistedValue(string dotGVFSRoot, string key) { - string metadataPath = Path.Combine(dotGVFSRoot, RepoMetadataName); - string json; - using (FileStream fs = new FileStream(metadataPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete)) - using (StreamReader reader = new StreamReader(fs)) - { - while (!reader.EndOfStream) - { - json = reader.ReadLine(); - json.Substring(0, 2).ShouldEqual("A "); + Dictionary repoMetadata = ReadPersistedValues(dotGVFSRoot); + string value; + repoMetadata.TryGetValue(key, out value); + return value; + } - KeyValuePair kvp = GVFSJsonOptions.Deserialize>(json.Substring(2)); - if (kvp.Key == key) - { - return kvp.Value; - } - } - } + private static void SavePersistedValue(string dotGVFSRoot, string key, string value) + { + Dictionary repoMetadata = ReadPersistedValues(dotGVFSRoot); + repoMetadata[key] = value; + WritePersistedValues(dotGVFSRoot, repoMetadata); + } - return null; + private static void DeletePersistedValue(string dotGVFSRoot, string key) + { + Dictionary repoMetadata = ReadPersistedValues(dotGVFSRoot); + repoMetadata.Remove(key); + WritePersistedValues(dotGVFSRoot, repoMetadata); } - private static void SavePersistedValue(string dotGVFSRoot, string key, string value) + private static Dictionary ReadPersistedValues(string dotGVFSRoot) { string metadataPath = Path.Combine(dotGVFSRoot, RepoMetadataName); - Dictionary repoMetadata = new Dictionary(); string json; using (FileStream fs = new FileStream(metadataPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete)) @@ -350,12 +353,16 @@ private static void SavePersistedValue(string dotGVFSRoot, string key, string va json.Substring(0, 2).ShouldEqual("A "); KeyValuePair kvp = GVFSJsonOptions.Deserialize>(json.Substring(2)); - repoMetadata.Add(kvp.Key, kvp.Value); + repoMetadata[kvp.Key] = kvp.Value; } } - repoMetadata[key] = value; + return repoMetadata; + } + private static void WritePersistedValues(string dotGVFSRoot, Dictionary repoMetadata) + { + string metadataPath = Path.Combine(dotGVFSRoot, RepoMetadataName); string newRepoMetadataContents = string.Empty; foreach (KeyValuePair kvp in repoMetadata) diff --git a/GVFS/GVFS.FunctionalTests/Tools/GVFSProcess.cs b/GVFS/GVFS.FunctionalTests/Tools/GVFSProcess.cs index 7e632a279b..12d8d4c44c 100644 --- a/GVFS/GVFS.FunctionalTests/Tools/GVFSProcess.cs +++ b/GVFS/GVFS.FunctionalTests/Tools/GVFSProcess.cs @@ -179,8 +179,13 @@ public string Status(string trace = null) return this.CallGVFS("status " + this.enlistmentRoot, trace: trace); } - public string Health(string directory = null) + public string Health(string directory = null, bool statusOnly = false) { + if (statusOnly) + { + return this.CallGVFS("health --status \"" + this.enlistmentRoot + '"'); + } + if (string.IsNullOrEmpty(directory)) { return this.CallGVFS("health \"" + this.enlistmentRoot + '"'); diff --git a/GVFS/GVFS.FunctionalTests/Tools/GitHelpers.cs b/GVFS/GVFS.FunctionalTests/Tools/GitHelpers.cs index 48418452d2..3d75ec817e 100644 --- a/GVFS/GVFS.FunctionalTests/Tools/GitHelpers.cs +++ b/GVFS/GVFS.FunctionalTests/Tools/GitHelpers.cs @@ -23,7 +23,7 @@ public static class GitHelpers private const string WindowsPathSeparator = "\\"; private const string GitPathSeparator = "/"; - private static string LockHolderCommandPath + public static string LockHolderCommandPath { get { @@ -189,6 +189,46 @@ public static ManualResetEventSlim AcquireGVFSLock( out processId); } + public static void RunWithGVFSLock(GVFSFunctionalTestEnlistment enlistment, Action action) + { + ArgumentNullException.ThrowIfNull(enlistment); + ArgumentNullException.ThrowIfNull(action); + + ProcessStartInfo processInfo = new ProcessStartInfo(LockHolderCommandPath) + { + WorkingDirectory = enlistment.RepoRoot, + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + + using (Process holdingProcess = Process.Start(processInfo)) + { + try + { + enlistment.WaitForLock(LockHolderCommandName) + .ShouldBeTrue("The test lock holder did not acquire the GVFS lock."); + action(); + } + finally + { + holdingProcess.StandardInput.WriteLine("done"); + holdingProcess.StandardInput.Close(); + + bool exited = holdingProcess.WaitForExit(10000); + if (!exited) + { + holdingProcess.Kill(entireProcessTree: true); + holdingProcess.WaitForExit(); + } + + exited.ShouldBeTrue("The lock holder did not exit after the test released it."); + holdingProcess.ExitCode.ShouldEqual(0); + } + } + } + /// /// Run the specified Git command. This method will return once the GVFSLock has been acquired. /// diff --git a/GVFS/GVFS.Hooks/Program.CommandLocking.cs b/GVFS/GVFS.Hooks/Program.CommandLocking.cs new file mode 100644 index 0000000000..f759ae75e4 --- /dev/null +++ b/GVFS/GVFS.Hooks/Program.CommandLocking.cs @@ -0,0 +1,119 @@ +using GVFS.Common; +using System.Linq; + +namespace GVFS.Hooks +{ + public partial class Program + { + // The lock serializes changes to the enlistment's index and tracked working tree with GVFS projection updates. + // Before adding an exemption, check all supported options, subcommands, and child-command paths. + // Ref and object writes alone do not require this lock. Index refreshes do. + // Keep arbitrary Git dispatchers locked because nested command hooks can be suppressed. + // Excluded readers can observe concurrent working-tree changes. They do not receive snapshot isolation. + internal static bool ShouldLock(string[] args) + { + string gitCommand = GetGitCommand(args); + + switch (gitCommand) + { + // Keep these alphabetically sorted + case "annotate": + case "blame": + case "branch": + case "cat-file": + case "check-attr": + case "check-ignore": + case "check-mailmap": + case "check-ref-format": + case "cherry": + case "commit-graph": + case "config": + case "count-objects": + case "credential": + case "diff": + case "diff-files": + case "diff-index": + case "diff-tree": + case "difftool": + case "fetch": + case "for-each-ref": + case "help": + case "hash-object": + case "index-pack": + case "log": + case "ls-files": + case "ls-remote": + case "ls-tree": + case "merge-base": + case "multi-pack-index": + case "name-rev": + case "pack-objects": + case "push": + case "range-diff": + case "remote": + case "rev-list": + case "rev-parse": + case "shortlog": + case "show": + case "show-branch": + case "show-ref": + case "symbolic-ref": + case "tag": + case "unpack-objects": + case "update-ref": + case "verify-commit": + case "verify-pack": + case "verify-tag": + case "version": + case "web--browse": + return false; + + case "worktree": + return WorktreeCommandParser.GetSubcommand(args) != "list"; + + /* + * There are several git commands that are "unsupported" in virtualized (VFS4G) + * enlistments that are blocked by git. Usually, these are blocked before they acquire + * a GVFSLock, but the submodule command is different, and is blocked after acquiring the + * GVFS lock. This can cause issues if another action is attempting to create placeholders. + * As we know the submodule command is a no-op, allow it to proceed without acquiring the + * GVFSLock. Issue #1164 documents the need for git to block all unsupported commands + * before calling the pre-command hook. + */ + case "submodule": + return false; + } + + if (gitCommand == "reset" && args.Contains("--soft")) + { + return false; + } + + if (!KnownGitCommands.Contains(gitCommand) && + IsAlias(gitCommand)) + { + return false; + } + + return true; + } + + private static string GetGitCommand(string[] args) + { + string command = args[1].ToLowerInvariant(); + if (command.StartsWith("git-")) + { + command = command.Substring(4); + } + + return command; + } + + private static bool IsAlias(string command) + { + ProcessResult result = ProcessHelper.Run("git", "config --get alias." + command); + + return !string.IsNullOrEmpty(result.Output); + } + } +} diff --git a/GVFS/GVFS.Hooks/Program.cs b/GVFS/GVFS.Hooks/Program.cs index 940b2cf37d..49d72af064 100644 --- a/GVFS/GVFS.Hooks/Program.cs +++ b/GVFS/GVFS.Hooks/Program.cs @@ -425,103 +425,11 @@ private static bool IsGitEnvVarDisabled(string envVar) return false; } - private static bool ShouldLock(string[] args) - { - string gitCommand = GetGitCommand(args); - - switch (gitCommand) - { - // Keep these alphabetically sorted - case "blame": - case "branch": - case "cat-file": - case "check-attr": - case "check-ignore": - case "check-mailmap": - case "commit-graph": - case "config": - case "credential": - case "diff": - case "diff-files": - case "diff-index": - case "diff-tree": - case "difftool": - case "fetch": - case "for-each-ref": - case "help": - case "hash-object": - case "index-pack": - case "log": - case "ls-files": - case "ls-tree": - case "merge-base": - case "multi-pack-index": - case "name-rev": - case "pack-objects": - case "push": - case "remote": - case "rev-list": - case "rev-parse": - case "show": - case "show-ref": - case "symbolic-ref": - case "tag": - case "unpack-objects": - case "update-ref": - case "version": - case "web--browse": - return false; - - /* - * There are several git commands that are "unsupported" in virtualized (VFS4G) - * enlistments that are blocked by git. Usually, these are blocked before they acquire - * a GVFSLock, but the submodule command is different, and is blocked after acquiring the - * GVFS lock. This can cause issues if another action is attempting to create placeholders. - * As we know the submodule command is a no-op, allow it to proceed without acquiring the - * GVFSLock. I have filed issue #1164 to track having git block all unsupported commands - * before calling the pre-command hook. - */ - case "submodule": - return false; - } - - if (gitCommand == "reset" && args.Contains("--soft")) - { - return false; - } - - if (!KnownGitCommands.Contains(gitCommand) && - IsAlias(gitCommand)) - { - return false; - } - - return true; - } - private static string GetHookType(string[] args) { return args[0].ToLowerInvariant(); } - private static string GetGitCommand(string[] args) - { - string command = args[1].ToLowerInvariant(); - if (command.StartsWith("git-")) - { - command = command.Substring(4); - } - - return command; - } - - private static bool IsAlias(string command) - { - ProcessResult result = ProcessHelper.Run("git", "config --get alias." + command); - - return !string.IsNullOrEmpty(result.Output); - } - /// /// Extracts the git exit code from hook args. Git appends --exit_code=N /// to post-command hook arguments. Returns null if the argument is diff --git a/GVFS/GVFS.Mount/InProcessMount.cs b/GVFS/GVFS.Mount/InProcessMount.cs index 0a5c930b5f..284b87c83f 100644 --- a/GVFS/GVFS.Mount/InProcessMount.cs +++ b/GVFS/GVFS.Mount/InProcessMount.cs @@ -59,6 +59,10 @@ public class InProcessMount private volatile MountState currentState; private volatile string mountProgressMessage; + // Keeps the pipe answering GetStatus after a failure so the client can read the + // reason, instead of racing this process to exit and seeing a broken pipe. + private readonly MountFailureReporter mountFailureReporter; + // When false (default), the mount process does not surface progress phase // strings over the named pipe, so the CLI falls back to its static spinner. // This gates only the display layer; the early-pipe reliability infrastructure @@ -82,6 +86,7 @@ public InProcessMount(ITracer tracer, GVFSEnlistment enlistment, CacheServerInfo this.enlistment = enlistment; this.showDebugWindow = showDebugWindow; this.unmountEvent = new ManualResetEvent(false); + this.mountFailureReporter = new MountFailureReporter(tracer); this.missingTreeTracker = new MissingTreeTracker(tracer, TrackedTreeCapacity); } @@ -258,6 +263,8 @@ private void MountWithLockAcquired(EventLevel verbosity, Keywords keywords) this.mountProgressMessage = "Authenticating and validating"; using (NamedPipeServer pipeServer = this.StartNamedPipe()) { + this.mountFailureReporter.OnNamedPipeReady(); + this.tracer.RelatedEvent( EventLevel.Informational, $"{nameof(this.Mount)}_StartedNamedPipe", @@ -643,19 +650,45 @@ private void FailMountAndExit(string error, params object[] args) private void FailMountAndExit(ReturnCode returnCode, string error, params object[] args) { + // Format once, before taking ownership. RelatedError would otherwise format a + // second time without protection, and a throw there re-enters this method. + string failureMessage = MountFailureReporter.FormatFailure(error, args); + + if (!this.mountFailureReporter.TryBeginFailure((int)returnCode, failureMessage)) + { + // Unreachable in production: every path that returns false ends the process. + return; + } + this.currentState = MountState.MountFailed; - this.tracer.RelatedError(error, args); + this.tracer.RelatedError(failureMessage); if (this.showDebugWindow) { Console.WriteLine("\nPress Enter to Exit"); Console.ReadLine(); } - if (this.fileSystemCallbacks != null) + // The failure response must reach the client before the process exits, so + // report it before tearing anything down. + this.mountFailureReporter.WaitForClientToReadFailure(); + + try { - this.fileSystemCallbacks.Dispose(); - this.fileSystemCallbacks = null; + if (this.fileSystemCallbacks != null) + { + this.fileSystemCallbacks.Dispose(); + this.fileSystemCallbacks = null; + } + } + catch (Exception e) + { + // Disposal must not stop the process from exiting with the reported failure. + // The telemetry keyword keeps a disposal failure visible. + this.tracer.RelatedWarning( + null, + $"{nameof(this.FailMountAndExit)}: Exception while disposing file system callbacks: {e}", + Keywords.Telemetry); } Environment.Exit((int)returnCode); @@ -674,16 +707,37 @@ private T CreateOrReportAndExit(Func factory, string reportMessage) } } + /// + /// Whether the handler for can safely run in + /// . Requests that cannot are answered with + /// . + /// + private static bool IsRequestHandledInState(MountState state, string header) + { + switch (state) + { + case MountState.Mounting: + // Other handlers depend on the context, fileSystemCallbacks and similar + // state that is not initialized yet. + return header == NamedPipeMessages.GetStatus.Request; + + case MountState.MountFailed: + // A mount can fail at any step, so the same state is still missing here. + // GetStatus carries the failure to the client. Unmount is answered + // without tearing anything down. + return header == NamedPipeMessages.GetStatus.Request + || header == NamedPipeMessages.Unmount.Request; + + default: + return true; + } + } + private void HandleRequest(ITracer tracer, string request, NamedPipeServer.Connection connection) { NamedPipeMessages.Message message = NamedPipeMessages.Message.FromString(request); - // While mounting, only GetStatus requests are safe — other handlers depend - // on context, fileSystemCallbacks, etc. that aren't initialized yet. - // MountFailed is NOT guarded: HandleUnmountRequest needs to reach the - // "unmount even if mount failed" path so users aren't forced to kill the process. - if (message.Header != NamedPipeMessages.GetStatus.Request && - this.currentState == MountState.Mounting) + if (!IsRequestHandledInState(this.currentState, message.Header)) { connection.TrySendResponse(NamedPipeMessages.MountNotReadyResult); return; @@ -768,7 +822,8 @@ private void HandleRequest(ITracer tracer, string request, NamedPipeServer.Conne private void HandleGetHydrationStatusRequest(NamedPipeServer.Connection connection) { - EnlistmentHydrationSummary summary = this.fileSystemCallbacks?.GetCachedHydrationSummary(); + NamedPipeMessages.HydrationStatus.CacheInfo info = new NamedPipeMessages.HydrationStatus.CacheInfo(); + EnlistmentHydrationSummary summary = this.fileSystemCallbacks?.GetCachedHydrationSummary(out info); if (summary == null || !summary.IsValid) { this.tracer.RelatedInfo( @@ -776,7 +831,7 @@ private void HandleGetHydrationStatusRequest(NamedPipeServer.Connection connecti (summary == null ? "No cached hydration summary available yet" : "Cached hydration summary is invalid")); connection.TrySendResponse( - new NamedPipeMessages.Message(NamedPipeMessages.HydrationStatus.NotAvailableResult, null)); + new NamedPipeMessages.Message(NamedPipeMessages.HydrationStatus.NotAvailableResult, info.ToBody())); return; } @@ -791,7 +846,7 @@ private void HandleGetHydrationStatusRequest(NamedPipeServer.Connection connecti }; connection.TrySendResponse( - new NamedPipeMessages.Message(NamedPipeMessages.HydrationStatus.SuccessResult, response.ToBody())); + new NamedPipeMessages.Message(NamedPipeMessages.HydrationStatus.SuccessResult, response.ToBody(info))); } private void HandleDehydrateFolders(NamedPipeMessages.Message message, NamedPipeServer.Connection connection) @@ -1421,6 +1476,8 @@ private void HandlePrefetchBlobsRequest(NamedPipeMessages.Message message, Named private void HandleGetStatusRequest(NamedPipeServer.Connection connection) { + MountState state = this.currentState; + NamedPipeMessages.GetStatus.Response response = new NamedPipeMessages.GetStatus.Response(); response.EnlistmentRoot = this.enlistment.WorkingDirectoryRoot; response.LocalCacheRoot = !string.IsNullOrWhiteSpace(this.enlistment.LocalCacheRoot) ? this.enlistment.LocalCacheRoot : this.enlistment.GitObjectsRoot; @@ -1429,7 +1486,7 @@ private void HandleGetStatusRequest(NamedPipeServer.Connection connection) response.LockStatus = this.context?.Repository?.GVFSLock != null ? this.context.Repository.GVFSLock.GetStatus() : "Unavailable"; response.DiskLayoutVersion = $"{GVFSPlatform.Instance.DiskLayoutUpgrade.Version.CurrentMajorVersion}.{GVFSPlatform.Instance.DiskLayoutUpgrade.Version.CurrentMinorVersion}"; - switch (this.currentState) + switch (state) { case MountState.Mounting: response.MountStatus = NamedPipeMessages.GetStatus.Mounting; @@ -1451,6 +1508,7 @@ private void HandleGetStatusRequest(NamedPipeServer.Connection connection) case MountState.MountFailed: response.MountStatus = NamedPipeMessages.GetStatus.MountFailed; + response.MountError = this.mountFailureReporter.FailureMessage; break; default: @@ -1458,7 +1516,9 @@ private void HandleGetStatusRequest(NamedPipeServer.Connection connection) break; } - connection.TrySendResponse(response.ToJson()); + this.mountFailureReporter.TrySendStatus( + () => connection.TrySendResponse(response.ToJson()), + carriesMountFailure: state == MountState.MountFailed); } private void HandleUnmountRequest(NamedPipeServer.Connection connection) @@ -1469,10 +1529,11 @@ private void HandleUnmountRequest(NamedPipeServer.Connection connection) connection.TrySendResponse(NamedPipeMessages.Unmount.NotMounted); break; - // Even if the previous mount failed, attempt to unmount anyway. Otherwise the user has no - // recourse but to kill the process. + // A failed mount is already ending the process. Tearing down here would race + // that, and exit with Success instead of the reported failure. case MountState.MountFailed: - goto case MountState.Ready; + connection.TrySendResponse(NamedPipeMessages.Unmount.MountFailed); + break; case MountState.Ready: this.currentState = MountState.Unmounting; diff --git a/GVFS/GVFS.Platform.Windows/HResultExtensions.cs b/GVFS/GVFS.Platform.Windows/HResultExtensions.cs index 96df7ae84b..332297e038 100644 --- a/GVFS/GVFS.Platform.Windows/HResultExtensions.cs +++ b/GVFS/GVFS.Platform.Windows/HResultExtensions.cs @@ -6,6 +6,11 @@ public class HResultExtensions { public const int GenericProjFSError = -2147024579; // returned by ProjFS::DeleteFile() on Win server 2016 while deleting a partial file + // HRESULT_FROM_WIN32(ERROR_CANT_ATTACH_TO_DEV_VOLUME (478)): "The file system + // minifilter cannot attach to the developer volume." StartVirtualizing returns it + // when PrjFlt is not on the allowed-filter list of a Dev Drive. + public const int CantAttachToDevVolume = unchecked((int)0x800701DE); + private const int FacilityNtBit = 0x10000000; // FACILITY_NT_BIT private const int FacilityWin32 = 7; // FACILITY_WIN32 diff --git a/GVFS/GVFS.Platform.Windows/WindowsFileSystem.cs b/GVFS/GVFS.Platform.Windows/WindowsFileSystem.cs index 0fe4d781f6..71c4f5304e 100644 --- a/GVFS/GVFS.Platform.Windows/WindowsFileSystem.cs +++ b/GVFS/GVFS.Platform.Windows/WindowsFileSystem.cs @@ -107,6 +107,12 @@ public bool TryGetNormalizedPath(string path, out string normalizedPath, out str /// /// Hydrates a file by reading its first byte, triggering ProjFS placeholder hydration. /// + /// Path of the file to hydrate. + /// Scratch buffer used to read from the file. Must be at least 1 byte. + /// + /// The exception that caused hydration to fail, or null on success. Only ever assigned + /// when this method returns false. + /// /// /// This was originally implemented using direct P/Invoke to kernel32 CreateFile/ReadFile /// for minimal overhead. During the .NET 10 NativeAOT migration, the P/Invoke path caused @@ -119,7 +125,7 @@ public bool TryGetNormalizedPath(string path, out string normalizedPath, out str /// (~36-40K files/s) in the multi-threaded scenario that matches actual HydrateFilesStage /// usage (ProcessorCount * 2 threads). /// - public bool HydrateFile(string fileName, byte[] buffer) + public bool HydrateFile(string fileName, byte[] buffer, out Exception failure) { if (buffer.Length < 1) { @@ -141,14 +147,17 @@ public bool HydrateFile(string fileName, byte[] buffer) #pragma warning restore CA2022 } + failure = null; return true; } - catch (IOException) + catch (IOException e) { + failure = e; return false; } - catch (UnauthorizedAccessException) + catch (UnauthorizedAccessException e) { + failure = e; return false; } } diff --git a/GVFS/GVFS.Platform.Windows/WindowsFileSystemVirtualizer.cs b/GVFS/GVFS.Platform.Windows/WindowsFileSystemVirtualizer.cs index cc4ff66863..fde263b8a6 100644 --- a/GVFS/GVFS.Platform.Windows/WindowsFileSystemVirtualizer.cs +++ b/GVFS/GVFS.Platform.Windows/WindowsFileSystemVirtualizer.cs @@ -836,6 +836,12 @@ public override bool TryStart(out string error) { this.Context.Tracer.RelatedError($"{nameof(this.virtualizationInstance.StartVirtualizing)} failed: " + result.ToString("X") + "(" + result.ToString("G") + ")"); error = "Failed to start virtualization instance (" + result.ToString() + ")"; + + if ((int)result == HResultExtensions.CantAttachToDevVolume) + { + error += ". " + BuildProjFsAttachRemedy(this.Context.Enlistment.WorkingDirectoryRoot); + } + return false; } @@ -847,6 +853,47 @@ protected override void OnPossibleTombstoneFolderCreated(string relativePath) this.FileSystemCallbacks.OnPossibleTombstoneFolderCreated(relativePath); } + /// + /// Builds the user-facing remedy for ERROR_CANT_ATTACH_TO_DEV_VOLUME, which a Dev + /// Drive returns when PrjFlt is not on its allowed-filter list. + /// + /// + /// "fsutil devdrv setFiltersAllowed" REPLACES the allowed list, and without + /// /volume it replaces the list for every developer volume on the machine. So the + /// message must tell the user to read the current list first and include it, or + /// following this advice would drop filters the machine already requires (an + /// anti-malware filter, for example). + /// + internal static string BuildProjFsAttachRemedy(string enlistmentRoot) + { + string volume; + try + { + volume = Path.GetPathRoot(enlistmentRoot)?.TrimEnd(Path.DirectorySeparatorChar); + } + catch (ArgumentException) + { + volume = null; + } + + if (string.IsNullOrEmpty(volume)) + { + volume = ""; + } + + return "The ProjFS filter (PrjFlt) cannot attach to this volume. " + + $"If {volume} is a Dev Drive, PrjFlt must be on its allowed-filter list. " + + "From an elevated command prompt, list the filters that are already allowed:" + + Environment.NewLine + + $" fsutil devdrv query {volume}" + + Environment.NewLine + + "then set the list again with PrjFlt added to it:" + + Environment.NewLine + + $" fsutil devdrv setFiltersAllowed /f /volume {volume} \",PrjFlt\"" + + Environment.NewLine + + "setFiltersAllowed replaces the list, so keep the existing entries"; + } + private static void StreamCopyBlockTo(Stream input, Stream destination, long numBytes, byte[] buffer) { int read; diff --git a/GVFS/GVFS.UnitTests/CommandLine/GitIndexPathCallerTests.cs b/GVFS/GVFS.UnitTests/CommandLine/GitIndexPathCallerTests.cs new file mode 100644 index 0000000000..c481285e99 --- /dev/null +++ b/GVFS/GVFS.UnitTests/CommandLine/GitIndexPathCallerTests.cs @@ -0,0 +1,650 @@ +using GVFS.CommandLine; +using GVFS.Common; +using GVFS.Common.Git; +using GVFS.Common.Tracing; +using GVFS.RepairJobs; +using GVFS.Tests.Should; +using GVFS.UnitTests.Mock.Common; +using NUnit.Framework; +using System; +using System.IO; +using System.Text.Json; + +namespace GVFS.UnitTests.CommandLine +{ + [TestFixture] + public class GitIndexPathCallerTests + { + private string testRoot; + + [SetUp] + public void SetUp() + { + if (GVFSPlatform.Instance == null) + { + GVFSPlatform.Register(new MockPlatform()); + } + + this.testRoot = Path.Combine(Path.GetTempPath(), "GitIndexPathCallerTests_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(this.testRoot); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(this.testRoot)) + { + Directory.Delete(this.testRoot, recursive: true); + } + } + + [TestCase(false)] + [TestCase(true)] + public void GitIndexGeneratorUsesEnlistmentIndexPath(bool isWorktree) + { + GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree); + if (!isWorktree) + { + enlistment.GitIndexPath.ShouldEqual(Path.Combine(enlistment.DotGitRoot, "index")); + } + + Directory.CreateDirectory(Path.GetDirectoryName(enlistment.GitIndexPath)); + + File.WriteAllText(enlistment.GitIndexPath, "old index"); + if (isWorktree) + { + File.WriteAllText(Path.Combine(enlistment.DotGitRoot, GVFSConstants.DotGit.IndexName), "shared index"); + } + + GitIndexGenerator generator = new GitIndexGenerator(new MockTracer(), enlistment, shouldHashIndex: false); + generator.TemporaryIndexFilePath.ShouldEqual(enlistment.GitIndexPath + ".lock2"); + File.WriteAllText(generator.TemporaryIndexFilePath, "new index"); + + generator.ReplaceExistingIndex(); + + File.ReadAllText(enlistment.GitIndexPath).ShouldEqual("new index"); + if (isWorktree) + { + File.ReadAllText(Path.Combine(enlistment.DotGitRoot, GVFSConstants.DotGit.IndexName)).ShouldEqual("shared index"); + } + } + + [TestCase(false)] + [TestCase(true)] + public void GitIndexRepairJobUsesEnlistmentIndexPath(bool isWorktree) + { + GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree); + GitIndexRepairJob repairJob = new GitIndexRepairJob(new MockTracer(), TextWriter.Null, enlistment); + + repairJob.IndexPath.ShouldEqual(enlistment.GitIndexPath); + } + + [TestCase(false, false)] + [TestCase(false, true)] + [TestCase(true, false)] + public void DehydrateBacksUpEnlistmentIndexPath(bool isWorktree, bool move) + { + GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree); + string indexDirectory = Path.GetDirectoryName(enlistment.GitIndexPath); + Directory.CreateDirectory(indexDirectory); + File.WriteAllText(enlistment.GitIndexPath, "working tree index"); + + string sharedIndexPath = Path.Combine(enlistment.DotGitRoot, GVFSConstants.DotGit.IndexName); + if (isWorktree) + { + File.WriteAllText(sharedIndexPath, "shared index"); + } + + string backupGit = Path.Combine(this.testRoot, "backup-" + isWorktree + "-" + move, GVFSConstants.DotGit.Root); + Directory.CreateDirectory(backupGit); + + DehydrateVerb verb = new DehydrateVerb(); + bool result = verb.TryBackupGitIndex( + new MockTracer(), + enlistment, + backupGit, + move, + out string errorMessage); + + result.ShouldEqual(true, errorMessage); + File.ReadAllText(Path.Combine(backupGit, GVFSConstants.DotGit.IndexName)).ShouldEqual("working tree index"); + File.Exists(enlistment.GitIndexPath).ShouldEqual(!move); + if (isWorktree) + { + File.ReadAllText(sharedIndexPath).ShouldEqual("shared index"); + } + } + + [TestCase(false, false)] + [TestCase(false, true)] + [TestCase(true, false)] + public void DehydrateBacksUpOnlyCurrentWorkingTreeLocks(bool isWorktree, bool move) + { + GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree); + string indexDirectory = Path.GetDirectoryName(enlistment.GitIndexPath); + Directory.CreateDirectory(indexDirectory); + string indexLockPath = Path.Combine(indexDirectory, GVFSConstants.DotGit.IndexName + ".lock"); + File.WriteAllText(indexLockPath, "working tree lock"); + + string sharedLockPath = Path.Combine(enlistment.DotGitRoot, "shared.lock"); + if (isWorktree) + { + File.WriteAllText(sharedLockPath, "shared lock"); + } + + string backupGit = Path.Combine(this.testRoot, "lock-backup-" + isWorktree + "-" + move, GVFSConstants.DotGit.Root); + Directory.CreateDirectory(backupGit); + + DehydrateVerb verb = new DehydrateVerb(); + bool result = verb.TryBackupGitLocks( + new MockTracer(), + enlistment, + backupGit, + move, + out string errorMessage); + + result.ShouldEqual(true, errorMessage); + File.ReadAllText(Path.Combine(backupGit, GVFSConstants.DotGit.IndexName + ".lock")).ShouldEqual("working tree lock"); + File.Exists(indexLockPath).ShouldEqual(!move); + if (isWorktree) + { + File.ReadAllText(sharedLockPath).ShouldEqual("shared lock"); + File.Exists(Path.Combine(backupGit, "shared.lock")).ShouldBeFalse(); + } + } + + [TestCase] + public void FullDehydrateRejectsWorktree() + { + GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree: true); + StringWriter output = new StringWriter(); + DehydrateVerb verb = new DehydrateVerb + { + Full = true, + Output = output, + }; + + Assert.Throws( + () => verb.ValidateFullDehydrate(new MockTracer(), enlistment)); + + output.ToString().ShouldContain("Dehydrate --full is not supported for git worktrees."); + } + + [TestCase] + public void FullDehydrateRejectsPrimaryWithLinkedWorktree() + { + GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree: false); + string worktreeGitDir = Path.Combine(enlistment.DotGitRoot, "worktrees", "linked-worktree"); + Directory.CreateDirectory(worktreeGitDir); + File.WriteAllText( + Path.Combine(worktreeGitDir, "gitdir"), + Path.Combine(this.testRoot, "linked-worktree", GVFSConstants.DotGit.Root)); + Directory.CreateDirectory(Path.Combine(enlistment.DotGitRoot, "worktrees", "not-a-registration")); + + StringWriter output = new StringWriter(); + DehydrateVerb verb = new DehydrateVerb + { + Full = true, + Output = output, + }; + + Assert.Throws( + () => verb.ValidateFullDehydrate(new MockTracer(), enlistment)); + + output.ToString().ShouldContain("Dehydrate --full is not supported while linked worktrees exist."); + output.ToString().ShouldContain(Path.Combine(this.testRoot, "linked-worktree")); + output.ToString().ShouldContain("git worktree remove "); + output.ToString().ShouldContain("git worktree prune"); + output.ToString().ShouldContain("git worktree unlock "); + output.ToString().ShouldContain("back up or push"); + output.ToString().Contains("not-a-registration").ShouldBeFalse(); + } + + [TestCase] + public void FullDehydrateResolvesRelativeRegistration() + { + GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree: false); + string worktreeGitDir = Path.Combine(enlistment.DotGitRoot, "worktrees", "relative"); + Directory.CreateDirectory(worktreeGitDir); + string worktreePath = Path.Combine(this.testRoot, "relative-worktree"); + File.WriteAllText( + Path.Combine(worktreeGitDir, "gitdir"), + Path.GetRelativePath(worktreeGitDir, Path.Combine(worktreePath, ".git")).Replace('\\', '/') + "\n"); + using (StringWriter output = new StringWriter()) + using (MockTracer tracer = new MockTracer()) + { + DehydrateVerb verb = new DehydrateVerb { Full = true, Output = output }; + Assert.Throws( + () => verb.ValidateFullDehydrate(tracer, enlistment)); + output.ToString().ShouldContain("not supported while linked worktrees exist."); + output.ToString().ShouldContain(worktreePath); + output.ToString().Contains("Invalid worktree registration").ShouldBeFalse(); + } + } + + [TestCase("\n")] + [TestCase(" ")] + [TestCase(" \n")] + [TestCase("not-an-absolute-path")] + [TestCase(@"C:\worktrees\bad\HEAD")] + public void FullDehydrateRejectsMalformedRegistration(string registration) + { + GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree: false); + string worktreeGitDir = Path.Combine(enlistment.DotGitRoot, "worktrees", "bad"); + Directory.CreateDirectory(worktreeGitDir); + File.WriteAllText(Path.Combine(worktreeGitDir, "gitdir"), registration); + + using (StringWriter output = new StringWriter()) + using (MockTracer tracer = new MockTracer()) + { + DehydrateVerb verb = new DehydrateVerb { Full = true, Output = output }; + Assert.Throws( + () => verb.ValidateFullDehydrate(tracer, enlistment)); + output.ToString().ShouldContain("Full dehydrate is blocked."); + output.ToString().ShouldContain(Path.Combine(worktreeGitDir, "gitdir")); + output.ToString().ShouldContain("git worktree list"); + output.ToString().ShouldContain("git worktree prune"); + tracer.RelatedErrorEvents.Count.ShouldEqual(1); + Directory.Exists(enlistment.WorkingDirectoryBackingRoot).ShouldBeTrue(); + } + } + + [TestCase] + public void FullDehydrateRejectsUnreadableRegistration() + { + GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree: false); + string worktreeGitDir = Path.Combine(enlistment.DotGitRoot, "worktrees", "locked"); + Directory.CreateDirectory(worktreeGitDir); + string gitdirFile = Path.Combine(worktreeGitDir, "gitdir"); + File.WriteAllText(gitdirFile, Path.Combine(this.testRoot, "linked-worktree", GVFSConstants.DotGit.Root)); + + using (FileStream locked = new FileStream(gitdirFile, FileMode.Open, FileAccess.Read, FileShare.None)) + using (StringWriter output = new StringWriter()) + using (MockTracer tracer = new MockTracer()) + { + DehydrateVerb verb = new DehydrateVerb { Full = true, Output = output }; + Assert.Throws(() => verb.ValidateFullDehydrate(tracer, enlistment)); + output.ToString().ShouldContain("Full dehydrate is blocked."); + output.ToString().ShouldContain(gitdirFile); + tracer.RelatedErrorEvents.Count.ShouldEqual(1); + tracer.RelatedWarningEvents.Count.ShouldEqual(0); + using (JsonDocument metadata = JsonDocument.Parse(tracer.RelatedErrorEvents[0])) + { + metadata.RootElement.GetProperty("Exception").GetString().ShouldContain("System.IO.IOException"); + metadata.RootElement.GetProperty("Exception").GetString().ShouldContain("TryGetKnownWorktreePaths"); + } + + output.ToString().Contains("System.IO.IOException").ShouldBeFalse(); + } + } + + [TestCase] + public void FullDehydrateRejectsUnreadableRegistrationsDirectory() + { + GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree: false); + File.WriteAllText(Path.Combine(enlistment.DotGitRoot, "worktrees"), "not a directory"); + using (StringWriter output = new StringWriter()) + using (MockTracer tracer = new MockTracer()) + { + DehydrateVerb verb = new DehydrateVerb { Full = true, Output = output }; + Assert.Throws( + () => verb.ValidateFullDehydrate(tracer, enlistment)); + output.ToString().ShouldContain("Full dehydrate is blocked."); + } + } + + [TestCase] + public void FullDehydrateAllowsPrimaryWithoutLinkedWorktrees() + { + GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree: false); + DehydrateVerb verb = new DehydrateVerb + { + Full = true, + Output = TextWriter.Null, + }; + + Assert.DoesNotThrow( + () => verb.ValidateFullDehydrate(new MockTracer(), enlistment)); + } + + [TestCase] + public void FullDehydrateAllowsEmptyRegistrationsDirectory() + { + GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree: false); + Directory.CreateDirectory(Path.Combine(enlistment.DotGitRoot, "worktrees")); + using (MockTracer tracer = new MockTracer()) + { + DehydrateVerb verb = new DehydrateVerb { Full = true, Output = TextWriter.Null }; + Assert.DoesNotThrow(() => verb.ValidateFullDehydrate(tracer, enlistment)); + tracer.RelatedErrorEvents.Count.ShouldEqual(0); + } + } + + [TestCase] + public void FullDehydrateIgnoresDirectoryWithoutGitdir() + { + GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree: false); + Directory.CreateDirectory(Path.Combine(enlistment.DotGitRoot, "worktrees", "not-a-registration")); + using (MockTracer tracer = new MockTracer()) + { + DehydrateVerb verb = new DehydrateVerb { Full = true, Output = TextWriter.Null }; + Assert.DoesNotThrow(() => verb.ValidateFullDehydrate(tracer, enlistment)); + tracer.RelatedErrorEvents.Count.ShouldEqual(0); + } + } + + [TestCase] + public void FullDehydrateIgnoresEmptyGitdir() + { + GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree: false); + string worktreeGitDir = Path.Combine(enlistment.DotGitRoot, "worktrees", "empty"); + Directory.CreateDirectory(worktreeGitDir); + File.WriteAllBytes(Path.Combine(worktreeGitDir, "gitdir"), Array.Empty()); + using (MockTracer tracer = new MockTracer()) + { + DehydrateVerb verb = new DehydrateVerb { Full = true, Output = TextWriter.Null }; + Assert.DoesNotThrow(() => verb.ValidateFullDehydrate(tracer, enlistment)); + tracer.RelatedErrorEvents.Count.ShouldEqual(0); + tracer.RelatedWarningEvents.Count.ShouldEqual(0); + } + } + + [TestCase] + public void FullDehydrateRejectsUnverifiableStateWhenAnotherWorktreeIsReadable() + { + GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree: false); + string validDir = Path.Combine(enlistment.DotGitRoot, "worktrees", "valid"); + string unreadableDir = Path.Combine(enlistment.DotGitRoot, "worktrees", "unreadable"); + Directory.CreateDirectory(validDir); + Directory.CreateDirectory(unreadableDir); + string worktreePath = Path.Combine(this.testRoot, "visible-worktree"); + File.WriteAllText(Path.Combine(validDir, "gitdir"), Path.Combine(worktreePath, ".git") + "\n"); + string unreadableFile = Path.Combine(unreadableDir, "gitdir"); + File.WriteAllText(unreadableFile, Path.Combine(this.testRoot, "hidden-worktree", ".git")); + using (FileStream locked = new FileStream(unreadableFile, FileMode.Open, FileAccess.Read, FileShare.None)) + using (StringWriter output = new StringWriter()) + using (MockTracer tracer = new MockTracer()) + { + DehydrateVerb verb = new DehydrateVerb { Full = true, Output = output }; + Assert.Throws(() => verb.ValidateFullDehydrate(tracer, enlistment)); + output.ToString().ShouldContain("Full dehydrate is blocked."); + output.ToString().ShouldContain(unreadableFile); + tracer.RelatedWarningEvents.Count.ShouldEqual(0); + tracer.RelatedErrorEvents.Count.ShouldEqual(1); + } + } + + [TestCase(false, "HEAD")] + [TestCase(true, "HEAD")] + [TestCase(false, "commondir")] + [TestCase(true, "commondir")] + [TestCase(false, ".gvfs")] + [TestCase(true, ".gvfs")] + public void FullDehydrateRejectsMissingOrEmptyPointerWithRemainingMetadata(bool emptyPointer, string metadataName) + { + GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree: false); + string entry = Path.Combine(enlistment.DotGitRoot, "worktrees", "incomplete"); + Directory.CreateDirectory(entry); + if (emptyPointer) + { + File.WriteAllBytes(Path.Combine(entry, "gitdir"), Array.Empty()); + } + + string metadataPath = Path.Combine(entry, metadataName); + if (metadataName == ".gvfs") + { + Directory.CreateDirectory(metadataPath); + } + else + { + File.WriteAllText(metadataPath, "remaining metadata"); + } + + using (StringWriter output = new StringWriter()) + using (MockTracer tracer = new MockTracer()) + { + DehydrateVerb verb = new DehydrateVerb { Full = true, Output = output }; + Assert.Throws(() => verb.ValidateFullDehydrate(tracer, enlistment)); + output.ToString().ShouldContain("Full dehydrate is blocked."); + output.ToString().ShouldContain(metadataPath); + tracer.RelatedErrorEvents.Count.ShouldEqual(1); + } + } + + [TestCase] + public void FullDehydrateRejectsMissingGitDirectory() + { + GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree: false); + Directory.Delete(enlistment.DotGitRoot); + using (StringWriter output = new StringWriter()) + using (MockTracer tracer = new MockTracer()) + { + DehydrateVerb verb = new DehydrateVerb { Full = true, Output = output }; + Assert.Throws( + () => verb.ValidateFullDehydrate(tracer, enlistment)); + output.ToString().ShouldContain("Full dehydrate is blocked."); + tracer.RelatedErrorEvents.Count.ShouldEqual(1); + } + } + + [TestCase] + public void FullDehydrateRejectsGitDirectoryReplacedByFile() + { + GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree: false); + Directory.Delete(enlistment.DotGitRoot); + File.WriteAllText(enlistment.DotGitRoot, "not a directory"); + using (StringWriter output = new StringWriter()) + using (MockTracer tracer = new MockTracer()) + { + DehydrateVerb verb = new DehydrateVerb { Full = true, Output = output }; + Assert.Throws( + () => verb.ValidateFullDehydrate(tracer, enlistment)); + output.ToString().ShouldContain("Full dehydrate is blocked."); + tracer.RelatedErrorEvents.Count.ShouldEqual(1); + } + } + + [TestCase(false, false)] + [TestCase(false, true)] + [TestCase(true, false)] + [TestCase(true, true)] + public void BackupRootIsUniqueAndWorktreeLocal(bool isWorktree, bool trailingSeparator) + { + GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree, trailingSeparator: trailingSeparator); + DateTime timestamp = new DateTime(2026, 9, 23, 13, 0, 0, 123); + + string backupRoot = DehydrateVerb.GetBackupRoot(enlistment, timestamp); + + string expectedParent = isWorktree + ? this.testRoot + : enlistment.PrimaryEnlistmentRoot; + string expectedSubfolder = isWorktree + ? Path.Combine("dehydrate_backup", enlistment.Worktree.Name) + : "dehydrate_backup"; + backupRoot.ShouldEqual( + Path.Combine( + expectedParent, + expectedSubfolder, + "20260923_130000_1230000")); + DehydrateVerb.GetBackupRoot(enlistment, timestamp.AddTicks(1)).ShouldNotEqual(backupRoot); + } + + [TestCase] + public void MissingIndexReturnsBackupError() + { + GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree: true); + string backupGit = Path.Combine(this.testRoot, "missing-index-backup"); + Directory.CreateDirectory(backupGit); + + DehydrateVerb verb = new DehydrateVerb(); + bool result = verb.TryBackupGitIndex( + new MockTracer(), + enlistment, + backupGit, + move: false, + out string errorMessage); + + result.ShouldBeFalse(); + errorMessage.ShouldContain(enlistment.GitIndexPath); + errorMessage.ShouldContain(Path.Combine(backupGit, "index")); + } + + [TestCase] + public void MissingLockDirectoryReturnsBackupError() + { + GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree: true); + Directory.Delete(Path.GetDirectoryName(enlistment.GitIndexPath)); + string backupGit = Path.Combine(this.testRoot, "missing-lock-backup"); + Directory.CreateDirectory(backupGit); + + DehydrateVerb verb = new DehydrateVerb(); + bool result = verb.TryBackupGitLocks( + new MockTracer(), + enlistment, + backupGit, + move: false, + out string errorMessage); + + result.ShouldBeFalse(); + errorMessage.ShouldNotBeNull(); + } + + [TestCase(false)] + [TestCase(true)] + public void LockBackupReturnsPreciseFileError(bool move) + { + GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree: false); + string lockPath = enlistment.GitIndexPath + ".lock"; + File.WriteAllText(lockPath, "lock content"); + string backupGit = Path.Combine(this.testRoot, "occupied-backup"); + Directory.CreateDirectory(backupGit); + string backupLockPath = Path.Combine(backupGit, Path.GetFileName(lockPath)); + File.WriteAllText(backupLockPath, "previous backup"); + + using (MockTracer tracer = new MockTracer()) + { + DehydrateVerb verb = new DehydrateVerb(); + verb.TryBackupGitLocks(tracer, enlistment, backupGit, move, out string errorMessage).ShouldBeFalse(); + errorMessage.ShouldContain(lockPath); + errorMessage.ShouldContain(backupLockPath); + using (JsonDocument errorEvent = JsonDocument.Parse(tracer.RelatedErrorEvents[0])) + { + errorMessage.ShouldContain(errorEvent.RootElement.GetProperty("Error").GetString()); + errorEvent.RootElement.GetProperty("Exception").GetString().ShouldContain(nameof(IOException)); + } + + File.ReadAllText(lockPath).ShouldEqual("lock content"); + File.ReadAllText(backupLockPath).ShouldEqual("previous backup"); + } + } + + [TestCase(false)] + [TestCase(true)] + public void IndexBackupReturnsPreciseFileError(bool move) + { + GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree: true); + File.WriteAllText(enlistment.GitIndexPath, "worktree index"); + string backupGit = Path.Combine(this.testRoot, "occupied-index-backup"); + Directory.CreateDirectory(backupGit); + string backupIndex = Path.Combine(backupGit, "index"); + File.WriteAllText(backupIndex, "previous backup"); + + using (MockTracer tracer = new MockTracer()) + { + DehydrateVerb verb = new DehydrateVerb(); + verb.TryBackupGitIndex(tracer, enlistment, backupGit, move, out string errorMessage).ShouldBeFalse(); + errorMessage.ShouldContain(enlistment.GitIndexPath); + errorMessage.ShouldContain(backupIndex); + using (JsonDocument metadata = JsonDocument.Parse(tracer.RelatedErrorEvents[0])) + { + errorMessage.ShouldContain(metadata.RootElement.GetProperty("Error").GetString()); + } + + File.ReadAllText(enlistment.GitIndexPath).ShouldEqual("worktree index"); + File.ReadAllText(backupIndex).ShouldEqual("previous backup"); + } + } + + [TestCase(false)] + [TestCase(true)] + public void IndexGenerationTracesResolvedDestination(bool isWorktree) + { + GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree, gitBinPath: "git.exe"); + Directory.CreateDirectory(Path.GetDirectoryName(enlistment.GitIndexPath)); + using (JsonTracer tracer = new JsonTracer("Microsoft-GVFS-Test", "IndexGeneration", disableTelemetry: true)) + using (IndexGenerationListener listener = new IndexGenerationListener()) + { + tracer.AddEventListener(listener); + GitIndexGenerator generator = new GitIndexGenerator(tracer, enlistment, shouldHashIndex: false); + // No repository exists here. Git returns a failure without contacting a remote. + generator.CreateFromRef("HEAD", indexVersion: 4, isFinal: false); + + generator.HasFailures.ShouldBeTrue(); + listener.ActivityPayload.ShouldNotBeNull(); + using (JsonDocument metadata = JsonDocument.Parse(listener.ActivityPayload)) + { + metadata.RootElement.GetProperty("IndexPath").GetString().ShouldEqual(enlistment.GitIndexPath); + metadata.RootElement.GetProperty("IsWorktree").GetBoolean().ShouldEqual(isWorktree); + } + } + } + + private GVFSEnlistment CreateEnlistment(bool isWorktree, string gitBinPath = null, bool trailingSeparator = false) + { + gitBinPath = gitBinPath ?? Path.Combine(this.testRoot, "git.exe"); + string primaryRoot = Path.Combine(this.testRoot, isWorktree ? "worktree-primary" : "standard"); + string primarySrc = Path.Combine(primaryRoot, GVFSConstants.WorkingDirectoryRootName); + string sharedGitDir = Path.Combine(primarySrc, GVFSConstants.DotGit.Root); + Directory.CreateDirectory(sharedGitDir); + + if (!isWorktree) + { + return new GVFSEnlistment(primaryRoot, "mock://repo", gitBinPath, authentication: null); + } + + string worktreePath = Path.Combine(this.testRoot, "linked-worktree"); + if (trailingSeparator) + { + worktreePath += Path.DirectorySeparatorChar; + } + + string worktreeGitDir = Path.Combine(sharedGitDir, "worktrees", "linked-worktree"); + Directory.CreateDirectory(worktreePath); + Directory.CreateDirectory(worktreeGitDir); + + GVFSEnlistment.WorktreeInfo worktreeInfo = new GVFSEnlistment.WorktreeInfo + { + Name = "linked-worktree", + WorktreePath = worktreePath, + WorktreeGitDir = worktreeGitDir, + SharedGitDir = sharedGitDir, + PipeSuffix = "_WT_LINKED-WORKTREE", + }; + + return GVFSEnlistment.CreateForWorktree( + primaryRoot, + gitBinPath, + authentication: null, + worktreeInfo, + repoUrl: "mock://repo"); + } + + private class IndexGenerationListener : EventListener + { + public IndexGenerationListener() + : base(EventLevel.Informational, Keywords.Any, null) + { + } + + public string ActivityPayload { get; private set; } + + protected override void RecordMessageInternal(TraceEventMessage message) + { + if (message.EventName == "CreateFromHeadTree" && message.Opcode == EventOpcode.Start) + { + this.ActivityPayload = message.Payload; + } + } + } + } +} diff --git a/GVFS/GVFS.UnitTests/Common/EnlistmentHydrationSummaryTests.cs b/GVFS/GVFS.UnitTests/Common/EnlistmentHydrationSummaryTests.cs index 3920ab1354..99ead1c816 100644 --- a/GVFS/GVFS.UnitTests/Common/EnlistmentHydrationSummaryTests.cs +++ b/GVFS/GVFS.UnitTests/Common/EnlistmentHydrationSummaryTests.cs @@ -10,6 +10,10 @@ using System.IO; using System.Text; using System.Threading; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using GVFS.UnitTests.Category; using static GVFS.Virtualization.Projection.GitIndexProjection.GitIndexParser; namespace GVFS.UnitTests.Common @@ -321,5 +325,313 @@ public void CreateSummary_CancelledToken_ReturnsInvalidSummary() Assert.IsFalse(result.IsValid); Assert.IsNull(result.Error); } + + [TestCase(20, "Success", "None")] + [TestCase(1, "InvalidCounts", "HydratedFoldersExceedTotal")] + public void CalculationEmitsCountsAndFinalValidity(int folderCount, string outcome, string validationFailures) + { + this.AddIndex(100); + using (JsonTracer tracer = new JsonTracer("test", "test", disableTelemetry: true)) + { + RecordingListener listener = new RecordingListener(Keywords.Telemetry); + tracer.AddEventListener(listener); + EnlistmentHydrationSummary summary = EnlistmentHydrationSummary.CreateSummary( + this.context.Enlistment, this.fileSystem, tracer, () => folderCount, new TestPathData()); + + TraceEventMessage message = listener.Events.Single(e => e.EventName == "HydrationSummaryDuration"); + Assert.AreEqual(outcome == "Success" ? EventLevel.Informational : EventLevel.Warning, message.Level); + using (JsonDocument json = JsonDocument.Parse(message.Payload)) + { + JsonElement root = json.RootElement; + Assert.AreEqual(outcome, root.GetProperty("Outcome").GetString()); + Assert.AreEqual(validationFailures, root.GetProperty("ValidationFailures").GetString()); + Assert.AreEqual(100, root.GetProperty("TotalFileCount").GetInt32()); + Assert.AreEqual(2, root.GetProperty("PlaceholderFileCount").GetInt32()); + Assert.AreEqual(3, root.GetProperty("ModifiedFileCount").GetInt32()); + Assert.AreEqual(folderCount, root.GetProperty("TotalFolderCount").GetInt32()); + Assert.AreEqual(summary.IsValid, root.GetProperty("IsValid").GetBoolean()); + Assert.IsTrue(root.GetProperty("CountsComplete").GetBoolean()); + Assert.AreNotEqual(Guid.Empty, Guid.Parse(root.GetProperty("CalculationId").GetString())); + } + } + } + + [TestCase(HydrationSummaryPhase.Placeholders)] + [TestCase(HydrationSummaryPhase.ModifiedPaths)] + [TestCase(HydrationSummaryPhase.FolderCount)] + [Category(CategoryConstants.ExceptionExpected)] + public void FailureRetainsEarlierAndPartialCounts(HydrationSummaryPhase phase) + { + this.AddIndex(100); + using (JsonTracer tracer = new JsonTracer("test", "test", disableTelemetry: true)) + { + RecordingListener telemetry = new RecordingListener(Keywords.Telemetry); + RecordingListener local = new RecordingListener(Keywords.Any); + tracer.AddEventListener(telemetry); + tracer.AddEventListener(local); + TestPathData paths = new TestPathData { FailurePhase = phase }; + EnlistmentHydrationSummary summary = EnlistmentHydrationSummary.CreateSummary( + this.context.Enlistment, this.fileSystem, tracer, + () => throw new IOException("mock:\\private\\folder {secret}"), paths); + + Assert.IsFalse(summary.IsValid); + Assert.AreEqual(HydrationSummaryOutcome.Failed, summary.Diagnostics.Outcome); + Assert.AreEqual(100, summary.Diagnostics.TotalFileCount); + Assert.AreEqual(phase, summary.Diagnostics.Phase); + Assert.AreEqual(HydrationDataState.Complete, summary.Diagnostics.IndexState); + Assert.AreEqual(phase == HydrationSummaryPhase.Placeholders ? HydrationDataState.Partial : HydrationDataState.Complete, summary.Diagnostics.PlaceholderState); + Assert.AreEqual(phase == HydrationSummaryPhase.Placeholders ? HydrationDataState.NotStarted + : phase == HydrationSummaryPhase.ModifiedPaths ? HydrationDataState.Partial : HydrationDataState.Complete, summary.Diagnostics.ModifiedPathsState); + Assert.AreEqual(phase == HydrationSummaryPhase.FolderCount ? HydrationDataState.Failed : HydrationDataState.NotStarted, summary.Diagnostics.FolderCountState); + Assert.IsFalse(summary.Diagnostics.CountsComplete); + Assert.AreEqual(2, summary.Diagnostics.PlaceholderFileCount); + Assert.IsNull(summary.Diagnostics.TotalFolderCount); + if (phase != HydrationSummaryPhase.Placeholders) + { + Assert.AreEqual(3, summary.Diagnostics.ModifiedFileCount); + } + + TraceEventMessage completion = telemetry.Events.Single(e => e.EventName == "HydrationSummaryDuration"); + Assert.AreEqual(EventLevel.Warning, completion.Level); + string payload = completion.Payload; + Assert.That(payload, Does.Not.Contain("secret")); + Assert.That(payload, Does.Contain("System.IO.IOException")); + Assert.That(local.Events.Single(e => e.EventName == "Warning").Payload, Does.Contain("secret")); + Assert.AreEqual(1, telemetry.Events.Count); + } + } + + [TestCase] + public void FailedSourceDoesNotReportZeroAsAvailable() + { + this.AddIndex(100); + TestPathData paths = new TestPathData { ModifiedState = HydrationDataState.Failed, EmptyModifiedPaths = true }; + EnlistmentHydrationSummary summary = EnlistmentHydrationSummary.CreateSummary( + this.context.Enlistment, this.fileSystem, this.tracer, () => 20, paths); + Assert.IsNull(summary.Diagnostics.ModifiedFileCount); + Assert.IsNull(summary.Diagnostics.ModifiedFolderCount); + Assert.AreEqual(HydrationDataState.Failed, summary.Diagnostics.ModifiedPathsState); + Assert.IsFalse(summary.Diagnostics.CountsComplete); + Assert.AreEqual(HydrationSummaryOutcome.IncompleteData, summary.Diagnostics.Outcome); + } + + [TestCase] + public void EmptySourceReportsCollectedZero() + { + this.AddIndex(100); + TestPathData paths = new TestPathData { EmptyModifiedPaths = true }; + EnlistmentHydrationSummary summary = EnlistmentHydrationSummary.CreateSummary( + this.context.Enlistment, this.fileSystem, this.tracer, () => 20, paths); + Assert.AreEqual(0, summary.Diagnostics.ModifiedFileCount); + Assert.AreEqual(0, summary.Diagnostics.ModifiedFolderCount); + Assert.IsTrue(summary.Diagnostics.CountsComplete); + Assert.AreEqual(HydrationSummaryOutcome.Success, summary.Diagnostics.Outcome); + } + + [TestCase] + public void EarlyExitLeavesFolderCountAndTimingUnavailable() + { + this.AddIndex(0); + EnlistmentHydrationSummary summary = EnlistmentHydrationSummary.CreateSummary( + this.context.Enlistment, this.fileSystem, this.tracer, () => throw new AssertionException("Folder provider must not run."), new TestPathData()); + Assert.IsTrue(summary.Diagnostics.EarlyExit); + Assert.IsFalse(summary.IsValid); + Assert.AreEqual(HydrationSummaryOutcome.InvalidCounts, summary.Diagnostics.Outcome); + Assert.IsNull(summary.Diagnostics.TotalFolderCount); + Assert.IsNull(summary.Diagnostics.TreeCountMs); + Assert.AreEqual(HydrationDataState.NotStarted, summary.Diagnostics.FolderCountState); + Assert.IsTrue(summary.Diagnostics.GetValidationFailures().HasFlag(HydrationValidationFailure.HydratedFilesExceedTotal)); + } + + [TestCase] + [Category(CategoryConstants.ExceptionExpected)] + public void CancellationRetainsSuccessfulObservations() + { + this.AddIndex(100); + using (CancellationTokenSource cts = new CancellationTokenSource()) + { + TestPathData paths = new TestPathData { AfterPlaceholders = cts.Cancel }; + EnlistmentHydrationSummary summary = EnlistmentHydrationSummary.CreateSummary( + this.context.Enlistment, this.fileSystem, this.tracer, () => 20, paths, cts.Token); + Assert.AreEqual(HydrationSummaryOutcome.Cancelled, summary.Diagnostics.Outcome); + Assert.IsTrue(summary.Diagnostics.CancellationRequested); + Assert.AreEqual(HydrationSummaryPhase.Placeholders, summary.Diagnostics.Phase); + Assert.AreEqual(HydrationDataState.Complete, summary.Diagnostics.PlaceholderState); + Assert.AreEqual(100, summary.Diagnostics.TotalFileCount); + Assert.AreEqual(2, summary.Diagnostics.PlaceholderFileCount); + Assert.IsNull(summary.Diagnostics.ModifiedFileCount); + Assert.AreEqual(HydrationDataState.NotStarted, summary.Diagnostics.ModifiedPathsState); + Assert.IsNull(summary.Error); + } + } + + [TestCase] + [Category(CategoryConstants.ExceptionExpected)] + public void UnrelatedCancellationDoesNotChangeCircuitBreakerPolicy() + { + this.AddIndex(100); + EnlistmentHydrationSummary summary = EnlistmentHydrationSummary.CreateSummary( + this.context.Enlistment, this.fileSystem, this.tracer, () => throw new OperationCanceledException(), new TestPathData()); + Assert.AreEqual(HydrationSummaryOutcome.Cancelled, summary.Diagnostics.Outcome); + Assert.IsFalse(summary.Diagnostics.CancellationRequested); + Assert.AreEqual(HydrationDataState.Cancelled, summary.Diagnostics.FolderCountState); + Assert.IsNull(summary.Error); + } + + [TestCase] + [Category(CategoryConstants.ExceptionExpected)] + public void MissingIndexRecordsFailedFirstPhaseAndUnavailableLaterCounts() + { + EnlistmentHydrationSummary summary = EnlistmentHydrationSummary.CreateSummary( + this.context.Enlistment, new MissingIndexFileSystem(), this.tracer, () => 20, new TestPathData()); + Assert.AreEqual(HydrationSummaryOutcome.Failed, summary.Diagnostics.Outcome); + Assert.AreEqual(HydrationSummaryPhase.IndexRead, summary.Diagnostics.Phase); + Assert.AreEqual(HydrationDataState.Failed, summary.Diagnostics.IndexState); + Assert.AreEqual(HydrationDataState.NotStarted, summary.Diagnostics.PlaceholderState); + Assert.IsNull(summary.Diagnostics.TotalFileCount); + Assert.IsNull(summary.Diagnostics.PlaceholderFileCount); + Assert.IsNull(summary.Diagnostics.ModifiedFileCount); + Assert.IsNotNull(summary.Diagnostics.IndexReadMs); + Assert.IsNull(summary.Diagnostics.PlaceholderLoadMs); + } + + [TestCase(true, "ShortIndex")] + [TestCase(false, "NegativeEntryCount")] + public void UnavailableIndexTotalIsIncompleteRatherThanArithmeticInvalidity(bool shortIndex, string reason) + { + if (shortIndex) + { + this.enlistmentDirectory.CreateFile(this.context.Enlistment.GitIndexPath, "short"); + } + else + { + this.AddIndex(-1); + } + + EnlistmentHydrationSummary summary = EnlistmentHydrationSummary.CreateSummary( + this.context.Enlistment, this.fileSystem, this.tracer, () => 20, new TestPathData()); + Assert.AreEqual(HydrationSummaryOutcome.IncompleteData, summary.Diagnostics.Outcome); + Assert.AreEqual(reason, summary.Diagnostics.IndexReadReason); + Assert.AreEqual(HydrationDataState.Failed, summary.Diagnostics.IndexState); + Assert.IsNull(summary.Diagnostics.TotalFileCount); + Assert.IsNull(summary.Diagnostics.TotalFolderCount); + Assert.IsFalse(summary.IsValid); + Assert.AreEqual(HydrationValidationFailure.MissingCounts, summary.Diagnostics.GetValidationFailures()); + } + + [TestCase(true)] + [TestCase(false)] + public void IndexDiagnosticsRecordHeaderWithoutLeakingItsBytes(bool validSignature) + { + this.AddIndex(100, validSignature); + HydrationSummaryDiagnostics diagnostics = new HydrationSummaryDiagnostics(); + Assert.AreEqual(100, EnlistmentHydrationSummary.GetIndexFileCount(this.context.Enlistment, this.fileSystem, diagnostics)); + Assert.AreEqual(12, diagnostics.IndexLength); + Assert.AreEqual(4, diagnostics.IndexVersion); + Assert.AreEqual(validSignature, diagnostics.IndexSignatureValid); + Assert.AreEqual("Success", diagnostics.IndexReadReason); + } + + [TestCase] + [Category(CategoryConstants.ExceptionExpected)] + public void InPhaseCancellationPreservesPartialModifiedCountsAndEmitsInformationalResult() + { + this.AddIndex(100); + using (CancellationTokenSource cts = new CancellationTokenSource()) + using (JsonTracer tracer = new JsonTracer("test", "test", disableTelemetry: true)) + { + RecordingListener listener = new RecordingListener(Keywords.Telemetry); + tracer.AddEventListener(listener); + TestPathData paths = new TestPathData + { + DuringModifiedPaths = () => { cts.Cancel(); cts.Token.ThrowIfCancellationRequested(); }, + }; + EnlistmentHydrationSummary summary = EnlistmentHydrationSummary.CreateSummary( + this.context.Enlistment, this.fileSystem, tracer, () => 20, paths, cts.Token); + Assert.AreEqual(HydrationSummaryOutcome.Cancelled, summary.Diagnostics.Outcome); + Assert.AreEqual(HydrationDataState.Cancelled, summary.Diagnostics.ModifiedPathsState); + Assert.AreEqual(3, summary.Diagnostics.ModifiedFileCount); + Assert.AreEqual(HydrationSummaryPhase.ModifiedPaths, summary.Diagnostics.Phase); + TraceEventMessage message = listener.Events.Single(e => e.EventName == "HydrationSummaryDuration"); + Assert.AreEqual(EventLevel.Informational, message.Level); + using (JsonDocument json = JsonDocument.Parse(message.Payload)) + { + Assert.AreEqual(JsonValueKind.Null, json.RootElement.GetProperty("TreeCountMs").ValueKind); + } + } + } + + private sealed class MissingIndexFileSystem : GVFS.Common.FileSystem.PhysicalFileSystem + { + public override Stream OpenFileStream(string path, FileMode fileMode, FileAccess fileAccess, FileShare shareMode, FileOptions options, bool flushesToDisk) + { + throw new FileNotFoundException("mock:\\missing\\index"); + } + } + + private void AddIndex(int count, bool validSignature = true) + { + byte[] bytes = new byte[12]; + Encoding.ASCII.GetBytes("DIRC").CopyTo(bytes, 0); + if (!validSignature) + { + bytes[0] = 0xFF; + } + System.Buffers.Binary.BinaryPrimitives.WriteInt32BigEndian(bytes.AsSpan(4, 4), 4); + System.Buffers.Binary.BinaryPrimitives.WriteInt32BigEndian(bytes.AsSpan(8, 4), count); + MockFile file = new MockFile(this.context.Enlistment.GitIndexPath, bytes); + this.enlistmentDirectory.FindDirectory(Path.Combine(this.gitParentPath, ".git")).Files.Add(file.FullName, file); + } + + private sealed class TestPathData : EnlistmentPathData + { + public HydrationSummaryPhase FailurePhase { get; set; } + public HydrationDataState ModifiedState { get; set; } = HydrationDataState.Complete; + public bool EmptyModifiedPaths { get; set; } + public Action AfterPlaceholders { get; set; } + public Action DuringModifiedPaths { get; set; } + + public override void LoadPlaceholdersFromDatabase(GVFSEnlistment enlistment, HydrationSummaryDiagnostics diagnostics = null) + { + this.PlaceholderFilePaths.AddRange(new[] { "a", "b" }); + this.PlaceholderFolderPaths.Add("folder"); + if (this.FailurePhase == HydrationSummaryPhase.Placeholders) + { + throw new IOException("mock:\\private\\placeholder {secret}"); + } + + this.AfterPlaceholders?.Invoke(); + } + + public override void LoadModifiedPaths(GVFSEnlistment enlistment, ITracer tracer, HydrationSummaryDiagnostics diagnostics = null) + { + if (!this.EmptyModifiedPaths) + { + this.ModifiedFilePaths.AddRange(new[] { "c", "d", "e" }); + this.ModifiedFolderPaths.Add("other"); + } + + diagnostics.ModifiedPathsState = this.ModifiedState; + this.DuringModifiedPaths?.Invoke(); + if (this.FailurePhase == HydrationSummaryPhase.ModifiedPaths) + { + throw new IOException("mock:\\private\\modified {secret}"); + } + } + } + + private sealed class RecordingListener : EventListener + { + public RecordingListener(Keywords keywords) : base(EventLevel.Verbose, keywords, null) + { + } + + public List Events { get; } = new List(); + + protected override void RecordMessageInternal(TraceEventMessage message) + { + this.Events.Add(message); + } + } } } diff --git a/GVFS/GVFS.UnitTests/Common/EnlistmentPathDiagnosticsTests.cs b/GVFS/GVFS.UnitTests/Common/EnlistmentPathDiagnosticsTests.cs new file mode 100644 index 0000000000..4c2aed4a68 --- /dev/null +++ b/GVFS/GVFS.UnitTests/Common/EnlistmentPathDiagnosticsTests.cs @@ -0,0 +1,166 @@ +using GVFS.Common; +using GVFS.Common.Database; +using GVFS.Common.NamedPipes; +using GVFS.UnitTests.Category; +using GVFS.UnitTests.Mock.Common; +using NUnit.Framework; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace GVFS.UnitTests.Common +{ + [TestFixture] + public class EnlistmentPathDiagnosticsTests + { + [TestCase("S", "Success")] + [TestCase("NotReady", "MountNotReady")] + [TestCase("InvalidVersion", "InvalidVersion")] + [TestCase("unknown", "UnexpectedResponse")] + public void RealLoaderRecordsPipeOutcomeAndDiskFallback(string header, string expected) + { + if (header == "NotReady") + { + header = NamedPipeMessages.MountNotReadyResult; + } + else if (header == "InvalidVersion") + { + header = NamedPipeMessages.ModifiedPaths.InvalidVersion; + } + + SourcePathData paths = new SourcePathData(); + paths.Pipe.Response = new NamedPipeMessages.Message(header, "file\0folder/\0"); + HydrationSummaryDiagnostics diagnostics = new HydrationSummaryDiagnostics(); + using (MockTracer tracer = new MockTracer()) + { + paths.LoadModifiedPaths(CreateEnlistment(), tracer, diagnostics); + } + + Assert.AreEqual(expected, diagnostics.ModifiedPathsPipeOutcome.ToString()); + Assert.AreEqual(HydrationDataState.Complete, diagnostics.ModifiedPathsState); + Assert.AreEqual(expected == "Success" ? HydrationPathSourceOutcome.NotAttempted : HydrationPathSourceOutcome.Success, diagnostics.ModifiedPathsDiskOutcome); + Assert.AreEqual(expected == "Success" ? "file" : "disk-file", paths.ModifiedFilePaths.Single()); + Assert.AreEqual(3000, paths.Pipe.ConnectTimeoutMs); + Assert.IsTrue(paths.Pipe.Disposed); + } + + [TestCase(true, "ConnectTimeout")] + [TestCase(false, "ConnectFailed")] + [Category(CategoryConstants.ExceptionExpected)] + public void ConnectionFailuresRetainCauseAndUseDisk(bool timeout, string expected) + { + SourcePathData paths = new SourcePathData(); + paths.Pipe.ConnectError = timeout ? new TimeoutException("private") : new IOException("private"); + HydrationSummaryDiagnostics diagnostics = new HydrationSummaryDiagnostics(); + using (MockTracer tracer = new MockTracer()) + { + paths.LoadModifiedPaths(CreateEnlistment(), tracer, diagnostics); + } + + Assert.AreEqual(expected, diagnostics.ModifiedPathsPipeOutcome.ToString()); + Assert.AreEqual(paths.Pipe.ConnectError.GetType().FullName, diagnostics.ModifiedPathsPipeExceptionType); + Assert.AreEqual(paths.Pipe.ConnectError.HResult, diagnostics.ModifiedPathsPipeExceptionHResult); + Assert.AreEqual(HydrationPathSourceOutcome.Success, diagnostics.ModifiedPathsDiskOutcome); + Assert.AreEqual(HydrationDataState.Complete, diagnostics.ModifiedPathsState); + } + + [TestCase(false, "Failed", 0)] + [TestCase(true, "Partial", 1)] + [Category(CategoryConstants.ExceptionExpected)] + public void DiskFailuresRetainCompletenessAndPartialPaths(bool partial, string state, int count) + { + SourcePathData paths = new SourcePathData { FailDisk = true, PartialDisk = partial }; + HydrationSummaryDiagnostics diagnostics = new HydrationSummaryDiagnostics(); + using (MockTracer tracer = new MockTracer()) + { + paths.LoadModifiedPaths(CreateEnlistment(), tracer, diagnostics); + Assert.That(tracer.RelatedWarningEvents.Single(), Does.Contain("System.IO.IOException")); + } + + Assert.AreEqual(state, diagnostics.ModifiedPathsState.ToString()); + Assert.AreEqual(HydrationPathSourceOutcome.Failed, diagnostics.ModifiedPathsDiskOutcome); + Assert.AreEqual(typeof(IOException).FullName, diagnostics.ModifiedPathsExceptionType); + Assert.IsNotNull(diagnostics.ModifiedPathsExceptionHResult); + Assert.AreEqual(count, paths.ModifiedFilePaths.Count); + } + + [TestCase(true)] + [TestCase(false)] + [Category(CategoryConstants.ExceptionExpected)] + public void PipeSendAndReadExceptionsRetainCause(bool send) + { + SourcePathData paths = new SourcePathData(); + IOException error = new IOException("private"); + paths.Pipe.SendError = send ? error : null; + paths.Pipe.ReadError = send ? null : error; + HydrationSummaryDiagnostics diagnostics = new HydrationSummaryDiagnostics(); + using (MockTracer tracer = new MockTracer()) + { + paths.LoadModifiedPaths(CreateEnlistment(), tracer, diagnostics); + } + + Assert.AreEqual(HydrationPathSourceOutcome.Failed, diagnostics.ModifiedPathsPipeOutcome); + Assert.AreEqual(typeof(IOException).FullName, diagnostics.ModifiedPathsPipeExceptionType); + Assert.AreEqual(error.HResult, diagnostics.ModifiedPathsPipeExceptionHResult); + Assert.AreEqual(HydrationPathSourceOutcome.Success, diagnostics.ModifiedPathsDiskOutcome); + } + + [TestCase] + public void RealPlaceholderLoaderClassifiesEachFolderKind() + { + SourcePathData paths = new SourcePathData(); + HydrationSummaryDiagnostics diagnostics = new HydrationSummaryDiagnostics(); + paths.LoadPlaceholdersFromDatabase(CreateEnlistment(), diagnostics); + Assert.AreEqual(1, paths.PlaceholderFilePaths.Count); + Assert.AreEqual(3, paths.PlaceholderFolderPaths.Count); + Assert.AreEqual(1, diagnostics.PartialFolderCount); + Assert.AreEqual(1, diagnostics.ExpandedFolderCount); + Assert.AreEqual(1, diagnostics.PossibleTombstoneFolderCount); + } + + private static GVFSEnlistment CreateEnlistment() + { + return new MockGVFSEnlistment("mock:\\repo", "mock://repo", "mock:\\git", gitProcess: null); + } + + private sealed class SourcePathData : EnlistmentPathData + { + public MockNamedPipeClient Pipe { get; } = new MockNamedPipeClient + { + Response = new NamedPipeMessages.Message("unknown", null), + }; + public bool FailDisk { get; set; } + public bool PartialDisk { get; set; } + + protected override NamedPipeClient CreateModifiedPathsPipeClient(GVFSEnlistment enlistment) + { + return this.Pipe; + } + + protected override IEnumerable ReadModifiedPathsFile(string filePath, HydrationSummaryDiagnostics diagnostics) + { + if (!this.FailDisk || this.PartialDisk) + { + yield return "disk-file"; + } + + if (this.FailDisk) + { + throw new IOException("mock:\\private\\modified-paths"); + } + } + + protected override void ReadPlaceholderEntries(GVFSEnlistment enlistment, out List files, out List folders) + { + files = new List { new PlaceholderTable.PlaceholderData { Path = "file", PathType = PlaceholderTable.PlaceholderData.PlaceholderType.File } }; + folders = new List + { + new PlaceholderTable.PlaceholderData { Path = "partial", PathType = PlaceholderTable.PlaceholderData.PlaceholderType.PartialFolder }, + new PlaceholderTable.PlaceholderData { Path = "expanded", PathType = PlaceholderTable.PlaceholderData.PlaceholderType.ExpandedFolder }, + new PlaceholderTable.PlaceholderData { Path = "tombstone", PathType = PlaceholderTable.PlaceholderData.PlaceholderType.PossibleTombstoneFolder }, + }; + } + } + } +} diff --git a/GVFS/GVFS.UnitTests/Common/Git/GitProcessCredentialTests.cs b/GVFS/GVFS.UnitTests/Common/Git/GitProcessCredentialTests.cs new file mode 100644 index 0000000000..64e30f3097 --- /dev/null +++ b/GVFS/GVFS.UnitTests/Common/Git/GitProcessCredentialTests.cs @@ -0,0 +1,335 @@ +using GVFS.Common.Git; +using GVFS.Common.Tracing; +using GVFS.Tests.Should; +using GVFS.UnitTests.Mock.Common; +using GVFS.UnitTests.Mock.Git; +using NUnit.Framework; +using System.Linq; + +namespace GVFS.UnitTests.Common.Git +{ + [TestFixture] + public class GitProcessCredentialTests + { + private const string SecretValue = "S3cretSentinelValueThatMustNotBeTraced"; + private const string AzureDevOpsUseHttpPathString = "-c credential.\"https://dev.azure.com\".useHttpPath=true"; + private const string CredentialFillCommand = AzureDevOpsUseHttpPathString + " credential fill"; + private const string CertificateFillCommand = "credential fill"; + private const string MissingPasswordMessage = "Git credential fill returned no password"; + private const int GitFatalExitCode = 128; + + [TestCase] + public void TryGetCredentialDoesNotTraceSecretWhenThePasswordIsMissing() + { + MockTracer tracer = new MockTracer(); + MockGitProcess gitProcess = CreateGitProcess( + CredentialFillCommand, + "protocol=https\nhost=example.com\nhelper output " + SecretValue + "=ignored\nusername=someone\ncredential=" + SecretValue + "\noauth_refresh_token=" + SecretValue + "\n"); + + gitProcess.TryGetCredential(tracer, "mock://repoUrl", out _, out string password, out string errorMessage).ShouldBeFalse(); + + password.ShouldBeNull(); + errorMessage.ShouldEqual(MissingPasswordMessage); + GetTelemetryWarning(tracer)["OutputKeys"].ShouldEqual("protocol,host,,username,credential,oauth_refresh_token"); + AssertNoSecret(tracer); + } + + [TestCase] + public void TryGetCertificatePasswordDoesNotTraceSecretWhenThePasswordIsMissing() + { + MockTracer tracer = new MockTracer(); + MockGitProcess gitProcess = CreateGitProcess( + CertificateFillCommand, + "protocol=cert\npath=mock://certificate\ncredential=" + SecretValue + "\n"); + + gitProcess.TryGetCertificatePassword(tracer, "mock://certificate", out string password, out string errorMessage).ShouldBeFalse(); + + password.ShouldBeNull(); + errorMessage.ShouldEqual(MissingPasswordMessage); + EventMetadata warning = GetTelemetryWarning(tracer); + warning["OutputKeys"].ShouldEqual("protocol,path,credential"); + warning["CertificatePath"].ShouldEqual("mock://certificate"); + AssertNoSecret(tracer); + } + + [TestCase("", "")] + [TestCase("protocol=https\r\nhost=example.com\r\nusername=someone\r\ncredential=" + SecretValue, "protocol,host,username,credential")] + [TestCase("protocol=https\rhost=example.com\nusername=someone\ncredential=" + SecretValue + "\n", "protocol,username,credential")] + [TestCase("no separator " + SecretValue + "\n=" + SecretValue + "\nusername=someone\ncredential=" + SecretValue + "\n", ",,username,credential")] + [TestCase("protocol=https\nhost=example.com\nurl=https://example.com\nwwwauth[]=Bearer realm=" + SecretValue + "\ncapability[]=authtype\noauth_refresh_token=" + SecretValue + "\nusername=someone\n", "protocol,host,url,wwwauth[],capability[],oauth_refresh_token,username")] + [TestCase("protocol=https\n password=" + SecretValue + "\nPassword=" + SecretValue + "\npassword =" + SecretValue + "\nusername=someone\n", "protocol,,,,username")] + [TestCase("custom_" + SecretValue + "=ignored\nusername=someone\n", ",username")] + public void TryGetCredentialTracesOnlyKnownKeyNamesWhenThePasswordIsMissing(string output, string expectedKeys) + { + MockTracer tracer = new MockTracer(); + MockGitProcess gitProcess = CreateGitProcess(CredentialFillCommand, output); + + gitProcess.TryGetCredential(tracer, "mock://repoUrl", out _, out _, out _).ShouldBeFalse(); + + GetTelemetryWarning(tracer)["OutputKeys"].ShouldEqual(expectedKeys); + AssertNoSecret(tracer); + } + + [TestCase] + public void TryGetCredentialLimitsTheNumberOfTracedKeys() + { + MockTracer tracer = new MockTracer(); + MockGitProcess gitProcess = CreateGitProcess( + CredentialFillCommand, + string.Concat(Enumerable.Repeat("key=value\n", 100))); + + gitProcess.TryGetCredential(tracer, "mock://repoUrl", out _, out _, out _).ShouldBeFalse(); + + string expectedKeys = string.Join(",", Enumerable.Repeat("", 32)) + ",..."; + GetTelemetryWarning(tracer)["OutputKeys"].ShouldEqual(expectedKeys); + } + + [TestCase] + public void TryGetCredentialDoesNotTraceKeysWhenOutputIsTruncated() + { + MockTracer tracer = new MockTracer(); + MockGitProcess gitProcess = CreateGitProcess( + CredentialFillCommand, + "protocol=https\nhost=example.com\n", + outputTruncated: true); + + gitProcess.TryGetCredential(tracer, "mock://repoUrl", out _, out _, out _).ShouldBeFalse(); + + GetTelemetryWarning(tracer)["OutputKeys"].ShouldEqual(""); + } + + [TestCase] + public void TryGetCredentialReportsTheFailureInTelemetryWhenThePasswordIsMissing() + { + MockTracer tracer = new MockTracer(); + MockGitProcess gitProcess = CreateGitProcess(CredentialFillCommand, "protocol=https\nhost=example.com\nusername=someone\n"); + + gitProcess.TryGetCredential(tracer, "mock://repoUrl", out _, out _, out _).ShouldBeFalse(); + + EventMetadata warning = GetTelemetryWarning(tracer); + warning["OutputKeys"].ShouldEqual("protocol,host,username"); + ((int)warning["ExitCode"]).ShouldEqual(GitProcess.Result.SuccessCode); + warning.ContainsKey("hasVisibleConsoleWindow").ShouldBeTrue("The console visibility diagnostic stays with the failure"); + warning[TracingConstants.MessageKey.WarningMessage].ShouldEqual(MissingPasswordMessage); + } + + [TestCase] + public void TryGetCredentialDoesNotReportAFailureWhenTheParseSucceeds() + { + MockTracer tracer = new MockTracer(); + MockGitProcess gitProcess = CreateGitProcess( + CredentialFillCommand, + "protocol=https\nhost=example.com\nusername=someone\npassword=" + SecretValue + "\n"); + + gitProcess.TryGetCredential(tracer, "mock://repoUrl", out _, out string password, out _).ShouldBeTrue(); + + password.ShouldEqual(SecretValue); + tracer.TelemetryWarnings.ShouldBeEmpty(); + GetActivityMetadata(tracer).ContainsKey("OutputKeys").ShouldBeFalse("Output keys are only traced when the parse fails"); + } + + [TestCase] + public void TryGetCertificatePasswordDoesNotReportAFailureWhenTheParseSucceeds() + { + MockTracer tracer = new MockTracer(); + MockGitProcess gitProcess = CreateGitProcess( + CertificateFillCommand, + "protocol=cert\npath=mock://certificate\npassword=" + SecretValue + "\n"); + + gitProcess.TryGetCertificatePassword(tracer, "mock://certificate", out string password, out _).ShouldBeTrue(); + + password.ShouldEqual(SecretValue); + tracer.TelemetryWarnings.ShouldBeEmpty(); + GetActivityMetadata(tracer).ContainsKey("OutputKeys").ShouldBeFalse("Output keys are only traced when the parse fails"); + } + + [TestCase] + public void TryGetCredentialAcceptsMissingUsername() + { + MockTracer tracer = new MockTracer(); + MockGitProcess gitProcess = CreateGitProcess( + CredentialFillCommand, + "protocol=https\nhost=example.com\npassword=" + SecretValue + "\n"); + + gitProcess.TryGetCredential(tracer, "mock://repoUrl", out string username, out string password, out _).ShouldBeTrue(); + + username.ShouldEqual(string.Empty); + password.ShouldEqual(SecretValue); + } + + [TestCase] + public void TryGetCredentialDescribesAFailedFillWithoutSendingStderrToTelemetry() + { + MockTracer tracer = new MockTracer(); + string stderr = "warning: invalid credential line: " + SecretValue; + MockGitProcess gitProcess = CreateGitProcess(CredentialFillCommand, string.Empty, stderr, GitFatalExitCode); + + gitProcess.TryGetCredential(tracer, "mock://repoUrl", out _, out _, out string errorMessage).ShouldBeFalse(); + + AssertStderrIsOnlyLocal(tracer, stderr); + AssertErrorMessageHasNoStderr(errorMessage); + } + + [TestCase] + public void TryGetCertificatePasswordDescribesAFailedFillWithoutSendingStderrToTelemetry() + { + MockTracer tracer = new MockTracer(); + string stderr = "warning: invalid credential line: " + SecretValue; + MockGitProcess gitProcess = CreateGitProcess(CertificateFillCommand, string.Empty, stderr, GitFatalExitCode); + + gitProcess.TryGetCertificatePassword(tracer, "mock://certificate", out _, out string errorMessage).ShouldBeFalse(); + + AssertStderrIsOnlyLocal(tracer, stderr); + GetTelemetryWarning(tracer)["CertificatePath"].ShouldEqual("mock://certificate"); + AssertErrorMessageHasNoStderr(errorMessage); + } + + [TestCase] + public void TryGetCredentialKeepsStderrOutOfTelemetryWhenTheCredentialManagerTimesOut() + { + MockTracer tracer = new MockTracer(); + string stderr = "Operation timed out after 5000ms; " + SecretValue; + MockGitProcess gitProcess = CreateGitProcess(CredentialFillCommand, string.Empty, stderr, GitProcess.Result.GenericFailureCode); + + gitProcess.TryGetCredential(tracer, "mock://repoUrl", out _, out _, out string errorMessage, timeoutMs: 5000).ShouldBeFalse(); + + errorMessage.ShouldEqual("Credential manager did not respond within 5 seconds"); + EventMetadata warning = GetTelemetryWarning(tracer); + ((int)warning["ErrorsLength"]).ShouldEqual(stderr.Length); + AssertNoSecret(warning); + tracer.RelatedWarningEvents.Any(e => e.Contains(SecretValue)).ShouldBeTrue("The local log keeps the captured stderr of a timeout"); + } + + [TestCase] + public void TryGetCredentialFailsWhenThePasswordIsEmpty() + { + MockTracer tracer = new MockTracer(); + MockGitProcess gitProcess = CreateGitProcess(CredentialFillCommand, "protocol=https\nusername=someone\npassword=\n"); + + gitProcess.TryGetCredential(tracer, "mock://repoUrl", out _, out _, out string errorMessage).ShouldBeFalse(); + + errorMessage.ShouldEqual(MissingPasswordMessage); + GetTelemetryWarning(tracer)["OutputKeys"].ShouldEqual("protocol,username,password"); + } + + [TestCase] + public void TryGetCertificatePasswordFailsWhenThePasswordIsEmpty() + { + MockTracer tracer = new MockTracer(); + MockGitProcess gitProcess = CreateGitProcess(CertificateFillCommand, "protocol=cert\npath=mock://certificate\npassword=\n"); + + gitProcess.TryGetCertificatePassword(tracer, "mock://certificate", out _, out string errorMessage).ShouldBeFalse(); + + errorMessage.ShouldEqual(MissingPasswordMessage); + GetTelemetryWarning(tracer)["OutputKeys"].ShouldEqual("protocol,path,password"); + } + + [TestCase("protocol=https\nxpassword=WRONG\npath=contains-password=WRONG\nusername=someone\npassword=RIGHT\n", "someone", "RIGHT")] + [TestCase("username=bob\nurl=https://example.com/?username=evil\npassword=RIGHT\n", "bob", "RIGHT")] + [TestCase("username=\npassword=RIGHT\n", "", "RIGHT")] + [TestCase("password=RIGHT\r\nusername=someone\r\n", "someone", "RIGHT")] + [TestCase("username=first\nusername=second\npassword=WRONG\npassword=RIGHT\n", "second", "RIGHT")] + [TestCase("username=someone\npassword=RIGHT", "someone", "RIGHT")] + public void TryGetCredentialReadsLikeGit(string output, string expectedUsername, string expectedPassword) + { + MockTracer tracer = new MockTracer(); + MockGitProcess gitProcess = CreateGitProcess(CredentialFillCommand, output); + + gitProcess.TryGetCredential(tracer, "mock://repoUrl", out string username, out string password, out _).ShouldBeTrue(); + + username.ShouldEqual(expectedUsername); + password.ShouldEqual(expectedPassword); + } + + [TestCase("oauth_password=WRONG\nusername=someone\n")] + [TestCase("path=contains-password=WRONG\nusername=someone\n")] + [TestCase("username=someone\rpassword=WRONG\n")] + public void TryGetCredentialFailsWhenNoLineStartsWithThePasswordKey(string output) + { + MockTracer tracer = new MockTracer(); + MockGitProcess gitProcess = CreateGitProcess(CredentialFillCommand, output); + + gitProcess.TryGetCredential(tracer, "mock://repoUrl", out _, out string password, out _).ShouldBeFalse(); + + password.ShouldBeNull(); + } + + private static MockGitProcess CreateGitProcess( + string command, + string output, + string errors = "", + int exitCode = GitProcess.Result.SuccessCode, + bool outputTruncated = false) + { + MockGitProcess gitProcess = new MockGitProcess(); + gitProcess.SetExpectedCommandResult( + command, + () => new GitProcess.Result(output, errors, exitCode, outputTruncated, errorsTruncated: false)); + return gitProcess; + } + + private static void AssertErrorMessageHasNoStderr(string errorMessage) + { + // The caller puts this message in an exception, and the retry handler reports exceptions + // with the telemetry keyword, so it must not carry stderr. + errorMessage.Contains(SecretValue).ShouldBeFalse("The returned error message must not carry stderr: " + errorMessage); + errorMessage.ShouldEqual("Git credential fill failed with exit code " + GitFatalExitCode + "; see the log for its error output"); + } + + private static void AssertStderrIsOnlyLocal(MockTracer tracer, string stderr) + { + EventMetadata warning = GetTelemetryWarning(tracer); + ((int)warning["ExitCode"]).ShouldEqual(GitFatalExitCode); + ((int)warning["ErrorsLength"]).ShouldEqual(stderr.Length); + ((bool)warning["ErrorsTruncated"]).ShouldBeFalse(); + warning.ContainsKey("hasVisibleConsoleWindow").ShouldBeTrue("The console visibility diagnostic stays in telemetry"); + AssertNoSecret(warning); + tracer.RelatedWarningEvents.Any(e => e.Contains(SecretValue)).ShouldBeTrue("The local log keeps stderr for diagnosis"); + } + + private static EventMetadata GetTelemetryWarning(MockTracer tracer) + { + tracer.TelemetryWarnings.Count.ShouldEqual(1); + return tracer.TelemetryWarnings.Single(); + } + + private static EventMetadata GetActivityMetadata(MockTracer tracer) + { + MockTracer activityTracer = tracer.StartActivityTracer; + activityTracer.ShouldNotBeNull("The credential call must start an activity"); + activityTracer.StopMetadata.Count.ShouldEqual(1); + + return activityTracer.StopMetadata.Single(); + } + + private static void AssertNoSecret(MockTracer tracer) + { + foreach (MockTracer source in new[] { tracer, tracer.StartActivityTracer }) + { + foreach (string traced in source.RelatedInfoEvents + .Concat(source.RelatedWarningEvents) + .Concat(source.RelatedErrorEvents)) + { + traced.Contains(SecretValue).ShouldBeFalse("Credential output must not be traced: " + traced); + } + + foreach (EventMetadata metadata in source.TelemetryWarnings + .Concat(source.RelatedEventMetadata) + .Concat(source.StopMetadata) + .Where(m => m != null)) + { + AssertNoSecret(metadata); + } + } + } + + private static void AssertNoSecret(EventMetadata metadata) + { + foreach (object value in metadata.Values) + { + string text = value?.ToString() ?? string.Empty; + text.Contains(SecretValue).ShouldBeFalse("Credential output must not be traced: " + text); + } + } + } +} diff --git a/GVFS/GVFS.UnitTests/Common/GitStatusCacheTests.cs b/GVFS/GVFS.UnitTests/Common/GitStatusCacheTests.cs index 59737a83f8..aa82ec0c81 100644 --- a/GVFS/GVFS.UnitTests/Common/GitStatusCacheTests.cs +++ b/GVFS/GVFS.UnitTests/Common/GitStatusCacheTests.cs @@ -10,6 +10,8 @@ using System; using System.Collections.Generic; using System.IO; +using System.Threading; +using GVFS.Common.Tracing; namespace GVFS.UnitTests.Common { @@ -88,6 +90,244 @@ public void TearDown() GitStatusCache.TEST_EnableHydrationSummaryOverride = null; } + [TestCase] + public void MissingHydrationProviderExplainsTheCacheMiss() + { + using (GitStatusCache cache = new GitStatusCache(this.context, TimeSpan.Zero)) + { + cache.UpdateHydrationSummary(); + Assert.IsNull(cache.GetCachedHydrationSummary(out NamedPipeMessages.HydrationStatus.CacheInfo info)); + Assert.AreEqual(NamedPipeMessages.HydrationStatus.CacheState.ProviderUnavailable, info.State); + Assert.IsNull(info.CalculationId); + Assert.AreEqual(-1, info.AgeMs); + } + } + + [TestCase] + public void DisabledHydrationFeatureExplainsTheCacheMiss() + { + using (GitStatusCache cache = new GitStatusCache(this.context, TimeSpan.Zero)) + { + cache.SetProjectedFolderCountProvider(() => throw new AssertionException("A disabled feature must not count folders.")); + cache.UpdateHydrationSummary(); + Assert.IsNull(cache.GetCachedHydrationSummary(out NamedPipeMessages.HydrationStatus.CacheInfo info)); + Assert.AreEqual(NamedPipeMessages.HydrationStatus.CacheState.FeatureDisabled, info.State); + Assert.IsFalse(info.CountsComplete); + } + } + + [TestCase] + public void HydrationSnapshotPublishesCountsIdentityAgeAndInvalidationTogether() + { + this.AddHydrationIndex(); + using (DiagnosticStatusCache cache = new DiagnosticStatusCache(this.context)) + { + cache.Calculate = diagnostics => this.CalculateHydration(diagnostics); + cache.UpdateHydrationSummary(); + EnlistmentHydrationSummary summary = cache.GetCachedHydrationSummary(out NamedPipeMessages.HydrationStatus.CacheInfo info); + Assert.IsNotNull(summary); + Assert.AreEqual(summary.Diagnostics.CalculationId, info.CalculationId); + Assert.AreEqual(NamedPipeMessages.HydrationStatus.CacheState.Available, info.State); + Assert.IsTrue(info.CountsComplete); + Assert.That(info.AgeMs, Is.GreaterThanOrEqualTo(0)); + Assert.IsFalse(info.Invalidated); + cache.Invalidate(); + Assert.AreSame(summary, cache.GetCachedHydrationSummary(out info)); + Assert.IsTrue(info.Invalidated); + cache.Enabled = false; + cache.UpdateHydrationSummary(); + Assert.AreSame(summary, cache.GetCachedHydrationSummary(out NamedPipeMessages.HydrationStatus.CacheInfo skipped)); + Assert.AreEqual(NamedPipeMessages.HydrationStatus.CacheState.FeatureDisabled, skipped.State); + Assert.AreEqual(info.CalculationId, skipped.CalculationId); + Assert.That(skipped.AgeMs, Is.GreaterThanOrEqualTo(info.AgeMs)); + cache.Enabled = true; + cache.Breaker.Disabled = true; + cache.UpdateHydrationSummary(); + Assert.AreSame(summary, cache.GetCachedHydrationSummary(out info)); + Assert.AreEqual(NamedPipeMessages.HydrationStatus.CacheState.CircuitBreakerDisabled, info.State); + } + } + + [TestCase("InvalidCounts", false)] + [TestCase("IncompleteData", true)] + [TestCase("Cancelled", false)] + [TestCase("Failed", false)] + [Category(CategoryConstants.ExceptionExpected)] + public void HydrationRefreshReportsEachCalculationState(string state, bool available) + { + this.AddHydrationIndex(); + using (DiagnosticStatusCache cache = new DiagnosticStatusCache(this.context)) + using (CancellationTokenSource cancelled = new CancellationTokenSource()) + { + cancelled.Cancel(); + cache.Calculate = diagnostics => this.CalculateHydration(diagnostics); + cache.UpdateHydrationSummary(); + Assert.IsNotNull(cache.GetCachedHydrationSummary(out _)); + cache.Calculate = diagnostics => this.CalculateHydration(diagnostics, + failModified: state == "IncompleteData", + folderCount: state == "InvalidCounts" ? -1 : 20, + throwFolder: state == "Failed", + cancellationToken: state == "Cancelled" ? cancelled.Token : default); + cache.UpdateHydrationSummary(); + EnlistmentHydrationSummary summary = cache.GetCachedHydrationSummary(out NamedPipeMessages.HydrationStatus.CacheInfo info); + Assert.AreEqual(available, summary != null); + Assert.AreEqual(state, info.State.ToString()); + Assert.IsNotNull(info.CalculationId); + Assert.IsFalse(info.CountsComplete && state != "InvalidCounts"); + Assert.AreEqual(state == "Failed" ? 1 : 0, cache.Breaker.Failures); + } + } + + [TestCase] + [Category(CategoryConstants.ExceptionExpected)] + public void ConfigurationFailurePreservesSummaryAndDoesNotTripBreaker() + { + this.AddHydrationIndex(); + using (DiagnosticStatusCache cache = new DiagnosticStatusCache(this.context)) + { + cache.Calculate = diagnostics => this.CalculateHydration(diagnostics); + cache.UpdateHydrationSummary(); + EnlistmentHydrationSummary previous = cache.GetCachedHydrationSummary(out NamedPipeMessages.HydrationStatus.CacheInfo previousInfo); + cache.ThrowConfig = true; + Assert.Throws(() => cache.UpdateHydrationSummary()); + Assert.AreSame(previous, cache.GetCachedHydrationSummary(out NamedPipeMessages.HydrationStatus.CacheInfo info)); + Assert.AreEqual(previousInfo.CalculationId, info.CalculationId); + Assert.AreEqual(NamedPipeMessages.HydrationStatus.CacheState.Available, info.State); + Assert.AreEqual(0, cache.Breaker.Failures); + } + } + + [TestCase] + [Category(CategoryConstants.ExceptionExpected)] + public void UnexpectedCacheUpdateFailureHasExplicitStateAndTripsBreakerOnce() + { + using (DiagnosticStatusCache cache = new DiagnosticStatusCache(this.context)) + { + cache.Calculate = diagnostics => throw new IOException("mock:\\private\\cache"); + cache.UpdateHydrationSummary(); + Assert.IsNull(cache.GetCachedHydrationSummary(out NamedPipeMessages.HydrationStatus.CacheInfo info)); + Assert.AreEqual(NamedPipeMessages.HydrationStatus.CacheState.Failed, info.State); + Assert.AreEqual(1, cache.Breaker.Failures); + } + } + + [TestCase] + [Category(CategoryConstants.ExceptionExpected)] + public void ForeignCancellationDoesNotIncrementTheBreaker() + { + this.AddHydrationIndex(); + using (DiagnosticStatusCache cache = new DiagnosticStatusCache(this.context)) + { + cache.Calculate = diagnostics => EnlistmentHydrationSummary.CreateSummary(this.context.Enlistment, this.fileSystem, this.context.Tracer, + () => throw new OperationCanceledException(), new EmptyHydrationPaths(), diagnostics: diagnostics); + cache.UpdateHydrationSummary(); + Assert.IsNull(cache.GetCachedHydrationSummary(out NamedPipeMessages.HydrationStatus.CacheInfo info)); + Assert.AreEqual(NamedPipeMessages.HydrationStatus.CacheState.Cancelled, info.State); + Assert.AreEqual(0, cache.Breaker.Failures); + } + } + + [TestCase] + public void HeartbeatCountersResetAndCurrentStateIsAPointSample() + { + using (DiagnosticStatusCache cache = new DiagnosticStatusCache(this.context)) + { + cache.Initialize(); + cache.GetCachedHydrationSummary(out _); + cache.GetCachedHydrationSummary(out _); + cache.Enabled = false; + cache.UpdateHydrationSummary(); + EventMetadata metadata = new EventMetadata(); + Assert.IsTrue(cache.WriteTelemetryandReset(metadata)); + Assert.AreEqual(2L, metadata["HydrationStatus.RequestCount"]); + Assert.AreEqual(2L, metadata["HydrationStatus.UnavailableCount"]); + Assert.AreEqual(NamedPipeMessages.HydrationStatus.CacheState.FeatureDisabled, metadata["HydrationStatus.CurrentCacheState"]); + EventMetadata reset = new EventMetadata(); + cache.WriteTelemetryandReset(reset); + Assert.IsFalse(reset.ContainsKey("HydrationStatus.RequestCount")); + } + } + + private void AddHydrationIndex() + { + byte[] bytes = new byte[12]; + System.Text.Encoding.ASCII.GetBytes("DIRC").CopyTo(bytes, 0); + bytes[7] = 4; + bytes[11] = 100; + MockFile file = new MockFile(this.context.Enlistment.GitIndexPath, bytes); + this.enlistmentDirectory.FindDirectory(Path.Combine(this.gitParentPath, ".git")).Files.Add(file.FullName, file); + } + + private EnlistmentHydrationSummary CalculateHydration(HydrationSummaryDiagnostics diagnostics, bool failModified = false, + int folderCount = 20, bool throwFolder = false, CancellationToken cancellationToken = default) + { + return EnlistmentHydrationSummary.CreateSummary(this.context.Enlistment, this.fileSystem, this.context.Tracer, + () => throwFolder ? throw new IOException("mock:\\private\\folder") : folderCount, + new EmptyHydrationPaths { FailModified = failModified }, cancellationToken, diagnostics); + } + + private sealed class EmptyHydrationPaths : EnlistmentPathData + { + public bool FailModified { get; set; } + + public override void LoadPlaceholdersFromDatabase(GVFSEnlistment enlistment, HydrationSummaryDiagnostics diagnostics = null) + { + } + + public override void LoadModifiedPaths(GVFSEnlistment enlistment, ITracer tracer, HydrationSummaryDiagnostics diagnostics = null) + { + diagnostics.ModifiedPathsState = this.FailModified ? HydrationDataState.Failed : HydrationDataState.Complete; + } + } + + private sealed class DiagnosticStatusCache : GitStatusCache + { + public DiagnosticStatusCache(GVFSContext context) : base(context, TimeSpan.Zero) + { + this.SetProjectedFolderCountProvider(() => 20); + } + + public Func Calculate { get; set; } + public bool Enabled { get; set; } = true; + public bool ThrowConfig { get; set; } + public MockHydrationBreaker Breaker { get; } = new MockHydrationBreaker(); + + internal override bool GetHydrationSummaryEnabled() + { + return this.ThrowConfig ? throw new IOException("mock:\\config") : this.Enabled; + } + + internal override HydrationStatusCircuitBreaker CreateHydrationCircuitBreaker() + { + return this.Breaker; + } + + internal override EnlistmentHydrationSummary CreateHydrationSummary(HydrationSummaryDiagnostics diagnostics) + { + return this.Calculate(diagnostics); + } + } + + private sealed class MockHydrationBreaker : HydrationStatusCircuitBreaker + { + public MockHydrationBreaker() : base("mock:\\gvfs", NullTracer.Instance) + { + } + + public bool Disabled { get; set; } + public int Failures { get; private set; } + + public override bool IsDisabled() + { + return this.Disabled; + } + + public override void RecordFailure() + { + this.Failures++; + } + } + [TestCase] public void CanInvalidateCleanCache() { diff --git a/GVFS/GVFS.UnitTests/Common/HydrationFailureDiagnosticsTests.cs b/GVFS/GVFS.UnitTests/Common/HydrationFailureDiagnosticsTests.cs new file mode 100644 index 0000000000..fc7c8d409c --- /dev/null +++ b/GVFS/GVFS.UnitTests/Common/HydrationFailureDiagnosticsTests.cs @@ -0,0 +1,131 @@ +using GVFS.Common.FileSystem; +using GVFS.Tests.Should; +using NUnit.Framework; +using System; +using System.Collections.Generic; +using System.IO; + +namespace GVFS.UnitTests.Common +{ + [TestFixture] + public class HydrationFailureDiagnosticsTests + { + private const int ErrorSharingViolationWin32Code = 32; + private static readonly int ErrorSharingViolationHResult = unchecked((int)0x80070000) | ErrorSharingViolationWin32Code; + + [TestCase] + public void BuildMetadataIncludesExceptionTypeAndHResult() + { + IOException failure = new IOException("sharing violation", ErrorSharingViolationHResult); + + var metadata = HydrationFailureDiagnostics.BuildMetadata("some\\path", failure); + + metadata["Path"].ShouldEqual("some\\path"); + metadata["ExceptionType"].ShouldEqual(nameof(IOException)); + metadata["HResult"].ShouldEqual("0x" + ErrorSharingViolationHResult.ToString("X8")); + } + + [TestCase] + public void BuildMetadataDerivesWin32ErrorFromWin32HResult() + { + IOException failure = new IOException("sharing violation", ErrorSharingViolationHResult); + + var metadata = HydrationFailureDiagnostics.BuildMetadata("some\\path", failure); + + metadata.ContainsKey("Win32Error").ShouldBeTrue(); + metadata["Win32Error"].ShouldEqual(ErrorSharingViolationWin32Code); + } + + [TestCase] + public void BuildMetadataOmitsWin32ErrorWhenHResultIsNotWin32Facility() + { + // The default HResult for a plain Exception (COR_E_EXCEPTION) is not encoded via + // HRESULT_FROM_WIN32 (facility != FACILITY_WIN32), unlike most BCL I/O exceptions. + Exception failure = new Exception("not a Win32-facility HResult"); + + var metadata = HydrationFailureDiagnostics.BuildMetadata("some\\path", failure); + + metadata.ContainsKey("Win32Error").ShouldBeFalse(); + } + + [TestCase] + public void GetFailureSignatureIncludesWin32ErrorWhenDerivable() + { + IOException failure = new IOException("sharing violation", ErrorSharingViolationHResult); + + string signature = HydrationFailureDiagnostics.GetFailureSignature(failure); + + signature.ShouldEqual($"{nameof(IOException)}(Win32={ErrorSharingViolationWin32Code})"); + } + + [TestCase] + public void GetFailureSignatureFallsBackToHResultWhenWin32NotDerivable() + { + Exception failure = new Exception("not a Win32-facility HResult"); + + string signature = HydrationFailureDiagnostics.GetFailureSignature(failure); + + signature.ShouldEqual($"{nameof(Exception)}(HResult=0x{failure.HResult:X8})"); + } + + [TestCase] + public void BuildMetadataDerivesWin32ErrorForUnauthorizedAccessException() + { + // Access-denied hydration failures surface as UnauthorizedAccessException, not IOException — + // confirm the Win32 derivation isn't accidentally IOException-specific. + int accessDeniedHResult = unchecked((int)0x80070000) | 5; + UnauthorizedAccessException failure = new UnauthorizedAccessException("access denied"); + SetHResult(failure, accessDeniedHResult); + + var metadata = HydrationFailureDiagnostics.BuildMetadata("some\\path", failure); + + metadata["ExceptionType"].ShouldEqual(nameof(UnauthorizedAccessException)); + metadata["Win32Error"].ShouldEqual(5); + } + + [TestCase] + public void GetFailureSignatureDerivesWin32ErrorAtZeroBoundary() + { + // Win32 code 0 (ERROR_SUCCESS) is a degenerate but legal boundary value for the mask — + // confirm it is still reported as a derived Win32 error, not mistaken for "not derivable". + IOException failure = new IOException("boundary", unchecked((int)0x80070000)); + + string signature = HydrationFailureDiagnostics.GetFailureSignature(failure); + + signature.ShouldEqual($"{nameof(IOException)}(Win32=0)"); + } + + [TestCase] + public void BuildMetadataThrowsOnNullFailure() + { + Assert.Throws(() => HydrationFailureDiagnostics.BuildMetadata("some\\path", null)); + } + + [TestCase] + public void GetFailureSignatureThrowsOnNullFailure() + { + Assert.Throws(() => HydrationFailureDiagnostics.GetFailureSignature(null)); + } + + private static void SetHResult(Exception exception, int hResult) + { + typeof(Exception) + .GetProperty(nameof(Exception.HResult)) + .SetValue(exception, hResult); + } + + [TestCase] + public void FormatSignatureCountsJoinsEntries() + { + Dictionary counts = new Dictionary + { + { "IOException(Win32=32)", 3 }, + { "UnauthorizedAccessException(HResult=0x80070005)", 1 }, + }; + + string formatted = HydrationFailureDiagnostics.FormatSignatureCounts(counts); + + formatted.ShouldEqual("IOException(Win32=32)=3;UnauthorizedAccessException(HResult=0x80070005)=1"); + } + } +} diff --git a/GVFS/GVFS.UnitTests/Common/HydrationStatusDiagnosticsTests.cs b/GVFS/GVFS.UnitTests/Common/HydrationStatusDiagnosticsTests.cs new file mode 100644 index 0000000000..3416b97ca1 --- /dev/null +++ b/GVFS/GVFS.UnitTests/Common/HydrationStatusDiagnosticsTests.cs @@ -0,0 +1,367 @@ +using GVFS.CommandLine; +using GVFS.Common; +using GVFS.Common.NamedPipes; +using GVFS.Common.Tracing; +using GVFS.UnitTests.Mock.Common; +using NUnit.Framework; +using System; +using System.IO; +using System.Linq; +using System.Collections.Generic; +using System.Threading; +using GVFS.UnitTests.Category; + +namespace GVFS.UnitTests.Common +{ + [TestFixture] + public class HydrationStatusDiagnosticsTests + { + [TestCase] + public void CacheExtensionPreservesLegacyCountPrefix() + { + Guid calculationId = Guid.NewGuid(); + NamedPipeMessages.HydrationStatus.Response response = new NamedPipeMessages.HydrationStatus.Response + { + PlaceholderFileCount = 1, + PlaceholderFolderCount = 2, + ModifiedFileCount = 3, + ModifiedFolderCount = 4, + TotalFileCount = 100, + TotalFolderCount = 50, + }; + NamedPipeMessages.HydrationStatus.CacheInfo info = new NamedPipeMessages.HydrationStatus.CacheInfo + { + State = NamedPipeMessages.HydrationStatus.CacheState.Available, + CalculationId = calculationId, + AgeMs = 123, + Invalidated = true, + CountsComplete = true, + }; + string body = response.ToBody(info); + Assert.AreEqual("1,2,3,4,100,50,D1,1," + calculationId.ToString("N") + ",123,1,1", body); + Assert.IsTrue(NamedPipeMessages.HydrationStatus.Response.TryParse(body, out NamedPipeMessages.HydrationStatus.Response parsed)); + Assert.AreEqual(100, parsed.TotalFileCount); + Assert.IsTrue(NamedPipeMessages.HydrationStatus.CacheInfo.TryParse(body, out NamedPipeMessages.HydrationStatus.CacheInfo parsedInfo, hasCounts: true)); + Assert.AreEqual(calculationId, parsedInfo.CalculationId); + Assert.AreEqual(123, parsedInfo.AgeMs); + Assert.IsTrue(parsedInfo.Invalidated); + Assert.IsTrue(parsedInfo.CountsComplete); + } + + [TestCase(null)] + [TestCase("")] + [TestCase("D2,1,-,0,0,1")] + [TestCase("D1,999,-,0,0,1")] + [TestCase("D1,1,private-path,0,0,1")] + [TestCase("D1,1,-,-2,0,1")] + [TestCase("D1,1,-,0,yes,1")] + [TestCase("D1,1,-,0,0,yes")] + public void CacheExtensionRejectsUnknownOrMalformedFields(string body) + { + Assert.IsFalse(NamedPipeMessages.HydrationStatus.CacheInfo.TryParse(body, out _)); + } + + [TestCase(null, "MalformedResponse")] + [TestCase("1,2", "MalformedResponse")] + [TestCase("1,2,secret,4,100,50", "MalformedResponse")] + [TestCase("1,2,3,4,100,1", "InvalidCounts")] + [TestCase("1,2,3,4,100,50", "Success")] + public void CacheResultDistinguishesMalformedAndInvalidCounts(string body, string outcome) + { + HealthVerb.CacheLookupResult result = HealthVerb.ParseCachedResponse( + new NamedPipeMessages.Message("S", body)); + Assert.AreEqual(outcome, result.Outcome.ToString()); + Assert.IsNull(result.Info); + Assert.IsFalse((bool)result.GetMetadata()["DiagnosticsAvailable"]); + Assert.That(EventMetadataConverter.SerializeToString(result.GetMetadata()), Does.Not.Contain("secret")); + if (body != null) + { + Assert.AreEqual(1, result.Counts["PlaceholderFileCount"]); + } + } + + [TestCase] + public void CacheMissRetainsTheFailedCalculationIdentity() + { + NamedPipeMessages.HydrationStatus.CacheInfo info = new NamedPipeMessages.HydrationStatus.CacheInfo + { + State = NamedPipeMessages.HydrationStatus.CacheState.InvalidCounts, + CalculationId = Guid.NewGuid(), + AgeMs = 1234, + }; + HealthVerb.CacheLookupResult result = HealthVerb.ParseCachedResponse(new NamedPipeMessages.Message("NA", info.ToBody())); + Assert.AreEqual(HealthVerb.CacheLookupOutcome.NotAvailable, result.Outcome); + Assert.AreEqual(info.CalculationId, result.Info.CalculationId); + Assert.AreEqual(NamedPipeMessages.HydrationStatus.CacheState.InvalidCounts, result.Info.State); + Assert.IsNull(result.Counts); + } + + [TestCase] + public void CachedDisplayRecordsCountsWithoutFallback() + { + using (StringWriter output = new StringWriter()) + using (MockTracer tracer = new MockTracer()) + { + CachedHealthVerb verb = new CachedHealthVerb { Output = output }; + EventMetadata metadata = new EventMetadata(); + verb.OutputHydrationPercent(null, tracer, metadata, Guid.NewGuid()); + Assert.AreEqual("Cache", metadata["ResultSource"]); + Assert.AreEqual("Percentage", metadata["DisplayOutcome"]); + Assert.AreEqual(HealthVerb.HealthCommandOutcome.Success, metadata["Outcome"]); + Assert.IsNull(metadata["CalculationOutcome"]); + EventMetadata cache = (EventMetadata)metadata["Cache"]; + Assert.AreEqual(HealthVerb.CacheLookupOutcome.Success, cache["Outcome"]); + Assert.AreEqual(100, ((EventMetadata)cache["Counts"])["TotalFileCount"]); + Assert.That(output.ToString(), Does.Contain("4% of files and 12% of folders hydrated.")); + } + } + + [TestCase("NotComputed", 0)] + [TestCase("Available", 1)] + [TestCase("ProviderUnavailable", 2)] + [TestCase("FeatureDisabled", 3)] + [TestCase("CircuitBreakerDisabled", 4)] + [TestCase("InvalidCounts", 5)] + [TestCase("IncompleteData", 6)] + [TestCase("Cancelled", 7)] + [TestCase("Failed", 8)] + public void CacheStateIntegersAreWireContracts(string state, int value) + { + Assert.AreEqual(value, (int)Enum.Parse(state)); + Assert.AreEqual("D1," + value + ",-,-1,0,0", + new NamedPipeMessages.HydrationStatus.CacheInfo { State = (NamedPipeMessages.HydrationStatus.CacheState)value }.ToBody()); + Assert.IsTrue(NamedPipeMessages.HydrationStatus.CacheInfo.TryParse("D1," + value + ",-,-1,0,0", out NamedPipeMessages.HydrationStatus.CacheInfo parsed)); + Assert.AreEqual(-1, parsed.AgeMs); + } + + [TestCase("NA", null, "NotAvailable", "Absent")] + [TestCase("NA", "private-path", "NotAvailable", "Unrecognized")] + [TestCase("S", "1,2,3,4,100,50,D2,private", "Success", "Unrecognized")] + [TestCase("unknown", "private", "UnexpectedResponse", "Absent")] + [TestCase("S", "1,2,3,4,100,50,D1,999,-,0,0,0", "Success", "Unrecognized")] + [TestCase("S", "1,2,3,4,100,50,D1,1,private,-2,0,0", "Success", "Unrecognized")] + public void UnknownResponsesPreserveSafeProvenance(string header, string body, string outcome, string diagnostics) + { + HealthVerb.CacheLookupResult result = HealthVerb.ParseCachedResponse(new NamedPipeMessages.Message(header, body)); + Assert.AreEqual(outcome, result.Outcome.ToString()); + Assert.AreEqual(diagnostics, result.DiagnosticsStatus.ToString()); + Assert.That(EventMetadataConverter.SerializeToString(result.GetMetadata()), Does.Not.Contain("private")); + } + + [TestCase] + public void AdversarialResponsesCannotCopyRawTextIntoTelemetry() + { + string[] bodies = + { + null, + string.Empty, + ",,,,,", + "1,2,3,4,100,50,D1,99,-,0,0,0", + "1,2,3,4,100,50,D1,1,private,0,0,0", + "1,2,3,4,100,50,D1,1,{01234567-89ab-cdef-0123-456789abcdef},0,0,0", + "1,2,3,4,100,50,D1,1,-,9223372036854775808,0,0", + "2147483648,0,0,0,0,0", + "2147483647,0,2147483647,0,1,1", + new string(',', 1024 * 1024), + }; + foreach (string body in bodies) + { + HealthVerb.CacheLookupResult result = HealthVerb.ParseCachedResponse(new NamedPipeMessages.Message("S", body)); + string payload = EventMetadataConverter.SerializeToString(result.GetMetadata()); + Assert.That(payload, Does.Not.Contain("private")); + Assert.That(payload.Length, Is.LessThan(2048)); + Assert.IsNull(result.Info); + Assert.IsTrue(result.Counts.Values.All(value => value == null || value is int)); + } + } + + [TestCase(true)] + [TestCase(false)] + public void CacheHitKeepsCommandOutcomeSeparateFromCompleteness(bool complete) + { + using (StringWriter output = new StringWriter()) + using (MockTracer tracer = new MockTracer()) + { + NamedPipeMessages.HydrationStatus.CacheInfo info = new NamedPipeMessages.HydrationStatus.CacheInfo + { + CountsComplete = complete, + State = complete ? NamedPipeMessages.HydrationStatus.CacheState.Available : NamedPipeMessages.HydrationStatus.CacheState.IncompleteData, + CalculationId = Guid.NewGuid(), + }; + WorkerHealthVerb verb = new WorkerHealthVerb { Output = output }; + verb.Pipe.Response = new NamedPipeMessages.Message("S", "1,2,3,4,100,50," + info.ToBody()); + EventMetadata metadata = new EventMetadata(); + verb.OutputHydrationPercent(null, tracer, metadata, Guid.NewGuid()); + Assert.AreEqual(HealthVerb.HealthCommandOutcome.Success, metadata["Outcome"]); + Assert.AreEqual(complete ? HydrationSummaryOutcome.Success : HydrationSummaryOutcome.IncompleteData, metadata["CalculationOutcome"]); + Assert.AreEqual(info.CalculationId.Value.ToString("N"), metadata["CalculationId"]); + Assert.AreEqual("Cache", metadata["ResultSource"]); + Assert.IsTrue(verb.Pipe.Disposed); + } + } + + [TestCase("ConnectTimeout", "Connect")] + [TestCase("ConnectFailed", "Connect")] + [TestCase("SendFailed", "Send")] + [TestCase("ReadFailed", "Read")] + [Category(CategoryConstants.ExceptionExpected)] + public void RealWorkerRecordsTransportFailureAndPhase(string failure, string phase) + { + WorkerHealthVerb verb = new WorkerHealthVerb(); + verb.Pipe.ConnectError = failure == "ConnectTimeout" ? new TimeoutException("private") + : failure == "ConnectFailed" ? new IOException("private") : null; + verb.Pipe.SendError = failure == "SendFailed" ? new IOException("private") : null; + verb.Pipe.ReadError = failure == "ReadFailed" ? new IOException("private") : null; + HealthVerb.CacheLookupResult result = verb.TryGetCachedHydrationStatus(null); + Assert.AreEqual(failure.EndsWith("Failed") && phase != "Connect" ? "Failed" : failure, result.Outcome.ToString()); + Assert.AreEqual(phase, result.Phase.ToString()); + Assert.AreEqual(500, verb.Pipe.ConnectTimeoutMs); + Assert.IsTrue(verb.Pipe.Disposed); + Assert.That(EventMetadataConverter.SerializeToString(result.GetMetadata()), Does.Not.Contain("private")); + } + + [TestCase] + [Category(CategoryConstants.ExceptionExpected)] + public void RealPipeConnectionReturnsItsTimeoutCause() + { + using (NamedPipeClient client = new NamedPipeClient("GVFS.UnitTests.Missing." + Guid.NewGuid().ToString("N"))) + { + Assert.IsFalse(client.Connect(25, out Exception error)); + Assert.IsInstanceOf(error); + } + } + + [TestCase] + public void LateWorkerDoesNotMutateTimeoutMetadata() + { + using (ManualResetEventSlim release = new ManualResetEventSlim()) + { + WorkerHealthVerb verb = new WorkerHealthVerb(); + verb.Pipe.ReadResponseCallback = () => + { + if (!release.Wait(TimeSpan.FromSeconds(10))) + { + throw new TimeoutException("Test did not release the pipe worker."); + } + + return new NamedPipeMessages.Message("S", "1,2,3,4,100,50"); + }; + try + { + HealthVerb.CacheLookupResult result = verb.TryGetCachedHydrationStatus(null); + Assert.AreEqual(HealthVerb.CacheLookupOutcome.Timeout, result.Outcome); + Assert.That(result.DurationMs, Is.GreaterThanOrEqualTo(900)); + string before = EventMetadataConverter.SerializeToString(result.GetMetadata()); + release.Set(); + Assert.IsTrue(SpinWait.SpinUntil(() => verb.Pipe.Disposed, TimeSpan.FromSeconds(5))); + Assert.AreEqual(before, EventMetadataConverter.SerializeToString(result.GetMetadata())); + } + finally + { + release.Set(); + SpinWait.SpinUntil(() => verb.Pipe.Disposed, TimeSpan.FromSeconds(5)); + } + } + } + + [TestCase] + [Category(CategoryConstants.ExceptionExpected)] + public void TerminalFailureRetainsIdentityWithoutLeakingExceptionText() + { + using (JsonTracer tracer = new JsonTracer("test", "health", disableTelemetry: true)) + { + MetadataListener telemetry = new MetadataListener(Keywords.Telemetry); + MetadataListener local = new MetadataListener(Keywords.Any); + tracer.AddEventListener(telemetry); + tracer.AddEventListener(local); + ThrowingHealthVerb verb = new ThrowingHealthVerb(); + Assert.Throws(() => verb.ExecuteWithCompletion(null, tracer, Guid.NewGuid())); + string completion = telemetry.Events.Single(e => e.EventName == "health").Payload; + Assert.That(completion, Does.Contain("\"Outcome\":\"Failed\"")); + Assert.That(completion, Does.Contain("\"ExceptionType\":\"System.IO.IOException\"")); + Assert.That(completion, Does.Not.Contain("private")); + Assert.That(local.Events.Single(e => e.EventName == "Warning").Payload, Does.Contain("private")); + } + } + + private sealed class WorkerHealthVerb : HealthVerb + { + public MockNamedPipeClient Pipe { get; } = new MockNamedPipeClient(); + + internal override NamedPipeClient CreateHydrationPipeClient(GVFSEnlistment enlistment) + { + return this.Pipe; + } + } + + private sealed class ThrowingHealthVerb : HealthVerb + { + internal override void ExecuteWithTracer(GVFSEnlistment enlistment, ITracer tracer, EventMetadata metadata, Guid commandId) + { + throw new IOException("mock:\\private\\details"); + } + } + + private sealed class MetadataListener : EventListener + { + public MetadataListener(Keywords keywords) : base(EventLevel.Verbose, keywords, null) + { + } + + public List Events { get; } = new List(); + + protected override void RecordMessageInternal(TraceEventMessage message) + { + this.Events.Add(message); + } + } + + [TestCase] + public void DiagnosticArithmeticDoesNotOverflowOrHideMultipleFailures() + { + HydrationSummaryDiagnostics diagnostics = new HydrationSummaryDiagnostics + { + PlaceholderFileCount = int.MaxValue, + ModifiedFileCount = int.MaxValue, + TotalFileCount = 1, + PlaceholderFolderCount = 2, + ModifiedFolderCount = 3, + TotalFolderCount = 1, + }; + HydrationValidationFailure failures = diagnostics.GetValidationFailures(); + Assert.IsTrue(failures.HasFlag(HydrationValidationFailure.FileSumOverflow)); + Assert.IsTrue(failures.HasFlag(HydrationValidationFailure.HydratedFilesExceedTotal)); + Assert.IsTrue(failures.HasFlag(HydrationValidationFailure.HydratedFoldersExceedTotal)); + Assert.AreEqual(4294967294L, diagnostics.GetMetadata()["HydratedFileCount"]); + } + + [TestCase] + public void DiskDiagnosticsObserveOperationsWithoutChangingReplaySemantics() + { + using (Stream stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes("A a\nD a\nA b/\ninvalid\n"))) + using (StreamReader streamReader = new StreamReader(stream)) + { + HydrationSummaryDiagnostics diagnostics = new HydrationSummaryDiagnostics(); + string[] lines = new EnlistmentPathData().ReadModifiedPathDatabaseLines(streamReader, diagnostics).ToArray(); + Assert.AreEqual(new[] { "a", "D a", "b/", "invalid" }, lines); + Assert.AreEqual(2, diagnostics.ModifiedPathsAddRecords); + Assert.AreEqual(1, diagnostics.ModifiedPathsDeleteRecords); + Assert.AreEqual(1, diagnostics.ModifiedPathsOtherRecords); + } + } + + private sealed class CachedHealthVerb : HealthVerb + { + internal override CacheLookupResult TryGetCachedHydrationStatus(GVFSEnlistment enlistment) + { + return ParseCachedResponse(new NamedPipeMessages.Message("S", "1,2,3,4,100,50")); + } + + internal override EnlistmentHydrationSummary CreateHydrationSummary(GVFSEnlistment enlistment, ITracer tracer, HydrationSummaryDiagnostics diagnostics) + { + Assert.Fail("A cached result must not calculate a fallback."); + return null; + } + } + } +} diff --git a/GVFS/GVFS.UnitTests/Common/MountFailureReporterTests.cs b/GVFS/GVFS.UnitTests/Common/MountFailureReporterTests.cs new file mode 100644 index 0000000000..1660868f05 --- /dev/null +++ b/GVFS/GVFS.UnitTests/Common/MountFailureReporterTests.cs @@ -0,0 +1,216 @@ +using GVFS.Common; +using GVFS.Common.NamedPipes; +using GVFS.Tests.Should; +using GVFS.UnitTests.Category; +using GVFS.UnitTests.Mock.Common; +using NUnit.Framework; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace GVFS.UnitTests.Common +{ + [TestFixture] + public class MountFailureReporterTests + { + [TestCase] + public void FirstCallerOwnsTheFailureAndPublishesTheMessage() + { + FakeEnvironment environment = new FakeEnvironment(); + MountFailureReporter reporter = CreateReporter(environment); + + reporter.FailureMessage.ShouldBeNull(); + + reporter.TryBeginFailure(exitCode: 3, message: "the real reason").ShouldBeTrue(); + + reporter.FailureMessage.ShouldEqual("the real reason"); + environment.ExitCodes.Count.ShouldEqual(0); + environment.BlockCount.ShouldEqual(0); + } + + [TestCase] + public void ReentrantCallOnTheOwningThreadExitsAndKeepsTheFirstMessage() + { + FakeEnvironment environment = new FakeEnvironment(); + MountFailureReporter reporter = CreateReporter(environment); + reporter.TryBeginFailure(exitCode: 3, message: "first").ShouldBeTrue(); + + reporter.TryBeginFailure(exitCode: 7, message: "second").ShouldBeFalse(); + + environment.ExitCodes.ShouldMatchInOrder(7); + environment.BlockCount.ShouldEqual(0); + reporter.FailureMessage.ShouldEqual("first"); + } + + [TestCase] + public void CallFromAnotherThreadBlocksUntilTheProcessExits() + { + FakeEnvironment environment = new FakeEnvironment(); + MountFailureReporter reporter = CreateReporter(environment); + reporter.TryBeginFailure(exitCode: 3, message: "first").ShouldBeTrue(); + + bool otherThreadResult = true; + Task other = Task.Run(() => otherThreadResult = reporter.TryBeginFailure(exitCode: 7, message: "second")); + other.Wait(Timeout.Infinite); + + otherThreadResult.ShouldBeFalse(); + environment.BlockCount.ShouldEqual(1); + environment.ExitCodes.Count.ShouldEqual(0); + reporter.FailureMessage.ShouldEqual("first"); + } + + [TestCase] + public void WaitDoesNothingWhenThePipeNeverOpened() + { + FakeEnvironment environment = new FakeEnvironment(); + MountFailureReporter reporter = CreateReporter(environment); + reporter.TryBeginFailure(exitCode: 3, message: "failed before the pipe opened"); + + reporter.WaitForClientToReadFailure().ShouldBeFalse(); + + environment.WaitTimeouts.Count.ShouldEqual(0); + environment.Sleeps.Count.ShouldEqual(0); + } + + [TestCase] + public void WaitUsesTheShortTimeoutWhenNoClientHasReceivedAStatus() + { + FakeEnvironment environment = new FakeEnvironment(); + MountFailureReporter reporter = CreateReporter(environment); + reporter.OnNamedPipeReady(); + reporter.TryBeginFailure(exitCode: 3, message: "nobody is listening"); + + reporter.WaitForClientToReadFailure().ShouldBeFalse(); + + environment.WaitTimeouts.ShouldMatchInOrder(MountFailureReporter.DefaultNoClientTimeoutMs); + environment.Sleeps.Count.ShouldEqual(0); + } + + [TestCase] + public void WaitUsesTheLongTimeoutOnceAClientHasReceivedAStatus() + { + FakeEnvironment environment = new FakeEnvironment(); + MountFailureReporter reporter = CreateReporter(environment); + reporter.OnNamedPipeReady(); + + // A client polled while the mount was still in progress. Somebody is listening, + // but that response did not carry the failure. + reporter.TrySendStatus(() => true, carriesMountFailure: false).ShouldBeTrue(); + reporter.TryBeginFailure(exitCode: 3, message: "the real reason"); + + reporter.WaitForClientToReadFailure().ShouldBeFalse(); + + environment.WaitTimeouts.ShouldMatchInOrder(MountFailureReporter.DefaultReportTimeoutMs); + } + + [TestCase] + public void WaitReturnsAndDrainsOnceAClientHasReadTheFailure() + { + FakeEnvironment environment = new FakeEnvironment(); + MountFailureReporter reporter = CreateReporter(environment); + reporter.OnNamedPipeReady(); + reporter.TryBeginFailure(exitCode: 3, message: "the real reason"); + reporter.TrySendStatus(() => true, carriesMountFailure: true).ShouldBeTrue(); + + reporter.WaitForClientToReadFailure().ShouldBeTrue(); + + environment.Sleeps.ShouldMatchInOrder(MountFailureReporter.DefaultDrainMs); + } + + [TestCase] + public void AFailedSendIsNotRecordedAndDoesNotReleaseTheWait() + { + FakeEnvironment environment = new FakeEnvironment(); + MountFailureReporter reporter = CreateReporter(environment); + reporter.OnNamedPipeReady(); + reporter.TryBeginFailure(exitCode: 3, message: "the real reason"); + + // The client disconnected before it received the response. Counting it would + // release the wait and let the process exit before any client read the failure. + reporter.TrySendStatus(() => false, carriesMountFailure: true).ShouldBeFalse(); + + reporter.WaitForClientToReadFailure().ShouldBeFalse(); + + environment.WaitTimeouts.ShouldMatchInOrder(MountFailureReporter.DefaultNoClientTimeoutMs); + environment.Sleeps.Count.ShouldEqual(0); + } + + [TestCase] + public void DrainOutlastsTheClientPollIntervalAndTheNoClientWaitIsShorter() + { + // The mounting client must get another poll before the process exits, even if a + // different client consumed the failure first. + MountFailureReporter.DefaultDrainMs.ShouldBeAtLeast(GVFSEnlistment.MountStatusPollIntervalMs + 1); + + MountFailureReporter.DefaultNoClientTimeoutMs.ShouldBeAtMost(MountFailureReporter.DefaultReportTimeoutMs - 1); + } + + [TestCase] + public void FormatFailureAppliesArguments() + { + MountFailureReporter.FormatFailure("Error: {0}", new object[] { "boom" }) + .ShouldEqual("Error: boom"); + } + + [TestCase] + public void FormatFailureLeavesTheMessageAloneWhenThereAreNoArguments() + { + // A pre-built message can contain braces, so it must not be run through + // string.Format when there is nothing to substitute. + MountFailureReporter.FormatFailure("a message with {braces} in it", null) + .ShouldEqual("a message with {braces} in it"); + + MountFailureReporter.FormatFailure("a message with {braces} in it", new object[0]) + .ShouldEqual("a message with {braces} in it"); + } + + [TestCase] + [Category(CategoryConstants.ExceptionExpected)] + public void FormatFailureFallsBackWhenTheFormatStringIsMalformed() + { + // Failure text built from exception messages and server responses can contain + // unbalanced braces. Formatting must not throw and mask the reported failure. + MountFailureReporter.FormatFailure("unbalanced {0} and {", new object[] { "value" }) + .ShouldEqual("unbalanced {0} and {"); + } + + private static MountFailureReporter CreateReporter(FakeEnvironment environment) + { + return new MountFailureReporter(new MockTracer(), environment); + } + + private class FakeEnvironment : IMountFailureEnvironment + { + public List WaitTimeouts { get; } = new List(); + + public List Sleeps { get; } = new List(); + + public List ExitCodes { get; } = new List(); + + public int BlockCount { get; private set; } + + public bool WaitForSignal(WaitHandle signal, int timeoutMs) + { + this.WaitTimeouts.Add(timeoutMs); + + // Report the signal's current state instead of waiting on it. + return signal.WaitOne(0); + } + + public void Sleep(int durationMs) + { + this.Sleeps.Add(durationMs); + } + + public void ExitProcess(int exitCode) + { + this.ExitCodes.Add(exitCode); + } + + public void BlockUntilProcessExits() + { + this.BlockCount++; + } + } + } +} diff --git a/GVFS/GVFS.UnitTests/Common/RepoMetadataTests.cs b/GVFS/GVFS.UnitTests/Common/RepoMetadataTests.cs new file mode 100644 index 0000000000..f6ba954378 --- /dev/null +++ b/GVFS/GVFS.UnitTests/Common/RepoMetadataTests.cs @@ -0,0 +1,82 @@ +using GVFS.Common; +using GVFS.Tests.Should; +using GVFS.UnitTests.Mock.Common; +using GVFS.UnitTests.Mock.FileSystem; +using NUnit.Framework; +using System.Collections.Generic; +using System.IO; + +namespace GVFS.UnitTests.Common +{ + [TestFixture] + public class RepoMetadataTests + { + private const string DiskLayoutMajorVersionKey = "DiskLayoutVersion"; + private const string DotGVFSPath = "mock:\\repo\\.gvfs"; + + private MockFileSystem fileSystem; + + [SetUp] + public void SetUp() + { + if (GVFSPlatform.Instance == null) + { + GVFSPlatform.Register(new MockPlatform()); + } + + MockDirectory root = new MockDirectory("mock:", null, null); + root.CreateDirectory(Path.Combine(DotGVFSPath, "databases")); + this.fileSystem = new MockFileSystem(root); + + string error; + RepoMetadata.TryInitialize(new MockTracer(), this.fileSystem, DotGVFSPath, out error) + .ShouldEqual(true); + } + + [TearDown] + public void TearDown() + { + RepoMetadata.Shutdown(); + } + + [TestCase] + public void MissingDiskLayoutVersionReturnsSpecificReturnCode() + { + int majorVersion; + int minorVersion; + string error; + ReturnCode returnCode; + + RepoMetadata.Instance.TryGetOnDiskLayoutVersion(out majorVersion, out minorVersion, out error, out returnCode) + .ShouldEqual(false); + + returnCode.ShouldEqual(ReturnCode.MissingDiskLayoutVersion); + error.ShouldEqual(RepoMetadata.MissingDiskLayoutVersionMessage); + } + + [TestCase] + public void InvalidDiskLayoutVersionReturnsGenericError() + { + string metadataPath = RepoMetadata.Instance.DataFilePath; + KeyValuePair diskLayoutVersion = new KeyValuePair(DiskLayoutMajorVersionKey, "not-an-int"); + this.fileSystem.WriteAllText(metadataPath, "A " + GVFSJsonOptions.Serialize(diskLayoutVersion).Trim() + "\r\n"); + + RepoMetadata.Shutdown(); + + string initializeError; + RepoMetadata.TryInitialize(new MockTracer(), this.fileSystem, DotGVFSPath, out initializeError) + .ShouldEqual(true); + + int majorVersion; + int minorVersion; + string error; + ReturnCode returnCode; + + RepoMetadata.Instance.TryGetOnDiskLayoutVersion(out majorVersion, out minorVersion, out error, out returnCode) + .ShouldEqual(false); + + returnCode.ShouldEqual(ReturnCode.GenericError); + error.ShouldContain("Failed to parse persisted disk layout version number"); + } + } +} diff --git a/GVFS/GVFS.UnitTests/Common/WaitUntilMountedFailureReportingTests.cs b/GVFS/GVFS.UnitTests/Common/WaitUntilMountedFailureReportingTests.cs new file mode 100644 index 0000000000..6f28e51b84 --- /dev/null +++ b/GVFS/GVFS.UnitTests/Common/WaitUntilMountedFailureReportingTests.cs @@ -0,0 +1,102 @@ +using GVFS.Common; +using GVFS.Common.NamedPipes; +using GVFS.Tests.Should; +using GVFS.UnitTests.Mock.Common; +using NUnit.Framework; +using System; +using System.IO.Pipes; +using System.Threading.Tasks; + +namespace GVFS.UnitTests.Common +{ + [TestFixture] + public class WaitUntilMountedFailureReportingTests + { + private const string EnlistmentRoot = "mock:\\fake\\root"; + + [TestCase] + public void ReportsTheMountErrorSentByTheMountProcess() + { + const string MountError = + "Error: Failed to start virtualization instance (-2147024418). " + + "The ProjFS filter (PrjFlt) cannot attach to this volume."; + + string errorMessage = RunAgainstMountFailedServer(MountError); + + errorMessage.ShouldEqual(MountError); + } + + [TestCase] + public void FallsBackToGenericMessageWhenNoMountErrorIsSent() + { + string errorMessage = RunAgainstMountFailedServer(mountError: null); + + errorMessage.ShouldEqual("Failed to mount at " + EnlistmentRoot); + } + + /// + /// Stands in for GVFS.Mount: answers a single GetStatus request with MountFailed, + /// then returns the error message WaitUntilMounted produced. + /// + /// + /// Uses a raw rather than + /// , because the latter goes through + /// GVFSPlatform.CreatePipeByName, which the unit-test mock platform does not + /// support. + /// + private static string RunAgainstMountFailedServer(string mountError) + { + string pipeName = "GVFS_test_mount_failed_" + Guid.NewGuid().ToString("N"); + + NamedPipeMessages.GetStatus.Response response = new NamedPipeMessages.GetStatus.Response + { + MountStatus = NamedPipeMessages.GetStatus.MountFailed, + MountError = mountError, + EnlistmentRoot = EnlistmentRoot, + }; + + string responseJson = response.ToJson(); + + using (NamedPipeServerStream serverStream = new NamedPipeServerStream( + pipeName, + PipeDirection.InOut, + maxNumberOfServerInstances: 1)) + { + Task serverTask = Task.Run(() => + { + serverStream.WaitForConnection(); + + NamedPipeStreamReader reader = new NamedPipeStreamReader(serverStream); + NamedPipeStreamWriter writer = new NamedPipeStreamWriter(serverStream); + + // WaitUntilMounted sends GetStatus and stops on the first + // MountFailed response, so one exchange is enough. + reader.ReadMessage(); + writer.WriteMessage(responseJson); + }); + + try + { + string errorMessage; + bool result = GVFSEnlistment.WaitUntilMounted( + new MockTracer(), + pipeName, + EnlistmentRoot, + unattended: false, + out errorMessage); + + result.ShouldBeFalse(); + return errorMessage; + } + finally + { + // Surfaces a hung server task instead of silently disposing the stream + // out from under it, which would fault an unobserved task and hide the + // real cause of a failure. + serverTask.Wait(TimeSpan.FromSeconds(5)).ShouldBeTrue( + "The stand-in mount server should have answered and completed."); + } + } + } + } +} diff --git a/GVFS/GVFS.UnitTests/Common/WorktreeEnlistmentTests.cs b/GVFS/GVFS.UnitTests/Common/WorktreeEnlistmentTests.cs index dd072b1e5b..b1a3460caf 100644 --- a/GVFS/GVFS.UnitTests/Common/WorktreeEnlistmentTests.cs +++ b/GVFS/GVFS.UnitTests/Common/WorktreeEnlistmentTests.cs @@ -1,5 +1,7 @@ using GVFS.Common; +using GVFS.Common.Git; using GVFS.Tests.Should; +using GVFS.UnitTests.Mock.Git; using NUnit.Framework; using System.IO; @@ -91,6 +93,26 @@ public void DotGitRootPointsToSharedGitDir() enlistment.DotGitRoot.ShouldEqual(this.sharedGitDir); } + [TestCase] + public void GitProcessPassesWorktreeGitFileAsGitDir() + { + // The --git-dir that reaches git must be the worktree's .git file, not the gitdir: + // target inside it and not DotGitRoot. Git resolves per-worktree state through the file. + GVFSEnlistment enlistment = this.CreateWorktreeEnlistment(); + MockGitProcess gitProcess = new MockGitProcess(enlistment); + gitProcess.SetExpectedCommandResult( + "rev-parse HEAD", + () => new GitProcess.Result("abc123", string.Empty, GitProcess.Result.SuccessCode)); + + gitProcess.RevParse("HEAD"); + + gitProcess.InvocationsRun.Count.ShouldEqual(1); + string gitDir = gitProcess.InvocationsRun[0].DotGitDirectory; + gitDir.ShouldEqual(Path.Combine(this.worktreePath, ".git")); + gitDir.ShouldNotEqual(enlistment.DotGitRoot); + gitDir.ShouldNotEqual(this.worktreeGitDir); + } + [TestCase] public void WorkingDirectoryRootIsWorktreePath() { diff --git a/GVFS/GVFS.UnitTests/GVFS.UnitTests.csproj b/GVFS/GVFS.UnitTests/GVFS.UnitTests.csproj index 0517e82f15..1d88885cf4 100644 --- a/GVFS/GVFS.UnitTests/GVFS.UnitTests.csproj +++ b/GVFS/GVFS.UnitTests/GVFS.UnitTests.csproj @@ -27,6 +27,12 @@ + + Hooks\KnownGitCommands.cs + + + Hooks\Program.CommandLocking.cs + Hooks\UnstageCommandParser.cs @@ -57,4 +63,3 @@ - diff --git a/GVFS/GVFS.UnitTests/Git/GitAuthenticationTests.cs b/GVFS/GVFS.UnitTests/Git/GitAuthenticationTests.cs index 682a80537f..6ebc4cb9de 100644 --- a/GVFS/GVFS.UnitTests/Git/GitAuthenticationTests.cs +++ b/GVFS/GVFS.UnitTests/Git/GitAuthenticationTests.cs @@ -1,6 +1,7 @@ using System; using System.Diagnostics; using System.Linq; +using System.Text; using System.Threading; using System.Threading.Tasks; using GVFS.Common; @@ -77,6 +78,105 @@ public void BackoffIsNotInEffectAfterSuccess() } } + [TestCase] + public void CredentialWithoutUsernameIsAcceptedAndApprovedWithEmptyUsername() + { + MockTracer tracer = new MockTracer(); + MockGitProcess gitProcess = this.GetGitProcess(); + gitProcess.SetExpectedCommandResult( + $"{AzureDevOpsUseHttpPathString} credential fill", + () => new GitProcess.Result("protocol=https\nhost=example.com\npassword=token\n", string.Empty, GitProcess.Result.SuccessCode)); + + GitAuthentication dut = new GitAuthentication(gitProcess, "mock://repoUrl"); + dut.TryInitializeAndRequireAuth(tracer, out _); + + dut.TryGetCredentials(tracer, out string authString, out string error).ShouldEqual(true, "Failed to get credential: " + error); + Encoding.UTF8.GetString(Convert.FromBase64String(authString)).ShouldEqual(":token"); + + dut.ApproveCredentials(tracer, authString); + + gitProcess.CredentialApprovals["mock://repoUrl"].Count.ShouldEqual(1); + gitProcess.CredentialApprovals["mock://repoUrl"][0].Username.ShouldEqual(string.Empty); + gitProcess.CredentialApprovals["mock://repoUrl"][0].Password.ShouldEqual("token"); + } + + [TestCase] + public void CredentialWithColonInPasswordCanBeApproved() + { + MockTracer tracer = new MockTracer(); + MockGitProcess gitProcess = this.GetGitProcess(); + gitProcess.SetExpectedCommandResult( + $"{AzureDevOpsUseHttpPathString} credential fill", + () => new GitProcess.Result("username=someone\npassword=to:ken:value\n", string.Empty, GitProcess.Result.SuccessCode)); + + GitAuthentication dut = new GitAuthentication(gitProcess, "mock://repoUrl"); + dut.TryInitializeAndRequireAuth(tracer, out _); + + dut.TryGetCredentials(tracer, out string authString, out string error).ShouldEqual(true, "Failed to get credential: " + error); + dut.ApproveCredentials(tracer, authString); + + tracer.RelatedErrorEvents.ShouldBeEmpty(); + gitProcess.CredentialApprovals["mock://repoUrl"].Count.ShouldEqual(1); + gitProcess.CredentialApprovals["mock://repoUrl"][0].Username.ShouldEqual("someone"); + gitProcess.CredentialApprovals["mock://repoUrl"][0].Password.ShouldEqual("to:ken:value"); + } + + [TestCase] + public void CredentialWithNonAsciiCharactersIsNotCorrupted() + { + const string username = "üser"; + const string password = "pässwörd€"; + MockTracer tracer = new MockTracer(); + MockGitProcess gitProcess = this.GetGitProcess(); + gitProcess.SetExpectedCommandResult( + $"{AzureDevOpsUseHttpPathString} credential fill", + () => new GitProcess.Result($"username={username}\npassword={password}\n", string.Empty, GitProcess.Result.SuccessCode)); + + GitAuthentication dut = new GitAuthentication(gitProcess, "mock://repoUrl"); + dut.TryInitializeAndRequireAuth(tracer, out _); + + dut.TryGetCredentials(tracer, out string authString, out string error).ShouldEqual(true, "Failed to get credential: " + error); + Encoding.UTF8.GetString(Convert.FromBase64String(authString)).ShouldEqual(username + ":" + password); + + dut.ApproveCredentials(tracer, authString); + + tracer.RelatedErrorEvents.ShouldBeEmpty(); + gitProcess.CredentialApprovals["mock://repoUrl"][0].Username.ShouldEqual(username); + gitProcess.CredentialApprovals["mock://repoUrl"][0].Password.ShouldEqual(password); + } + + [TestCase] + public void FailedCredentialFillDoesNotReturnStderrToTheCaller() + { + const string secret = "S3cretSentinelValueThatMustNotBeTraced"; + MockTracer tracer = new MockTracer(); + MockGitProcess gitProcess = this.GetGitProcess(); + gitProcess.SetExpectedCommandResult( + $"{AzureDevOpsUseHttpPathString} credential fill", + () => new GitProcess.Result(string.Empty, "warning: invalid credential line: " + secret, 128)); + + GitAuthentication dut = new GitAuthentication(gitProcess, "mock://repoUrl"); + + // The caller wraps this message in an exception that the retry handler reports to telemetry. + dut.TryInitializeAndRequireAuth(tracer, out string error).ShouldEqual(false); + error.Contains(secret).ShouldBeFalse("The error message must not carry stderr: " + error); + } + + [TestCase] + public void CredentialWithoutPasswordFailsInitializationWithAReason() + { + MockTracer tracer = new MockTracer(); + MockGitProcess gitProcess = this.GetGitProcess(); + gitProcess.SetExpectedCommandResult( + $"{AzureDevOpsUseHttpPathString} credential fill", + () => new GitProcess.Result("protocol=https\nhost=example.com\nusername=someone\n", string.Empty, GitProcess.Result.SuccessCode)); + + GitAuthentication dut = new GitAuthentication(gitProcess, "mock://repoUrl"); + + dut.TryInitializeAndRequireAuth(tracer, out string error).ShouldEqual(false); + error.ShouldEqual("Git credential fill returned no password"); + } + [TestCase] public void ContinuesToBackoffIfTryGetCredentialsFails() { diff --git a/GVFS/GVFS.UnitTests/Git/GitProcessTests.cs b/GVFS/GVFS.UnitTests/Git/GitProcessTests.cs index 8182f8dfb5..b2b2efe038 100644 --- a/GVFS/GVFS.UnitTests/Git/GitProcessTests.cs +++ b/GVFS/GVFS.UnitTests/Git/GitProcessTests.cs @@ -1,8 +1,10 @@ using GVFS.Common.Git; using GVFS.Tests.Should; using GVFS.UnitTests.Mock.Common; +using GVFS.UnitTests.Mock.Git; using NUnit.Framework; using System.Diagnostics; +using System.IO; namespace GVFS.UnitTests.Git { @@ -102,6 +104,38 @@ public void TryKillRunningProcess_NeverRan() error.ShouldBeNull(); } + [TestCase] + public void GitDirectoryMatchesDotGitRootForRegularEnlistment() + { + // Regular enlistments have no separate worktree .git file, + // so the --git-dir that reaches git matches Enlistment.DotGitRoot. + MockGVFSEnlistment enlistment = new MockGVFSEnlistment(); + MockGitProcess process = new MockGitProcess(enlistment); + process.SetExpectedCommandResult( + "rev-parse HEAD", + () => new GitProcess.Result("abc123", string.Empty, GitProcess.Result.SuccessCode)); + + process.RevParse("HEAD"); + + process.InvocationsRun.Count.ShouldEqual(1); + process.InvocationsRun[0].DotGitDirectory.ShouldEqual(enlistment.DotGitRoot); + } + + [TestCase] + public void NoGitDirectoryIsPassedWhenWorkingDirectoryRootIsNull() + { + // Instances without an enlistment root cannot supply a --git-dir. + MockGitProcess process = new MockGitProcess(Path.Combine("mock:", "git"), workingDirectoryRoot: null); + process.SetExpectedCommandResult( + "rev-parse HEAD", + () => new GitProcess.Result("abc123", string.Empty, GitProcess.Result.SuccessCode)); + + process.RevParse("HEAD"); + + process.InvocationsRun.Count.ShouldEqual(1); + process.InvocationsRun[0].DotGitDirectory.ShouldBeNull(); + } + [TestCase] public void ResultHasNoErrors() { diff --git a/GVFS/GVFS.UnitTests/Hooks/CommandLockingTests.cs b/GVFS/GVFS.UnitTests/Hooks/CommandLockingTests.cs new file mode 100644 index 0000000000..6b198795b3 --- /dev/null +++ b/GVFS/GVFS.UnitTests/Hooks/CommandLockingTests.cs @@ -0,0 +1,140 @@ +using GVFS.Tests.Should; +using NUnit.Framework; +using System.Linq; + +namespace GVFS.UnitTests.Hooks +{ + [TestFixture] + public class CommandLockingTests + { + [TestCase("annotate")] + [TestCase("annotate --contents mock-file HEAD")] + [TestCase("git-annotate")] + [TestCase("blame")] + [TestCase("branch")] + [TestCase("cat-file")] + [TestCase("check-attr")] + [TestCase("check-ignore")] + [TestCase("check-mailmap")] + [TestCase("check-ref-format --branch topic")] + [TestCase("check-ref-format --normalize refs/heads/topic")] + [TestCase("check-ref-format --refspec-pattern refs/heads/*")] + [TestCase("cherry -v HEAD~2 HEAD HEAD~1")] + [TestCase("commit-graph")] + [TestCase("config")] + [TestCase("count-objects")] + [TestCase("count-objects -vH")] + [TestCase("credential")] + [TestCase("diff")] + [TestCase("diff-files")] + [TestCase("diff-index")] + [TestCase("diff-tree")] + [TestCase("difftool")] + [TestCase("fetch")] + [TestCase("for-each-ref")] + [TestCase("help")] + [TestCase("hash-object")] + [TestCase("index-pack")] + [TestCase("log")] + [TestCase("ls-files")] + [TestCase("ls-remote --symref origin")] + [TestCase("ls-remote --get-url origin")] + [TestCase("ls-tree")] + [TestCase("merge-base")] + [TestCase("multi-pack-index")] + [TestCase("name-rev")] + [TestCase("pack-objects")] + [TestCase("push")] + [TestCase("range-diff HEAD~2..HEAD~1 HEAD~1..HEAD")] + [TestCase("remote")] + [TestCase("reset --soft")] + [TestCase("rev-list")] + [TestCase("rev-parse")] + [TestCase("shortlog -sn HEAD")] + [TestCase("show")] + [TestCase("show-branch --list")] + [TestCase("show-branch --merge-base")] + [TestCase("show-branch --reflog")] + [TestCase("show-ref")] + [TestCase("submodule")] + [TestCase("symbolic-ref")] + [TestCase("tag")] + [TestCase("unpack-objects")] + [TestCase("update-ref")] + [TestCase("verify-commit --raw -v HEAD")] + [TestCase("verify-pack -v mock-pack.idx")] + [TestCase("verify-pack -s mock-pack.idx")] + [TestCase("verify-tag --raw -v mock-tag")] + [TestCase("verify-tag --format=%(refname) mock-tag")] + [TestCase("version")] + [TestCase("web--browse")] + [TestCase("worktree list")] + [TestCase("worktree list --porcelain")] + [TestCase("worktree list --porcelain -z")] + [TestCase("worktree list --verbose --expire=now")] + [TestCase("git-worktree list")] + [TestCase("WORKTREE LIST")] + [TestCase("worktree --git-pid=123 list")] + public void CommandsWithoutLockPreserveTheirPolicy(string command) + { + AssertLockPolicy(command, shouldLock: false); + } + + [TestCase("add")] + [TestCase("apply")] + [TestCase("checkout")] + [TestCase("checkout-index")] + [TestCase("cherry-pick")] + [TestCase("clean")] + [TestCase("commit")] + [TestCase("describe")] + [TestCase("describe --dirty")] + [TestCase("describe --broken")] + [TestCase("grep --open-files-in-pager=mock-pager pattern")] + [TestCase("merge")] + [TestCase("mv")] + [TestCase("pull")] + [TestCase("read-tree")] + [TestCase("rebase")] + [TestCase("reset")] + [TestCase("reset --mixed")] + [TestCase("reset --hard")] + [TestCase("restore")] + [TestCase("revert")] + [TestCase("rm")] + [TestCase("status")] + [TestCase("status --no-lock-index")] + [TestCase("update-index")] + [TestCase("worktree")] + [TestCase("worktree --git-pid=123")] + [TestCase("worktree add -b branch list")] + [TestCase("worktree remove list")] + [TestCase("worktree move list new-path")] + [TestCase("worktree lock list")] + [TestCase("worktree unlock list")] + [TestCase("worktree prune")] + [TestCase("worktree repair")] + [TestCase("worktree unknown list")] + [TestCase("git-worktree add list")] + [TestCase("write-tree")] + public void CommandsWithLockPreserveTheirPolicy(string command) + { + AssertLockPolicy(command, shouldLock: true); + } + + private static void AssertLockPolicy(string command, bool shouldLock) + { + foreach (string hook in new[] { "pre-command", "post-command" }) + { + string[] args = new[] { hook } + .Concat(command.Split(' ')) + .Concat(hook == "post-command" + ? new[] { "--git-pid=456", "--exit_code=0" } + : new[] { "--git-pid=456" }) + .ToArray(); + + GVFS.Hooks.Program.ShouldLock(args).ShouldEqual(shouldLock, string.Join(" ", args)); + } + } + } +} diff --git a/GVFS/GVFS.UnitTests/Mock/Common/MockNamedPipeClient.cs b/GVFS/GVFS.UnitTests/Mock/Common/MockNamedPipeClient.cs new file mode 100644 index 0000000000..dd2fad6410 --- /dev/null +++ b/GVFS/GVFS.UnitTests/Mock/Common/MockNamedPipeClient.cs @@ -0,0 +1,50 @@ +using GVFS.Common.NamedPipes; +using System; + +namespace GVFS.UnitTests.Mock.Common +{ + public class MockNamedPipeClient : NamedPipeClient + { + public MockNamedPipeClient() : base("mock-pipe") + { + } + + public Exception ConnectError { get; set; } + public Exception SendError { get; set; } + public Exception ReadError { get; set; } + public Func ReadResponseCallback { get; set; } + public NamedPipeMessages.Message Response { get; set; } + public bool Disposed { get; private set; } + public int ConnectTimeoutMs { get; private set; } + + public override bool Connect(int timeoutMilliseconds, out Exception error) + { + this.ConnectTimeoutMs = timeoutMilliseconds; + error = this.ConnectError; + return error == null; + } + + public override void SendRequest(NamedPipeMessages.Message message) + { + if (this.SendError != null) + { + throw this.SendError; + } + } + + public override NamedPipeMessages.Message ReadResponse() + { + if (this.ReadError != null) + { + throw this.ReadError; + } + + return this.ReadResponseCallback == null ? this.Response : this.ReadResponseCallback(); + } + + public override void Dispose() + { + this.Disposed = true; + } + } +} diff --git a/GVFS/GVFS.UnitTests/Mock/Common/MockTracer.cs b/GVFS/GVFS.UnitTests/Mock/Common/MockTracer.cs index 47ad66e295..5dba402b91 100644 --- a/GVFS/GVFS.UnitTests/Mock/Common/MockTracer.cs +++ b/GVFS/GVFS.UnitTests/Mock/Common/MockTracer.cs @@ -19,6 +19,8 @@ public MockTracer() this.RelatedEventNames = new List(); this.RelatedEventKeywords = new List(); this.RelatedEventMetadata = new List(); + this.StopMetadata = new List(); + this.TelemetryWarnings = new List(); } public MockTracer StartActivityTracer { get; private set; } @@ -34,6 +36,13 @@ public MockTracer() public List RelatedEventKeywords { get; } public List RelatedEventMetadata { get; } + // Metadata passed to each Stop() call, in order — lets tests assert on activity-summary + // fields (e.g. HydrateFilesStage's FailureSignatureCounts) that Stop would otherwise discard. + public List StopMetadata { get; } + + // Warnings reported with Keywords.Telemetry, which a telemetry listener would ship off the machine. + public List TelemetryWarnings { get; } + public void WaitForRelatedEvent() { this.waitEvent.WaitOne(); @@ -93,6 +102,10 @@ public void RelatedWarning(EventMetadata metadata, string message) public void RelatedWarning(EventMetadata metadata, string message, Keywords keyword) { this.RelatedWarning(metadata, message); + if (metadata != null && (keyword & Keywords.Telemetry) != 0) + { + this.TelemetryWarnings.Add(metadata); + } } public void RelatedWarning(string message) @@ -144,6 +157,7 @@ public ITracer StartActivity(string activityName, EventLevel level, Keywords sta public TimeSpan Stop(EventMetadata metadata) { + this.StopMetadata.Add(metadata); return TimeSpan.Zero; } diff --git a/GVFS/GVFS.UnitTests/Mock/FileSystem/MockPlatformFileSystem.cs b/GVFS/GVFS.UnitTests/Mock/FileSystem/MockPlatformFileSystem.cs index 9a01b7243b..836865abfb 100644 --- a/GVFS/GVFS.UnitTests/Mock/FileSystem/MockPlatformFileSystem.cs +++ b/GVFS/GVFS.UnitTests/Mock/FileSystem/MockPlatformFileSystem.cs @@ -35,8 +35,21 @@ public void SetDirectoryLastWriteTime(string path, DateTime lastWriteTime, out b throw new NotSupportedException(); } - public bool HydrateFile(string fileName, byte[] buffer) + /// + /// Optional override for HydrateFile, letting tests script hydration results (success/failure + /// per call) without needing a real platform file system. Defaults to null, preserving the + /// original unconditional-throw behavior for tests that don't exercise hydration. + /// + public Func HydrateFileImplementation { get; set; } + + public bool HydrateFile(string fileName, byte[] buffer, out Exception failure) { + if (this.HydrateFileImplementation != null) + { + (bool succeeded, failure) = this.HydrateFileImplementation(fileName, buffer); + return succeeded; + } + throw new NotSupportedException(); } diff --git a/GVFS/GVFS.UnitTests/Mock/Git/MockGitProcess.cs b/GVFS/GVFS.UnitTests/Mock/Git/MockGitProcess.cs index cf786a9835..1eeb7996ad 100644 --- a/GVFS/GVFS.UnitTests/Mock/Git/MockGitProcess.cs +++ b/GVFS/GVFS.UnitTests/Mock/Git/MockGitProcess.cs @@ -1,4 +1,5 @@ -using GVFS.Common.Git; +using GVFS.Common; +using GVFS.Common.Git; using GVFS.Common.Tracing; using GVFS.Tests.Should; using GVFS.UnitTests.Mock.Common; @@ -15,28 +16,33 @@ public class MockGitProcess : GitProcess private List expectedCommandInfos = new List(); public MockGitProcess() - : base(new MockGVFSEnlistment()) + : this(new MockGVFSEnlistment()) { - this.CommandsRun = new List(); - this.InvocationsRun = new List(); - this.StoredCredentials = new Dictionary(StringComparer.OrdinalIgnoreCase); - this.CredentialApprovals = new Dictionary>(); - this.CredentialRejections = new Dictionary>(); } - public List CommandsRun { get; } + public MockGitProcess(Enlistment enlistment) + : base(enlistment) + { + } + + public MockGitProcess(string gitBinPath, string workingDirectoryRoot) + : base(gitBinPath, workingDirectoryRoot) + { + } + + public List CommandsRun { get; } = new List(); /// - /// Records every call into in order, with the mayRequireAuth and - /// hasVisibleConsoleWindow values passed to it. Recording each invocation (rather than keeping only + /// Records every call into in order, with the mayRequireAuth, + /// hasVisibleConsoleWindow and --git-dir values passed to it. Recording each invocation (rather than keeping only /// the last value per command) keeps the wiring assertions honest if a command is ever run more than /// once with different flags. /// - public List InvocationsRun { get; } + public List InvocationsRun { get; } = new List(); public bool ShouldFail { get; set; } - public Dictionary StoredCredentials { get; } - public Dictionary> CredentialApprovals { get; } - public Dictionary> CredentialRejections { get; } + public Dictionary StoredCredentials { get; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + public Dictionary> CredentialApprovals { get; } = new Dictionary>(); + public Dictionary> CredentialRejections { get; } = new Dictionary>(); /// /// Returns the mayRequireAuth value the given command was invoked with, asserting the command ran and @@ -115,7 +121,7 @@ protected override Result InvokeGitImpl( bool? hasVisibleConsoleWindow = null) { this.CommandsRun.Add(command); - this.InvocationsRun.Add(new GitInvocation(command, mayRequireAuth, hasVisibleConsoleWindow)); + this.InvocationsRun.Add(new GitInvocation(command, dotGitDirectory, mayRequireAuth, hasVisibleConsoleWindow)); if (this.ShouldFail) { @@ -156,14 +162,18 @@ protected override Result InvokeGitImpl( public class GitInvocation { - public GitInvocation(string command, bool mayRequireAuth, bool? hasVisibleConsoleWindow) + public GitInvocation(string command, string dotGitDirectory, bool mayRequireAuth, bool? hasVisibleConsoleWindow) { this.Command = command; + this.DotGitDirectory = dotGitDirectory; this.MayRequireAuth = mayRequireAuth; this.HasVisibleConsoleWindow = hasVisibleConsoleWindow; } public string Command { get; } + + /// The --git-dir value passed to git, or null when none would be passed. + public string DotGitDirectory { get; } public bool MayRequireAuth { get; } public bool? HasVisibleConsoleWindow { get; } } diff --git a/GVFS/GVFS.UnitTests/Prefetch/HydrateFilesStageTests.cs b/GVFS/GVFS.UnitTests/Prefetch/HydrateFilesStageTests.cs new file mode 100644 index 0000000000..d2ceb38d1c --- /dev/null +++ b/GVFS/GVFS.UnitTests/Prefetch/HydrateFilesStageTests.cs @@ -0,0 +1,151 @@ +using GVFS.Common; +using GVFS.Common.Prefetch.Git; +using GVFS.Common.Prefetch.Pipeline; +using GVFS.Common.Tracing; +using GVFS.Tests.Should; +using GVFS.UnitTests.Mock.Common; +using GVFS.UnitTests.Mock.FileSystem; +using NUnit.Framework; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; + +namespace GVFS.UnitTests.Prefetch +{ + [TestFixture] + public class HydrateFilesStageTests + { + private const string WorkingDirectoryRoot = "mock:\\repo"; + + private static readonly int SharingViolationHResult = unchecked((int)0x80070000) | 32; + private static readonly int AccessDeniedHResult = unchecked((int)0x80070000) | 5; + + private MockPlatformFileSystem mockFileSystem; + + [SetUp] + public void SetUp() + { + // GVFSPlatform.Instance.FileSystem is a process-wide singleton (registered once by + // GVFS.UnitTests.Setup), so scripting it per-test and resetting afterward avoids + // leaking one test's hydration behavior into another. + this.mockFileSystem = GVFSPlatform.Instance.FileSystem as MockPlatformFileSystem; + this.mockFileSystem.ShouldNotBeNull(); + } + + [TearDown] + public void TearDown() + { + this.mockFileSystem.HydrateFileImplementation = null; + } + + [TestCase] + public void AllFilesHydrateSuccessfully() + { + this.mockFileSystem.HydrateFileImplementation = (path, buffer) => (true, null); + + MockTracer tracer = new MockTracer(); + HydrateFilesStage dut = this.CreateStage( + tracer, + out ConcurrentDictionary> blobIdToPaths, + out BlockingCollection availableBlobs); + + blobIdToPaths["sha1"] = new HashSet { new PathWithMode("file1.txt", 0) }; + blobIdToPaths["sha2"] = new HashSet { new PathWithMode("file2.txt", 0) }; + availableBlobs.Add("sha1"); + availableBlobs.Add("sha2"); + availableBlobs.CompleteAdding(); + + dut.Start(); + dut.WaitForCompletion(); + + dut.ReadFileCount.ShouldEqual(2); + dut.HasFailures.ShouldBeFalse(); + + MockTracer activity = tracer.StartActivityTracer; + activity.ShouldNotBeNull(); + activity.RelatedErrorEvents.Count.ShouldEqual(0); + + EventMetadataShouldNotContainKey(activity.StopMetadata[0], "FailureSignatureCounts"); + } + + [TestCase] + public void FailedHydrationsAreReportedPerFileAndAggregatedInStopSummary() + { + this.mockFileSystem.HydrateFileImplementation = (path, buffer) => + { + if (path.Contains("unauthorized")) + { + UnauthorizedAccessException ex = new UnauthorizedAccessException("denied"); + SetHResult(ex, AccessDeniedHResult); + return (false, ex); + } + + return (false, new IOException("sharing violation", SharingViolationHResult)); + }; + + MockTracer tracer = new MockTracer(); + HydrateFilesStage dut = this.CreateStage( + tracer, + out ConcurrentDictionary> blobIdToPaths, + out BlockingCollection availableBlobs); + + blobIdToPaths["sha1"] = new HashSet { new PathWithMode("locked1.txt", 0) }; + blobIdToPaths["sha2"] = new HashSet { new PathWithMode("locked2.txt", 0) }; + blobIdToPaths["sha3"] = new HashSet { new PathWithMode("unauthorized.txt", 0) }; + availableBlobs.Add("sha1"); + availableBlobs.Add("sha2"); + availableBlobs.Add("sha3"); + availableBlobs.CompleteAdding(); + + dut.Start(); + dut.WaitForCompletion(); + + dut.ReadFileCount.ShouldEqual(0); + dut.HasFailures.ShouldBeTrue(); + + MockTracer activity = tracer.StartActivityTracer; + activity.ShouldNotBeNull(); + + // One RelatedError per failed file — the original incident's core ask (per-file + // diagnostic identity) regardless of how the aggregate summary below is formatted. + activity.RelatedErrorEvents.Count.ShouldEqual(3); + + EventMetadata stopMetadata = activity.StopMetadata[0]; + stopMetadata["FilesRead"].ShouldEqual(0); + stopMetadata["Failures"].ShouldEqual(3); + + string signatureCounts = stopMetadata["FailureSignatureCounts"] as string; + signatureCounts.ShouldNotBeNull(); + signatureCounts.ShouldEqual("IOException(Win32=32)=2;UnauthorizedAccessException(Win32=5)=1"); + } + + private HydrateFilesStage CreateStage( + MockTracer tracer, + out ConcurrentDictionary> blobIdToPaths, + out BlockingCollection availableBlobs) + { + blobIdToPaths = new ConcurrentDictionary>(); + availableBlobs = new BlockingCollection(); + + return new HydrateFilesStage( + maxThreads: 1, + workingDirectoryRoot: WorkingDirectoryRoot, + blobIdToPaths: blobIdToPaths, + availableBlobs: availableBlobs, + tracer: tracer); + } + + private static void SetHResult(Exception exception, int hResult) + { + typeof(Exception) + .GetProperty(nameof(Exception.HResult)) + .SetValue(exception, hResult); + } + + private static void EventMetadataShouldNotContainKey(EventMetadata metadata, string key) + { + metadata.ContainsKey(key).ShouldBeFalse(); + } + } +} diff --git a/GVFS/GVFS.UnitTests/Tracing/TelemetryDaemonEventListenerTests.cs b/GVFS/GVFS.UnitTests/Tracing/TelemetryDaemonEventListenerTests.cs index f1deffe98f..3c9ced2ac8 100644 --- a/GVFS/GVFS.UnitTests/Tracing/TelemetryDaemonEventListenerTests.cs +++ b/GVFS/GVFS.UnitTests/Tracing/TelemetryDaemonEventListenerTests.cs @@ -4,12 +4,119 @@ using GVFS.Common.Tracing; using GVFS.Tests.Should; using NUnit.Framework; +using GVFS.Common; +using System; +using System.IO; +using System.IO.Pipes; +using System.Threading; +using System.Threading.Tasks; +using GVFS.CommandLine; +using GVFS.Common.FileSystem; +using GVFS.Common.NamedPipes; +using GVFS.UnitTests.Mock.Common; +using GVFS.UnitTests.Category; namespace GVFS.UnitTests.Tracing { [TestFixture] public class TelemetryDaemonEventListenerTests { + [TestCase(false)] + [TestCase(true)] + [Category(CategoryConstants.ExceptionExpected)] + public async Task HydrationMetadataReachesTheCollectorWithNullsAndNestedCounts(bool cacheHit) + { + string pipeName = "GVFS.UnitTests.Hydration." + Guid.NewGuid().ToString("N"); + using (CancellationTokenSource deadline = new CancellationTokenSource(TimeSpan.FromSeconds(10))) + using (NamedPipeServerStream server = new NamedPipeServerStream(pipeName, PipeDirection.In, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous)) + { + Task received = ReadTelemetryAsync(server, deadline.Token); + using (JsonTracer tracer = new JsonTracer("test", "health", disableTelemetry: true)) + using (StringWriter output = new StringWriter()) + { + tracer.AddEventListener(new TelemetryDaemonEventListener("test", "test-enlistment", "test-mount", pipeName, eventSink: null)); + CollectorHealthVerb verb = new CollectorHealthVerb { StatusOnly = true, CacheHit = cacheHit, Output = output }; + verb.ExecuteWithCompletion(null, tracer, Guid.NewGuid()); + } + + string[] lines = (await received).Split('\n', StringSplitOptions.RemoveEmptyEntries); + Assert.AreEqual(cacheHit ? 1 : 2, lines.Length); + TelemetryDaemonEventListener.PipeMessage health = TelemetryDaemonEventListener.PipeMessage.FromJson(lines.Last()); + Assert.AreEqual("health", health.EventName); + Assert.AreEqual("test-enlistment", health.Payload.EnlistmentId); + using (JsonDocument json = JsonDocument.Parse(health.Payload.Json)) + { + JsonElement root = json.RootElement; + Assert.AreEqual(cacheHit ? "Success" : "Failed", root.GetProperty("Outcome").GetString()); + if (cacheHit) + { + Assert.AreEqual(0, root.GetProperty("Cache").GetProperty("Counts").GetProperty("ModifiedFileCount").GetInt32()); + Assert.IsFalse(root.TryGetProperty("Summary", out _)); + } + else + { + JsonElement summary = root.GetProperty("Summary"); + Assert.AreEqual(100, summary.GetProperty("TotalFileCount").GetInt32()); + Assert.AreEqual(JsonValueKind.Null, summary.GetProperty("ModifiedFileCount").ValueKind); + Assert.AreEqual(JsonValueKind.Null, summary.GetProperty("TotalFolderCount").ValueKind); + Assert.AreEqual("Failed", summary.GetProperty("Outcome").GetString()); + TelemetryDaemonEventListener.PipeMessage duration = TelemetryDaemonEventListener.PipeMessage.FromJson(lines[0]); + Assert.AreEqual("HydrationSummaryDuration", duration.EventName); + using (JsonDocument calculation = JsonDocument.Parse(duration.Payload.Json)) + { + Assert.AreEqual(summary.GetRawText(), calculation.RootElement.GetRawText()); + Assert.AreEqual(root.GetProperty("CommandId").GetString(), summary.GetProperty("CommandId").GetString()); + } + } + } + + Assert.That(string.Join("\n", lines), Does.Not.Contain("private")); + } + } + + private sealed class CollectorHealthVerb : HealthVerb + { + public bool CacheHit { get; set; } + + internal override CacheLookupResult TryGetCachedHydrationStatus(GVFSEnlistment enlistment) + { + return ParseCachedResponse(this.CacheHit + ? new NamedPipeMessages.Message("S", "0,0,0,0,100,10") + : new NamedPipeMessages.Message("NA", null)); + } + + internal override EnlistmentHydrationSummary CreateHydrationSummary(GVFSEnlistment enlistment, ITracer tracer, HydrationSummaryDiagnostics diagnostics) + { + return EnlistmentHydrationSummary.CreateSummary(new MockGVFSEnlistment(), new HeaderFileSystem(), tracer, + () => 10, new FailingPlaceholderPaths(), diagnostics: diagnostics); + } + } + + private sealed class HeaderFileSystem : PhysicalFileSystem + { + public override Stream OpenFileStream(string path, FileMode fileMode, FileAccess fileAccess, FileShare shareMode, FileOptions options, bool flushesToDisk) + { + return new MemoryStream(new byte[] { 68, 73, 82, 67, 0, 0, 0, 4, 0, 0, 0, 100 }); + } + } + + private sealed class FailingPlaceholderPaths : EnlistmentPathData + { + public override void LoadPlaceholdersFromDatabase(GVFSEnlistment enlistment, HydrationSummaryDiagnostics diagnostics = null) + { + throw new IOException("mock:\\private\\database"); + } + } + + private static async Task ReadTelemetryAsync(NamedPipeServerStream server, CancellationToken cancellationToken) + { + await server.WaitForConnectionAsync(cancellationToken).ConfigureAwait(false); + using (StreamReader reader = new StreamReader(server)) + { + return await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); + } + } + [TestCase] public void TraceMessageDataIsCorrectFormat() { diff --git a/GVFS/GVFS.UnitTests/Virtualization/Background/BackgroundFileSystemTaskRunnerDisposeTests.cs b/GVFS/GVFS.UnitTests/Virtualization/Background/BackgroundFileSystemTaskRunnerDisposeTests.cs new file mode 100644 index 0000000000..abaa969d61 --- /dev/null +++ b/GVFS/GVFS.UnitTests/Virtualization/Background/BackgroundFileSystemTaskRunnerDisposeTests.cs @@ -0,0 +1,42 @@ +using GVFS.Virtualization.Background; +using NUnit.Framework; +using System.Threading; +using System.Threading.Tasks; + +namespace GVFS.UnitTests.Virtualization.Background +{ + [TestFixture] + public class BackgroundFileSystemTaskRunnerDisposeTests + { + [TestCase] + public void DisposeDoesNotThrowWhenBackgroundThreadIsStillRunning() + { + using (ManualResetEventSlim releaseBackgroundThread = new ManualResetEventSlim(false)) + { + Task backgroundThread = Task.Factory.StartNew( + () => releaseBackgroundThread.Wait(), + TaskCreationOptions.LongRunning); + + try + { + TestableBackgroundFileSystemTaskRunner runner = new TestableBackgroundFileSystemTaskRunner(); + runner.SetBackgroundThreadForTests(backgroundThread); + + // Dispose without Shutdown leaves the task running. Task.Dispose throws + // InvalidOperationException for a task that has not completed. + Assert.DoesNotThrow(() => runner.Dispose()); + } + finally + { + releaseBackgroundThread.Set(); + backgroundThread.Wait(); + backgroundThread.Dispose(); + } + } + } + + private class TestableBackgroundFileSystemTaskRunner : BackgroundFileSystemTaskRunner + { + } + } +} diff --git a/GVFS/GVFS.UnitTests/Virtualization/Projection/GitIndexProjectionDisposeTests.cs b/GVFS/GVFS.UnitTests/Virtualization/Projection/GitIndexProjectionDisposeTests.cs new file mode 100644 index 0000000000..7174df650b --- /dev/null +++ b/GVFS/GVFS.UnitTests/Virtualization/Projection/GitIndexProjectionDisposeTests.cs @@ -0,0 +1,42 @@ +using GVFS.Virtualization.Projection; +using NUnit.Framework; +using System.Threading; +using System.Threading.Tasks; + +namespace GVFS.UnitTests.Virtualization.Projection +{ + [TestFixture] + public class GitIndexProjectionDisposeTests + { + [TestCase] + public void DisposeDoesNotThrowWhenIndexParsingThreadIsStillRunning() + { + using (ManualResetEventSlim releaseParsingThread = new ManualResetEventSlim(false)) + { + Task parsingThread = Task.Factory.StartNew( + () => releaseParsingThread.Wait(), + TaskCreationOptions.LongRunning); + + try + { + TestableGitIndexProjection projection = new TestableGitIndexProjection(); + projection.SetIndexParsingThreadForTests(parsingThread); + + // Dispose without Shutdown leaves the task running. Task.Dispose throws + // InvalidOperationException for a task that has not completed. + Assert.DoesNotThrow(() => projection.Dispose()); + } + finally + { + releaseParsingThread.Set(); + parsingThread.Wait(); + parsingThread.Dispose(); + } + } + } + + private class TestableGitIndexProjection : GitIndexProjection + { + } + } +} diff --git a/GVFS/GVFS.UnitTests/Windows/Virtualization/ProjFsAttachRemedyTests.cs b/GVFS/GVFS.UnitTests/Windows/Virtualization/ProjFsAttachRemedyTests.cs new file mode 100644 index 0000000000..43f7078b8a --- /dev/null +++ b/GVFS/GVFS.UnitTests/Windows/Virtualization/ProjFsAttachRemedyTests.cs @@ -0,0 +1,45 @@ +using GVFS.Platform.Windows; +using GVFS.Tests.Should; +using NUnit.Framework; + +namespace GVFS.UnitTests.Windows.Virtualization +{ + [TestFixture] + public class ProjFsAttachRemedyTests + { + [TestCase] + public void NamesTheVolumeTheEnlistmentIsOn() + { + // Only the drive root is read from the path. Nothing touches the file system. + string remedy = WindowsFileSystemVirtualizer.BuildProjFsAttachRemedy("X:\\mock\\enlistment"); + + remedy.ShouldContain("fsutil devdrv query X:"); + remedy.ShouldContain("setFiltersAllowed /f /volume X:"); + remedy.ShouldContain("If X: is a Dev Drive"); + } + + [TestCase] + public void TellsTheUserToKeepTheFiltersThatAreAlreadyAllowed() + { + // setFiltersAllowed replaces the list, so advising only "PrjFlt" would drop + // filters the machine already requires. + string remedy = WindowsFileSystemVirtualizer.BuildProjFsAttachRemedy("X:\\mock\\enlistment"); + + remedy.ShouldContain("PrjFlt"); + remedy.ShouldContain(",PrjFlt"); + remedy.ShouldContain("replaces the list"); + } + + [TestCase] + public void FallsBackToAPlaceholderWhenThereIsNoVolumeToName() + { + foreach (string enlistmentRoot in new string[] { null, string.Empty }) + { + string remedy = WindowsFileSystemVirtualizer.BuildProjFsAttachRemedy(enlistmentRoot); + + remedy.ShouldContain("fsutil devdrv query "); + remedy.ShouldContain("setFiltersAllowed /f /volume "); + } + } + } +} diff --git a/GVFS/GVFS.UnitTests/Windows/WindowsFileSystemTests.cs b/GVFS/GVFS.UnitTests/Windows/WindowsFileSystemTests.cs new file mode 100644 index 0000000000..c109160364 --- /dev/null +++ b/GVFS/GVFS.UnitTests/Windows/WindowsFileSystemTests.cs @@ -0,0 +1,100 @@ +using GVFS.Platform.Windows; +using GVFS.Tests.Should; +using NUnit.Framework; +using System; +using System.IO; + +namespace GVFS.UnitTests.Windows +{ + [TestFixture] + public class WindowsFileSystemTests + { + private string tempDirectory; + + [SetUp] + public void SetUp() + { + this.tempDirectory = Path.Combine(Path.GetTempPath(), "GVFSWindowsFileSystemTests_" + Guid.NewGuid()); + Directory.CreateDirectory(this.tempDirectory); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(this.tempDirectory)) + { + Directory.Delete(this.tempDirectory, recursive: true); + } + } + + [TestCase] + public void HydrateFileSucceedsAndReportsNoFailure() + { + string filePath = Path.Combine(this.tempDirectory, "success.txt"); + File.WriteAllText(filePath, "contents"); + + WindowsFileSystem fileSystem = new WindowsFileSystem(); + bool succeeded = fileSystem.HydrateFile(filePath, new byte[1], out Exception failure); + + succeeded.ShouldBeTrue(); + failure.ShouldBeNull(); + } + + [TestCase] + public void HydrateFileCapturesIOExceptionOnSharingViolation() + { + string filePath = Path.Combine(this.tempDirectory, "locked.txt"); + File.WriteAllText(filePath, "contents"); + + WindowsFileSystem fileSystem = new WindowsFileSystem(); + + // Open the file exclusively so the FileStream opened inside HydrateFile + // (FileShare.ReadWrite | FileShare.Delete) hits a real sharing violation. + using (new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.None)) + { + bool succeeded = fileSystem.HydrateFile(filePath, new byte[1], out Exception failure); + + succeeded.ShouldBeFalse(); + failure.ShouldNotBeNull(); + failure.ShouldBeOfType(); + + // Pin the actual Win32-wrapped HResult (ERROR_SHARING_VIOLATION = 0x20) rather than + // just asserting non-zero — a regression that wraps a different/unwrapped error would + // otherwise pass this test while breaking HydrationFailureDiagnostics's derivation. + failure.HResult.ShouldEqual(unchecked((int)0x80070020)); + } + } + + [TestCase] + public void HydrateFileCapturesUnauthorizedAccessExceptionOnDirectory() + { + // FileStream refuses to open a directory as a file with UnauthorizedAccessException. + WindowsFileSystem fileSystem = new WindowsFileSystem(); + bool succeeded = fileSystem.HydrateFile(this.tempDirectory, new byte[1], out Exception failure); + + succeeded.ShouldBeFalse(); + failure.ShouldNotBeNull(); + failure.ShouldBeOfType(); + + // Pin the actual Win32-wrapped HResult (ERROR_ACCESS_DENIED = 0x5), same rationale as + // the sharing-violation case above. + failure.HResult.ShouldEqual(unchecked((int)0x80070005)); + } + + [TestCase] + public void HydrateFileCapturesFileNotFoundExceptionOnMissingFile() + { + string filePath = Path.Combine(this.tempDirectory, "missing.txt"); + + WindowsFileSystem fileSystem = new WindowsFileSystem(); + bool succeeded = fileSystem.HydrateFile(filePath, new byte[1], out Exception failure); + + succeeded.ShouldBeFalse(); + failure.ShouldNotBeNull(); + failure.ShouldBeOfType(); + + // ERROR_FILE_NOT_FOUND = 0x2 + failure.HResult.ShouldEqual(unchecked((int)0x80070002)); + } + } +} diff --git a/GVFS/GVFS.Virtualization/Background/BackgroundFileSystemTaskRunner.cs b/GVFS/GVFS.Virtualization/Background/BackgroundFileSystemTaskRunner.cs index 88ea897098..e520f83e9f 100644 --- a/GVFS/GVFS.Virtualization/Background/BackgroundFileSystemTaskRunner.cs +++ b/GVFS/GVFS.Virtualization/Background/BackgroundFileSystemTaskRunner.cs @@ -122,13 +122,24 @@ public void Dispose() GC.SuppressFinalize(this); } + /// + /// Test seam. Puts the runner into the "background thread is still running" state + /// that a failed mount produces, without needing a real repo. + /// + internal void SetBackgroundThreadForTests(Task backgroundTask) + { + this.backgroundThread = backgroundTask; + } + protected void Dispose(bool disposing) { if (disposing) { if (this.backgroundThread != null) { - this.backgroundThread.Dispose(); + // Dispose can run while this task is still running, and Task.Dispose + // throws unless the task has completed. A Task holds no unmanaged + // resources, so it is not disposed here. this.backgroundThread = null; } if (this.backgroundTasks != null) diff --git a/GVFS/GVFS.Virtualization/FileSystemCallbacks.cs b/GVFS/GVFS.Virtualization/FileSystemCallbacks.cs index c9fc1cd2a1..68358e8dd7 100644 --- a/GVFS/GVFS.Virtualization/FileSystemCallbacks.cs +++ b/GVFS/GVFS.Virtualization/FileSystemCallbacks.cs @@ -584,9 +584,9 @@ public void InvalidateGitStatusCache() } } - public EnlistmentHydrationSummary GetCachedHydrationSummary() + public EnlistmentHydrationSummary GetCachedHydrationSummary(out NamedPipeMessages.HydrationStatus.CacheInfo info) { - return this.gitStatusCache.GetCachedHydrationSummary(); + return this.gitStatusCache.GetCachedHydrationSummary(out info); } public int GetProjectedFolderCount() diff --git a/GVFS/GVFS.Virtualization/Projection/GitIndexProjection.cs b/GVFS/GVFS.Virtualization/Projection/GitIndexProjection.cs index 3b8301b125..0b4552796f 100644 --- a/GVFS/GVFS.Virtualization/Projection/GitIndexProjection.cs +++ b/GVFS/GVFS.Virtualization/Projection/GitIndexProjection.cs @@ -706,6 +706,15 @@ public void Dispose() GC.SuppressFinalize(this); } + /// + /// Test seam. Puts the projection into the "index parsing thread is still + /// running" state that a failed mount produces, without needing a real repo. + /// + internal void SetIndexParsingThreadForTests(Task indexParsingTask) + { + this.indexParsingThread = indexParsingTask; + } + protected virtual void Dispose(bool disposing) { if (disposing) @@ -730,7 +739,9 @@ protected virtual void Dispose(bool disposing) if (this.indexParsingThread != null) { - this.indexParsingThread.Dispose(); + // Dispose can run while this task is still running, and Task.Dispose + // throws unless the task has completed. A Task holds no unmanaged + // resources, so it is not disposed here. this.indexParsingThread = null; } } diff --git a/GVFS/GVFS/CommandLine/DehydrateVerb.cs b/GVFS/GVFS/CommandLine/DehydrateVerb.cs index 28ce303572..8bb9a8c986 100644 --- a/GVFS/GVFS/CommandLine/DehydrateVerb.cs +++ b/GVFS/GVFS/CommandLine/DehydrateVerb.cs @@ -99,6 +99,7 @@ protected override void Execute(GVFSEnlistment enlistment) { "Confirmed", this.Confirmed }, { "NoStatus", this.NoStatus }, { "Full", this.Full }, + { "IsWorktree", enlistment.IsWorktree }, { "NamedPipeName", enlistment.NamedPipeName }, { "Folders", this.Folders }, { nameof(this.EnlistmentRootPathParameter), this.EnlistmentRootPathParameter }, @@ -144,6 +145,8 @@ protected override void Execute(GVFSEnlistment enlistment) this.ReportErrorAndExit("Cannot combine --full with --folders."); } + this.ValidateFullDehydrate(tracer, enlistment); + if (!this.Confirmed && fullDehydrate) { this.Output.WriteLine( @@ -202,7 +205,7 @@ from a parent of the folders list. bool cleanStatus = this.StatusChecked || this.CheckGitStatus(tracer, enlistment, fullDehydrate); - string backupRoot = Path.GetFullPath(Path.Combine(enlistment.PrimaryEnlistmentRoot, "dehydrate_backup", DateTime.Now.ToString("yyyyMMdd_HHmmss"))); + string backupRoot = GetBackupRoot(enlistment, DateTime.Now); this.Output.WriteLine(); if (fullDehydrate) @@ -223,9 +226,10 @@ from a parent of the folders list. this.Unmount(tracer); string error; - if (!DiskLayoutUpgrade.TryCheckDiskLayoutVersion(tracer, enlistment.PrimaryEnlistmentRoot, out error)) + ReturnCode returnCode; + if (!DiskLayoutUpgrade.TryCheckDiskLayoutVersion(tracer, enlistment.PrimaryEnlistmentRoot, out error, out returnCode)) { - this.ReportErrorAndExit(tracer, error); + this.ReportErrorAndExit(tracer, returnCode, error); } RetryConfig retryConfig; @@ -280,7 +284,7 @@ private void DehydrateFolders(JsonTracer tracer, GVFSEnlistment enlistment, stri { if (!this.TryBackupNonSrcFiles(tracer, enlistment, backupRoot)) { - return; + this.ReportErrorAndExit(tracer, "Folder dehydrate failed because the backup failed."); } List foldersToDehydrate = new List(); @@ -482,7 +486,7 @@ private void RunFullDehydrate(JsonTracer tracer, GVFSEnlistment enlistment, stri tracer, skipVersionCheck: !this.NoStatus); - this.WriteMessage(tracer, "Dehydrate failed, but remounting succeeded"); + this.ReportErrorAndExit(tracer, "Dehydrate failed, but remounting succeeded"); } } @@ -628,19 +632,13 @@ private bool TryBackupNonSrcFiles(ITracer tracer, GVFSEnlistment enlistment, str // ... backup the .gvfs hydration-related data structures... string databasesFolder = Path.Combine(enlistment.DotGVFSRoot, GVFSConstants.DotGVFS.Databases.Name); - if (!this.TryCopyFilesInFolder(tracer, databasesFolder, backupDatabases, searchPattern: "*", filenamesToSkip: "RepoMetadata.dat")) + if (!this.TryCopyFilesInFolder(tracer, databasesFolder, backupDatabases, "*", out errorMessage, "RepoMetadata.dat")) { return false; } // ... backup everything related to the .git\index... - if (!this.TryIO( - tracer, - () => File.Copy( - Path.Combine(enlistment.DotGitRoot, GVFSConstants.DotGit.IndexName), - Path.Combine(backupGit, GVFSConstants.DotGit.IndexName)), - "Backup the git index", - out errorMessage) || + if (!this.TryBackupGitIndex(tracer, enlistment, backupGit, move: false, out errorMessage) || !this.TryIO( tracer, () => File.Copy( @@ -652,10 +650,9 @@ private bool TryBackupNonSrcFiles(ITracer tracer, GVFSEnlistment enlistment, str return false; } - // ... backup all .git\*.lock files - if (!this.TryCopyFilesInFolder(tracer, enlistment.DotGitRoot, backupGit, searchPattern: "*.lock")) + // ... backup all lock files for this working tree + if (!this.TryBackupGitLocks(tracer, enlistment, backupGit, move: false, out errorMessage)) { - errorMessage = "Failed to backup .git lock files."; return false; } @@ -715,19 +712,13 @@ private bool TryBackupFiles(ITracer tracer, GVFSEnlistment enlistment, string ba // ... backup the .gvfs hydration-related data structures... string databasesFolder = Path.Combine(enlistment.DotGVFSRoot, GVFSConstants.DotGVFS.Databases.Name); - if (!this.TryBackupFilesInFolder(tracer, databasesFolder, backupDatabases, searchPattern: "*", filenamesToSkip: "RepoMetadata.dat")) + if (!this.TryBackupFilesInFolder(tracer, databasesFolder, backupDatabases, "*", out errorMessage, "RepoMetadata.dat")) { return false; } // ... backup everything related to the .git\index... - if (!this.TryIO( - tracer, - () => File.Move( - Path.Combine(enlistment.DotGitRoot, GVFSConstants.DotGit.IndexName), - Path.Combine(backupGit, GVFSConstants.DotGit.IndexName)), - "Backup the git index", - out errorMessage) || + if (!this.TryBackupGitIndex(tracer, enlistment, backupGit, move: true, out errorMessage) || !this.TryIO( tracer, () => File.Move( @@ -739,8 +730,8 @@ private bool TryBackupFiles(ITracer tracer, GVFSEnlistment enlistment, string ba return false; } - // ... backup all .git\*.lock files - if (!this.TryBackupFilesInFolder(tracer, enlistment.DotGitRoot, backupGit, searchPattern: "*.lock")) + // ... backup all lock files for this working tree + if (!this.TryBackupGitLocks(tracer, enlistment, backupGit, move: true, out errorMessage)) { return false; } @@ -758,47 +749,152 @@ private bool TryBackupFiles(ITracer tracer, GVFSEnlistment enlistment, string ba return true; } - private bool TryBackupFilesInFolder(ITracer tracer, string folderPath, string backupPath, string searchPattern, params string[] filenamesToSkip) + internal bool TryBackupGitIndex( + ITracer tracer, + GVFSEnlistment enlistment, + string backupGit, + bool move, + out string errorMessage) { - string errorMessage; - foreach (string file in Directory.GetFiles(folderPath, searchPattern)) + string backupIndexPath = Path.Combine(backupGit, GVFSConstants.DotGit.IndexName); + Action backupIndex = move + ? () => File.Move(enlistment.GitIndexPath, backupIndexPath) + : () => File.Copy(enlistment.GitIndexPath, backupIndexPath); + + if (!this.TryIO(tracer, backupIndex, $"Back up {enlistment.GitIndexPath} to {backupIndexPath}", out errorMessage)) { - string fileName = Path.GetFileName(file); - if (!filenamesToSkip.Any(x => x.Equals(fileName, GVFSPlatform.Instance.Constants.PathComparison))) + errorMessage = $"Failed to back up '{enlistment.GitIndexPath}' to '{backupIndexPath}': {errorMessage}"; + return false; + } + + return true; + } + + internal void ValidateFullDehydrate(ITracer tracer, GVFSEnlistment enlistment) + { + if (!this.Full) + { + return; + } + + if (enlistment.IsWorktree) + { + this.ReportErrorAndExit( + tracer, + "Dehydrate --full is not supported for git worktrees. " + + "Use 'gvfs dehydrate --confirm --folders ' for folder dehydrate."); + } + + string cleanupAdvice = + "Before you remove worktrees, back up or push any work you need to keep. " + + "Use 'git worktree remove ' to remove a worktree. " + + "If its directory no longer exists, use 'git worktree prune' to remove stale registrations. " + + "If a stale registration is locked, use 'git worktree unlock ' before pruning."; + bool readSucceeded = GVFSEnlistment.TryGetKnownWorktreePaths( + enlistment.DotGitRoot, + out string[] worktreePaths, + out string error, + out Exception exception); + + if (!readSucceeded) + { + EventMetadata metadata = new EventMetadata { - if (!this.TryIO( - tracer, - () => File.Move(file, file.Replace(folderPath, backupPath)), - $"Backing up {Path.GetFileName(file)}", - out errorMessage)) - { - return false; - } + { "Error", error }, + { "DotGitRoot", enlistment.DotGitRoot }, + }; + if (exception != null) + { + metadata.Add("Exception", exception.ToString()); } + + tracer.RelatedError(metadata, "Cannot verify linked worktree registrations."); + this.ReportErrorAndExit( + "Cannot verify linked worktree registrations. Full dehydrate is blocked. " + error + Environment.NewLine + + "Check access to the registration path. Use 'git worktree list' to inspect registrations. " + cleanupAdvice); } - return true; + if (worktreePaths.Length > 0) + { + this.ReportErrorAndExit( + tracer, + "Dehydrate --full is not supported while linked worktrees exist." + Environment.NewLine + + string.Join(Environment.NewLine, worktreePaths) + Environment.NewLine + cleanupAdvice); + } } - private bool TryCopyFilesInFolder(ITracer tracer, string folderPath, string backupPath, string searchPattern, params string[] filenamesToSkip) + internal static string GetBackupRoot(GVFSEnlistment enlistment, DateTime timestamp) { - string errorMessage; - foreach (string file in Directory.GetFiles(folderPath, searchPattern)) + string backupParent = enlistment.PrimaryEnlistmentRoot; + string backupSubfolder = string.Empty; + if (enlistment.IsWorktree) + { + backupParent = Path.GetDirectoryName(Path.TrimEndingDirectorySeparator(enlistment.WorkingDirectoryBackingRoot)); + backupSubfolder = enlistment.Worktree.Name; + } + + return Path.GetFullPath( + Path.Combine( + backupParent, + "dehydrate_backup", + backupSubfolder, + timestamp.ToString("yyyyMMdd_HHmmss_fffffff"))); + } + + internal bool TryBackupGitLocks( + ITracer tracer, + GVFSEnlistment enlistment, + string backupGit, + bool move, + out string errorMessage) + { + string gitIndexDirectory = Path.GetDirectoryName(enlistment.GitIndexPath); + return move + ? this.TryBackupFilesInFolder(tracer, gitIndexDirectory, backupGit, "*.lock", out errorMessage) + : this.TryCopyFilesInFolder(tracer, gitIndexDirectory, backupGit, "*.lock", out errorMessage); + } + + private bool TryBackupFilesInFolder(ITracer tracer, string folderPath, string backupPath, string searchPattern, out string errorMessage, params string[] filenamesToSkip) + { + return this.TryTransferFilesInFolder(tracer, folderPath, backupPath, searchPattern, move: true, out errorMessage, filenamesToSkip); + } + + private bool TryCopyFilesInFolder(ITracer tracer, string folderPath, string backupPath, string searchPattern, out string errorMessage, params string[] filenamesToSkip) + { + return this.TryTransferFilesInFolder(tracer, folderPath, backupPath, searchPattern, move: false, out errorMessage, filenamesToSkip); + } + + private bool TryTransferFilesInFolder(ITracer tracer, string folderPath, string backupPath, string searchPattern, bool move, out string errorMessage, params string[] filenamesToSkip) + { + string[] files = null; + if (!this.TryIO(tracer, () => files = Directory.GetFiles(folderPath, searchPattern), $"Enumerate files in {folderPath}", out errorMessage)) + { + errorMessage = $"Failed to enumerate '{folderPath}': {errorMessage}"; + return false; + } + + foreach (string file in files) { string fileName = Path.GetFileName(file); if (!filenamesToSkip.Any(x => x.Equals(fileName, GVFSPlatform.Instance.Constants.PathComparison))) { + string destination = Path.Combine(backupPath, fileName); + Action transfer = move + ? () => File.Move(file, destination) + : () => File.Copy(file, destination); if (!this.TryIO( tracer, - () => File.Copy(file, file.Replace(folderPath, backupPath)), - $"Backing up {Path.GetFileName(file)}", + transfer, + $"Back up {file} to {destination}", out errorMessage)) { + errorMessage = $"Failed to back up '{file}' to '{destination}': {errorMessage}"; return false; } } } + errorMessage = null; return true; } @@ -906,7 +1002,8 @@ private bool TryIO(ITracer tracer, Action action, string description, out string new EventMetadata { { "Description", description }, - { "Error", error } + { "Error", error }, + { "Exception", e.ToString() } }, "TryIO: Caught exception performing action"); } diff --git a/GVFS/GVFS/CommandLine/HealthVerb.cs b/GVFS/GVFS/CommandLine/HealthVerb.cs index 8bb453ac9c..a6128dd8e3 100644 --- a/GVFS/GVFS/CommandLine/HealthVerb.cs +++ b/GVFS/GVFS/CommandLine/HealthVerb.cs @@ -4,8 +4,10 @@ using GVFS.Common.Tracing; using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Linq; +using System.Threading; using System.Threading.Tasks; namespace GVFS.CommandLine @@ -68,106 +70,283 @@ protected override void Execute(GVFSEnlistment enlistment) EventLevel.Informational, Keywords.Any); - if (this.StatusOnly) + Guid commandId = Guid.NewGuid(); + this.ExecuteWithCompletion(enlistment, tracer, commandId); + } + } + + internal void ExecuteWithCompletion(GVFSEnlistment enlistment, ITracer tracer, Guid commandId) + { + EventMetadata metadata = new EventMetadata + { + ["SchemaVersion"] = 1, + ["CommandId"] = commandId.ToString("N"), + ["Mode"] = this.StatusOnly ? "Status" : "Detailed", + ["Outcome"] = HealthCommandOutcome.Failed, + }; + try + { + this.ExecuteWithTracer(enlistment, tracer, metadata, commandId); + } + catch (Exception e) + { + metadata["Outcome"] = HealthCommandOutcome.Failed; + metadata["ExceptionType"] = e.GetType().FullName; + metadata["ExceptionHResult"] = e.HResult; + tracer.RelatedWarning(new EventMetadata { ["Exception"] = e.ToString() }, "Health command failed.", Keywords.None); + throw; + } + finally + { + tracer.Stop(metadata); + } + } + + internal virtual void ExecuteWithTracer(GVFSEnlistment enlistment, ITracer tracer, EventMetadata metadata, Guid commandId) + { + if (this.StatusOnly) + { + this.OutputHydrationPercent(enlistment, tracer, metadata, commandId); + return; + } + + // Now default to the current working directory when running the verb without a specified path + if (string.IsNullOrEmpty(this.Directory) || this.Directory.Equals(".")) + { + if (Environment.CurrentDirectory.StartsWith(enlistment.WorkingDirectoryRoot, GVFSPlatform.Instance.Constants.PathComparison)) { - this.OutputHydrationPercent(enlistment, tracer); - return; + this.Directory = Environment.CurrentDirectory.Substring(enlistment.WorkingDirectoryRoot.Length); } - - // Now default to the current working directory when running the verb without a specified path - if (string.IsNullOrEmpty(this.Directory) || this.Directory.Equals(".")) + else { - if (Environment.CurrentDirectory.StartsWith(enlistment.WorkingDirectoryRoot, GVFSPlatform.Instance.Constants.PathComparison)) - { - this.Directory = Environment.CurrentDirectory.Substring(enlistment.WorkingDirectoryRoot.Length); - } - else - { - // If the path is not under the source root, set the directory to empty - this.Directory = string.Empty; - } + // If the path is not under the source root, set the directory to empty + this.Directory = string.Empty; } + } - this.Output.WriteLine("\nGathering repository data..."); + this.Output.WriteLine("\nGathering repository data..."); - this.Directory = this.Directory.Replace(GVFSPlatform.GVFSPlatformConstants.PathSeparator, GVFSConstants.GitPathSeparator); + this.Directory = this.Directory.Replace(GVFSPlatform.GVFSPlatformConstants.PathSeparator, GVFSConstants.GitPathSeparator); - EnlistmentPathData pathData = new EnlistmentPathData(); + EnlistmentPathData pathData = new EnlistmentPathData(); - pathData.LoadPlaceholdersFromDatabase(enlistment); - pathData.LoadModifiedPaths(enlistment, tracer); - pathData.LoadPathsFromGitIndex(enlistment); + pathData.LoadPlaceholdersFromDatabase(enlistment); + pathData.LoadModifiedPaths(enlistment, tracer); + pathData.LoadPathsFromGitIndex(enlistment); - pathData.NormalizeAllPaths(); + pathData.NormalizeAllPaths(); - EnlistmentHealthCalculator enlistmentHealthCalculator = new EnlistmentHealthCalculator(pathData); - EnlistmentHealthData enlistmentHealthData = enlistmentHealthCalculator.CalculateStatistics(this.Directory); + EnlistmentHealthCalculator enlistmentHealthCalculator = new EnlistmentHealthCalculator(pathData); + EnlistmentHealthData enlistmentHealthData = enlistmentHealthCalculator.CalculateStatistics(this.Directory); - this.PrintOutput(enlistmentHealthData); - } + this.PrintOutput(enlistmentHealthData); + metadata["Outcome"] = HealthCommandOutcome.Success; } - private void OutputHydrationPercent(GVFSEnlistment enlistment, ITracer tracer) + internal void OutputHydrationPercent(GVFSEnlistment enlistment, ITracer tracer, EventMetadata metadata, Guid commandId) { - // Try cached summary from mount process first (fast path) - string cachedMessage = this.TryGetCachedHydrationMessage(enlistment); - if (cachedMessage != null) + CacheLookupResult cache = this.TryGetCachedHydrationStatus(enlistment); + metadata["Cache"] = cache.GetMetadata(); + if (cache.Exception != null) + { + tracer.RelatedWarning(new EventMetadata { ["Exception"] = cache.Exception.ToString() }, "Hydration cache lookup failed.", Keywords.None); + } + + if (cache.Response != null && cache.Outcome == CacheLookupOutcome.Success) { - this.Output.WriteLine(cachedMessage); + metadata["ResultSource"] = "Cache"; + metadata["DisplayOutcome"] = "Percentage"; + metadata["Outcome"] = HealthCommandOutcome.Success; + metadata["CalculationOutcome"] = cache.Info == null ? null + : cache.Info.CountsComplete ? HydrationSummaryOutcome.Success : HydrationSummaryOutcome.IncompleteData; + metadata["CalculationId"] = cache.Info?.CalculationId?.ToString("N"); + this.Output.WriteLine(cache.Response.ToDisplayMessage()); return; } - // Fall back to in-proc computation with index-based folder count - Func folderCountProvider = () => - GVFS.Virtualization.Projection.GitIndexProjection.CountIndexFolders(tracer, enlistment.GitIndexPath); - EnlistmentHydrationSummary summary = EnlistmentHydrationSummary.CreateSummary( - enlistment, this.FileSystem, tracer, folderCountProvider); + metadata["ResultSource"] = "Fallback"; + HydrationSummaryDiagnostics diagnostics = new HydrationSummaryDiagnostics + { + CommandId = commandId, + Caller = HydrationSummaryCaller.HealthFallback, + FolderCountSource = HydrationFolderCountSource.IndexParser, + }; + EnlistmentHydrationSummary summary = this.CreateHydrationSummary(enlistment, tracer, diagnostics); + metadata["Summary"] = diagnostics.GetMetadata(); + metadata["CalculationId"] = diagnostics.CalculationId.ToString("N"); + metadata["Outcome"] = summary.IsValid ? HealthCommandOutcome.Success : HealthCommandOutcome.Failed; + metadata["CalculationOutcome"] = diagnostics.Outcome; + metadata["DisplayOutcome"] = summary.IsValid ? "Percentage" : "Error"; this.Output.WriteLine(summary.ToMessage()); } - /// - /// Try to get the cached hydration summary from the mount process via named pipe. - /// Returns null if unavailable (GVFS not mounted, no cached value, parse error, timeout). - /// - private string TryGetCachedHydrationMessage(GVFSEnlistment enlistment) + internal virtual EnlistmentHydrationSummary CreateHydrationSummary(GVFSEnlistment enlistment, ITracer tracer, HydrationSummaryDiagnostics diagnostics) + { + return EnlistmentHydrationSummary.CreateSummary(enlistment, this.FileSystem, tracer, + () => GVFS.Virtualization.Projection.GitIndexProjection.CountIndexFolders(tracer, enlistment.GitIndexPath), + diagnostics: diagnostics); + } + + internal virtual CacheLookupResult TryGetCachedHydrationStatus(GVFSEnlistment enlistment) { const int ConnectTimeoutMs = 500; const int TotalTimeoutMs = 1000; - - try + Stopwatch stopwatch = Stopwatch.StartNew(); + int phase = (int)CacheLookupPhase.Connect; + Task task = Task.Run(() => { - Task task = Task.Run(() => + try { - using (NamedPipeClient pipeClient = new NamedPipeClient(enlistment.NamedPipeName)) + using (NamedPipeClient pipeClient = this.CreateHydrationPipeClient(enlistment)) { - if (!pipeClient.Connect(timeoutMilliseconds: ConnectTimeoutMs)) + if (!pipeClient.Connect(ConnectTimeoutMs, out Exception error)) { - return null; + return new CacheLookupResult + { + Outcome = error is TimeoutException ? CacheLookupOutcome.ConnectTimeout : CacheLookupOutcome.ConnectFailed, + Phase = CacheLookupPhase.Connect, + Exception = error, + }; } + Volatile.Write(ref phase, (int)CacheLookupPhase.Send); pipeClient.SendRequest(new NamedPipeMessages.Message(NamedPipeMessages.HydrationStatus.Request, null)); + Volatile.Write(ref phase, (int)CacheLookupPhase.Read); NamedPipeMessages.Message response = pipeClient.ReadResponse(); - - if (response.Header != NamedPipeMessages.HydrationStatus.SuccessResult - || !NamedPipeMessages.HydrationStatus.Response.TryParse(response.Body, out NamedPipeMessages.HydrationStatus.Response status)) - { - return null; - } - - return status.ToDisplayMessage(); + Volatile.Write(ref phase, (int)CacheLookupPhase.Parse); + return ParseCachedResponse(response); } - }); - - if (task.Wait(TotalTimeoutMs) && task.Status == TaskStatus.RanToCompletion) + } + catch (Exception ex) { - return task.Result; + return new CacheLookupResult { Outcome = CacheLookupOutcome.Failed, Phase = (CacheLookupPhase)Volatile.Read(ref phase), Exception = ex }; } + }); - return null; + CacheLookupResult result; + if (task.Wait(TotalTimeoutMs)) + { + result = task.Result; } - catch (Exception) + else { - return null; + // The worker owns the pipe and never writes to the caller's tracer or metadata after a timeout. + result = new CacheLookupResult { Outcome = CacheLookupOutcome.Timeout, Phase = (CacheLookupPhase)Volatile.Read(ref phase) }; + } + + result.DurationMs = stopwatch.ElapsedMilliseconds; + return result; + } + + internal virtual NamedPipeClient CreateHydrationPipeClient(GVFSEnlistment enlistment) + { + return new NamedPipeClient(enlistment.NamedPipeName); + } + + internal enum HealthCommandOutcome + { + Success, + Failed, + } + + internal static CacheLookupResult ParseCachedResponse(NamedPipeMessages.Message message) + { + CacheLookupResult result = new CacheLookupResult { Phase = CacheLookupPhase.Parse }; + if (message.Header == NamedPipeMessages.HydrationStatus.NotAvailableResult) + { + result.Outcome = CacheLookupOutcome.NotAvailable; + NamedPipeMessages.HydrationStatus.CacheInfo.TryParse(message.Body, out NamedPipeMessages.HydrationStatus.CacheInfo info); + result.Info = info; + result.DiagnosticsStatus = info != null ? CacheDiagnosticsStatus.Parsed + : string.IsNullOrEmpty(message.Body) ? CacheDiagnosticsStatus.Absent : CacheDiagnosticsStatus.Unrecognized; + return result; + } + + if (message.Header != NamedPipeMessages.HydrationStatus.SuccessResult) + { + result.Outcome = CacheLookupOutcome.UnexpectedResponse; + return result; + } + + result.Counts = new EventMetadata(); + string[] parts = message.Body?.Split(',') ?? Array.Empty(); + string[] names = { "PlaceholderFileCount", "PlaceholderFolderCount", "ModifiedFileCount", "ModifiedFolderCount", "TotalFileCount", "TotalFolderCount" }; + for (int i = 0; i < names.Length; i++) + { + result.Counts[names[i]] = i < parts.Length && int.TryParse(parts[i], out int count) ? count : null; + } + + bool valid = NamedPipeMessages.HydrationStatus.Response.TryParse(message.Body, out NamedPipeMessages.HydrationStatus.Response response, out NamedPipeMessages.HydrationStatus.ParseOutcome outcome); + result.Response = response; + result.ParseOutcome = outcome; + result.Outcome = valid ? CacheLookupOutcome.Success + : outcome == NamedPipeMessages.HydrationStatus.ParseOutcome.InvalidCounts ? CacheLookupOutcome.InvalidCounts : CacheLookupOutcome.MalformedResponse; + NamedPipeMessages.HydrationStatus.CacheInfo.TryParse(message.Body, out NamedPipeMessages.HydrationStatus.CacheInfo cacheInfo, hasCounts: true); + result.Info = cacheInfo; + result.DiagnosticsStatus = cacheInfo != null ? CacheDiagnosticsStatus.Parsed + : parts.Length <= 6 ? CacheDiagnosticsStatus.Absent : CacheDiagnosticsStatus.Unrecognized; + return result; + } + + internal enum CacheDiagnosticsStatus + { + Absent, + Parsed, + Unrecognized, + } + + internal enum CacheLookupOutcome + { + Success, + NotAvailable, + ConnectTimeout, + ConnectFailed, + Timeout, + UnexpectedResponse, + MalformedResponse, + InvalidCounts, + Failed, + } + + internal enum CacheLookupPhase + { + Connect, + Send, + Read, + Parse, + } + + internal sealed class CacheLookupResult + { + public CacheLookupOutcome Outcome { get; set; } + public CacheLookupPhase Phase { get; set; } + public long DurationMs { get; set; } + public Exception Exception { get; set; } + public NamedPipeMessages.HydrationStatus.Response Response { get; set; } + public NamedPipeMessages.HydrationStatus.ParseOutcome? ParseOutcome { get; set; } + public NamedPipeMessages.HydrationStatus.CacheInfo Info { get; set; } + public EventMetadata Counts { get; set; } + public CacheDiagnosticsStatus DiagnosticsStatus { get; set; } + + public EventMetadata GetMetadata() + { + return new EventMetadata + { + [nameof(this.Outcome)] = this.Outcome, + [nameof(this.Phase)] = this.Phase, + [nameof(this.DurationMs)] = this.DurationMs, + ["ExceptionType"] = this.Exception?.GetType().FullName, + ["ExceptionHResult"] = this.Exception?.HResult, + [nameof(this.ParseOutcome)] = this.ParseOutcome, + [nameof(this.Counts)] = this.Counts, + ["DiagnosticsAvailable"] = this.Info != null, + [nameof(this.DiagnosticsStatus)] = this.DiagnosticsStatus, + ["State"] = this.Info?.State, + ["CalculationId"] = this.Info?.CalculationId?.ToString("N"), + ["AgeMs"] = this.Info?.AgeMs, + ["Invalidated"] = this.Info?.Invalidated, + ["CountsComplete"] = this.Info?.CountsComplete, + }; } } diff --git a/GVFS/GVFS/CommandLine/MountVerb.cs b/GVFS/GVFS/CommandLine/MountVerb.cs index 5ea8c5781e..2c4ab26cbb 100644 --- a/GVFS/GVFS/CommandLine/MountVerb.cs +++ b/GVFS/GVFS/CommandLine/MountVerb.cs @@ -130,15 +130,31 @@ protected override void PreCreateEnlistment() } } - if (!DiskLayoutUpgrade.TryRunAllUpgrades(enlistmentRoot)) + string diskLayoutError; + ReturnCode diskLayoutReturnCode; + if (!DiskLayoutUpgrade.TryRunAllUpgrades(enlistmentRoot, out diskLayoutError, out diskLayoutReturnCode)) { - this.ReportErrorAndExit("Failed to upgrade repo disk layout. " + ConsoleHelper.GetGVFSLogMessage(enlistmentRoot)); + if (diskLayoutReturnCode == ReturnCode.MissingDiskLayoutVersion) + { + this.ReportErrorAndExit(tracer: null, exitCode: diskLayoutReturnCode, error: "Error: " + diskLayoutError); + } + else + { + string upgradeErrorMessage = "Failed to upgrade repo disk layout."; + if (!string.IsNullOrWhiteSpace(diskLayoutError)) + { + upgradeErrorMessage += " " + diskLayoutError; + } + + this.ReportErrorAndExit(upgradeErrorMessage + " " + ConsoleHelper.GetGVFSLogMessage(enlistmentRoot)); + } } string error; - if (!DiskLayoutUpgrade.TryCheckDiskLayoutVersion(tracer: null, enlistmentRoot: enlistmentRoot, error: out error)) + ReturnCode returnCode; + if (!DiskLayoutUpgrade.TryCheckDiskLayoutVersion(tracer: null, enlistmentRoot: enlistmentRoot, error: out error, returnCode: out returnCode)) { - this.ReportErrorAndExit("Error: " + error); + this.ReportErrorAndExit(tracer: null, exitCode: returnCode, error: "Error: " + error); } } diff --git a/GVFS/GVFS/CommandLine/PrefetchVerb.cs b/GVFS/GVFS/CommandLine/PrefetchVerb.cs index f82f4c0898..ee0ae862fb 100644 --- a/GVFS/GVFS/CommandLine/PrefetchVerb.cs +++ b/GVFS/GVFS/CommandLine/PrefetchVerb.cs @@ -773,13 +773,18 @@ private void HydrateMatchingFiles( new ParallelOptions { MaxDegreeOfParallelism = maxParallelism }, filePath => { - if (GVFSPlatform.Instance.FileSystem.HydrateFile(filePath, new byte[1])) + if (GVFSPlatform.Instance.FileSystem.HydrateFile(filePath, new byte[1], out Exception failure)) { Interlocked.Increment(ref hydratedCount); } else { - tracer.RelatedWarning("HydrateMatchingFiles: Failed to hydrate " + filePath); + EventMetadata metadata = HydrationFailureDiagnostics.BuildMetadata(filePath, failure); + + // RelatedWarning defaults to Keywords.None, which the telemetry pipe + // filters out — pass Keywords.Telemetry explicitly so this failure is + // actually visible, matching RelatedError's default elsewhere. + tracer.RelatedWarning(metadata, "HydrateMatchingFiles: Failed to hydrate " + filePath, Keywords.Telemetry); Interlocked.Increment(ref failedCount); } }); diff --git a/GVFS/GVFS/CommandLine/RepairVerb.cs b/GVFS/GVFS/CommandLine/RepairVerb.cs index dc0034adc0..8397656292 100644 --- a/GVFS/GVFS/CommandLine/RepairVerb.cs +++ b/GVFS/GVFS/CommandLine/RepairVerb.cs @@ -92,9 +92,10 @@ To actually execute any necessary repair(s), run 'gvfs repair --confirm' } string error; - if (!DiskLayoutUpgrade.TryCheckDiskLayoutVersion(tracer: null, enlistmentRoot: enlistment.PrimaryEnlistmentRoot, error: out error)) + ReturnCode returnCode; + if (!DiskLayoutUpgrade.TryCheckDiskLayoutVersion(tracer: null, enlistmentRoot: enlistment.PrimaryEnlistmentRoot, error: out error, returnCode: out returnCode)) { - this.ReportErrorAndExit(error); + this.ReportErrorAndExit(tracer: null, exitCode: returnCode, error: error); } if (!ConsoleHelper.ShowStatusWhileRunning( diff --git a/GVFS/GVFS/CommandLine/ServiceVerb.cs b/GVFS/GVFS/CommandLine/ServiceVerb.cs index 0741aa40e1..8f2b3e5100 100644 --- a/GVFS/GVFS/CommandLine/ServiceVerb.cs +++ b/GVFS/GVFS/CommandLine/ServiceVerb.cs @@ -216,12 +216,11 @@ private bool IsRepoAvailableToMount(string repoRoot) private bool IsRepoAvailableToUnmount(string repoRoot) { // For --unmount-all: only unmount a repo whose mount is in a state that accepts an - // unmount request. A repo already Unmounting is reaching the desired state on its own, - // and one still Mounting rejects the request, so both are skipped to avoid spurious - // "Already unmounting" / "not mounted" failures during concurrent teardown. + // unmount request. A repo already Unmounting or MountFailed is ending its process + // on its own, and one still Mounting rejects the request, so all three are skipped + // to avoid spurious failures during concurrent teardown. return this.TryGetRepoMountStatus(repoRoot, out string mountStatus) - && (mountStatus.Equals(NamedPipeMessages.GetStatus.Ready, StringComparison.Ordinal) - || mountStatus.Equals(NamedPipeMessages.GetStatus.MountFailed, StringComparison.Ordinal)); + && mountStatus.Equals(NamedPipeMessages.GetStatus.Ready, StringComparison.Ordinal); } private bool TryGetRepoMountStatus(string repoRoot, out string mountStatus) diff --git a/GVFS/GVFS/RepairJobs/GitIndexRepairJob.cs b/GVFS/GVFS/RepairJobs/GitIndexRepairJob.cs index 153634badc..14bca8d531 100644 --- a/GVFS/GVFS/RepairJobs/GitIndexRepairJob.cs +++ b/GVFS/GVFS/RepairJobs/GitIndexRepairJob.cs @@ -13,9 +13,11 @@ public class GitIndexRepairJob : RepairJob public GitIndexRepairJob(ITracer tracer, TextWriter output, GVFSEnlistment enlistment) : base(tracer, output, enlistment) { - this.indexPath = Path.Combine(this.Enlistment.DotGitRoot, GVFSConstants.DotGit.IndexName); + this.indexPath = enlistment.GitIndexPath; } + internal string IndexPath => this.indexPath; + public override string Name { get { return GVFSConstants.DotGit.Index; } diff --git a/docs/hydration-status-diagnostics.md b/docs/hydration-status-diagnostics.md new file mode 100644 index 0000000000..4423e459b5 --- /dev/null +++ b/docs/hydration-status-diagnostics.md @@ -0,0 +1,115 @@ +# Hydration status diagnostics + +`gvfs health --status` first requests a cached hydration summary from the mount process. +If that request fails, the command calculates a summary locally. +The diagnostic fields describe both attempts without a repository path or a raw pipe response. + +## Calculation result + +`HydrationSummaryDuration` contains one terminal record for each calculation. +It retains the existing duration fields and adds `SchemaVersion: 1`. +It records success, invalid counts, incomplete data, cancellation, and exceptions. +Success and cancellation use the Informational level. +Invalid counts, incomplete data, and exceptions use the Warning level. + +The six count fields contain numbers or JSON null. +A null value means that the calculation did not obtain that count. +A collected zero remains zero. +Partial counts remain available, but their source state is not `Complete`. + +`IndexState`, `PlaceholderState`, `ModifiedPathsState`, and `FolderCountState` describe source completeness. +`CountsComplete` requires all four states to equal `Complete`. +`IsValid` records the existing display calculation. +`ValidationFailures` independently records missing counts, negative counts, overflow, and hydrated counts that exceed their totals. +Its arithmetic uses 64-bit sums. + +`Phase` identifies the last started phase, which can already be complete when cancellation occurs between phases. +`CancellationRequested` distinguishes caller-requested cancellation from other cancellation exceptions. +Neither cancellation type increments the circuit breaker. +Each duration includes failed work in that phase. +An unattempted phase has a null duration. +`TreeCountMs` retains its existing name and measures the folder-count provider. +`FolderCountSource` identifies `Projection`, `IndexParser`, or `Unknown`. + +The index fields record its observed length, version, signature validity, and header-read result. +An unavailable index total produces `IncompleteData`, not an arithmetic inconsistency. +They do not prove that all sources came from one atomic repository snapshot. +The placeholder fields distinguish partial, expanded, and possible-tombstone folders. + +The modified-path fields describe the pipe attempt and the disk fallback separately. +The disk fields count add, delete, and other records. +These fields describe the existing reader. +They do not change its replay semantics or the recursive meaning of modified-folder entries. + +## Command result and cache + +The `health` completion record distinguishes `Status` mode from `Detailed` mode. +Status mode records the cache outcome, fallback decision, displayed result, and calculation identity. +Command `Outcome` uses `Success` when the command displays its result, or `Failed` when it reports an error. +`CalculationOutcome` describes the calculation separately. +Legacy cache responses leave `CalculationOutcome` unknown. +The nested `Cache` object retains parseable counts even when response validation fails. +Legacy mount responses have no cache diagnostics. +Their absent fields remain unknown. + +`CommandId` identifies one health invocation. +`CalculationId` identifies one calculation and also travels with the cache response. +These fields appear explicitly in the JSON payload. +Activity identifiers in the local tracer are not sufficient for telemetry correlation. + +The hydration pipe keeps its `S` and `NA` response headers. +An `S` response starts with the existing six comma-separated counts. +An optional extension follows those counts: + +```text +D1,state,calculationId,ageMs,invalidated,countsComplete +``` + +An `NA` response can contain this extension without counts. +The state integers are fixed wire values: + +| State | Integer | +| --- | --- | +| NotComputed | 0 | +| Available | 1 | +| ProviderUnavailable | 2 | +| FeatureDisabled | 3 | +| CircuitBreakerDisabled | 4 | +| InvalidCounts | 5 | +| IncompleteData | 6 | +| Cancelled | 7 | +| Failed | 8 | + +The calculation identifier uses the GUID `N` format, or `-` when unavailable. +The age is nonnegative, or `-1` when unavailable. +The last two fields use `0` or `1`. +Older clients ignore the extension. +New clients accept responses without it. + +The mount publishes the summary and its diagnostic identity together. +Cache state describes the latest refresh or skip decision. +A skipped refresh can retain an earlier valid summary. +`Invalidated` compares the calculation's invalidation generation with the current generation. +It does not reject a cached summary or change the existing cache policy. +Heartbeat telemetry aggregates hydration requests and unavailable responses. +`HydrationStatus.CurrentCacheState` describes the cache at the heartbeat, not the state of earlier requests. + +## Limits and privacy + +The health cache lookup retains its 500 ms connection timeout and 1000 ms outer wait. +The hook retains its existing timeout and does not create a telemetry process or listener. +A timed-out health worker can finish later. +It owns its pipe and cannot write to the command's tracer or completion record. +The modified-path reader retains its existing connection and response-wait behavior. + +Telemetry contains exception types and HRESULT values, not arbitrary exception messages or stacks. +Local warnings retain full exception details. +The structured result fields are identical in local logs and telemetry. +Raw details appear only in local warnings. + +Telemetry delivery remains best-effort. +The listener can drop events when its queue is full or its collector is unavailable. +Process termination can also prevent a terminal record. +Mount telemetry cannot observe a hook connection failure that never reaches the mount. + +Diagnostic collection must preserve count sets, disk-log replay, percentages, cache acceptance, and circuit-breaker policy. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 8b91ed346a..ea8bb4a80d 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1,6 +1,11 @@ Troubleshooting =============== +Hydration status +---------------- + +[Hydration status diagnostics](hydration-status-diagnostics.md) describes the structured results from `gvfs health --status` and the mount cache. + Deleting a VFS for Git repo ---------------------------