diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 440d753bf..1aaa7ba42 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -290,6 +290,13 @@ jobs: if: steps.skip.outputs.result != 'true' uses: microsoft/setup-msbuild@v3.0.0 + - name: Install vcpkg native dependencies + if: steps.skip.outputs.result != 'true' + shell: cmd + run: | + "%VCPKG_INSTALLATION_ROOT%\vcpkg.exe" install --triplet x64-windows-static-aot --x-install-root=out\vcpkg_installed\static --x-manifest-root=src || exit /b 1 + "%VCPKG_INSTALLATION_ROOT%\vcpkg.exe" install --triplet x64-windows-dynamic --x-install-root=out\vcpkg_installed\dynamic --x-manifest-root=src || exit /b 1 + - name: Build VFS for Git if: steps.skip.outputs.result != 'true' shell: cmd diff --git a/.github/workflows/functional-tests.yaml b/.github/workflows/functional-tests.yaml index 72c9ee503..046003d9f 100644 --- a/.github/workflows/functional-tests.yaml +++ b/.github/workflows/functional-tests.yaml @@ -77,7 +77,19 @@ jobs: return true - name: Download Git installer + id: download-git if: steps.skip.outputs.result != 'true' + continue-on-error: true + uses: actions/download-artifact@v8 + with: + name: ${{ inputs.git_artifact_name }} + path: git + repository: ${{ inputs.git_repository || github.repository }} + run-id: ${{ inputs.git_run_id || github.run_id }} + github-token: ${{ secrets.git_token || github.token }} + + - name: Download Git installer (retry) + if: steps.skip.outputs.result != 'true' && steps.download-git.outcome == 'failure' uses: actions/download-artifact@v8 with: name: ${{ inputs.git_artifact_name }} @@ -87,7 +99,19 @@ jobs: github-token: ${{ secrets.git_token || github.token }} - name: Download GVFS installer + id: download-gvfs if: steps.skip.outputs.result != 'true' + continue-on-error: true + uses: actions/download-artifact@v8 + with: + name: GVFS_${{ matrix.configuration }} + path: gvfs + repository: ${{ inputs.vfs_repository || github.repository }} + run-id: ${{ inputs.vfs_run_id || github.run_id }} + github-token: ${{ secrets.vfs_token || github.token }} + + - name: Download GVFS installer (retry) + if: steps.skip.outputs.result != 'true' && steps.download-gvfs.outcome == 'failure' uses: actions/download-artifact@v8 with: name: GVFS_${{ matrix.configuration }} @@ -97,7 +121,19 @@ jobs: github-token: ${{ secrets.vfs_token || github.token }} - name: Download functional tests drop + id: download-ft if: steps.skip.outputs.result != 'true' + continue-on-error: true + uses: actions/download-artifact@v8 + with: + name: FunctionalTests_${{ matrix.configuration }} + path: ft + repository: ${{ inputs.vfs_repository || github.repository }} + run-id: ${{ inputs.vfs_run_id || github.run_id }} + github-token: ${{ secrets.vfs_token || github.token }} + + - name: Download functional tests drop (retry) + if: steps.skip.outputs.result != 'true' && steps.download-ft.outcome == 'failure' uses: actions/download-artifact@v8 with: name: FunctionalTests_${{ matrix.configuration }} diff --git a/.github/workflows/upgrade-tests.yaml b/.github/workflows/upgrade-tests.yaml index 44eaaac94..74e34f7e9 100644 --- a/.github/workflows/upgrade-tests.yaml +++ b/.github/workflows/upgrade-tests.yaml @@ -33,6 +33,7 @@ jobs: - double-staging - staging-then-clean - mount-safety-deferral + - unmount-all-triggers-upgrade fail-fast: false steps: @@ -62,14 +63,32 @@ jobs: gh release download $tag --repo microsoft/VFSForGit --pattern "SetupGVFS*.exe" --dir gvfs-lkg - name: Download Git installer + id: download-git if: steps.skip.outputs.result != 'true' + continue-on-error: true + uses: actions/download-artifact@v8 + 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@v8 with: name: MicrosoftGit path: git - name: Download current GVFS installer + id: download-gvfs if: steps.skip.outputs.result != 'true' + continue-on-error: true + uses: actions/download-artifact@v8 + with: + name: GVFS_${{ matrix.configuration }} + path: gvfs-new + + - name: Download current GVFS installer (retry) + if: steps.skip.outputs.result != 'true' && steps.download-gvfs.outcome == 'failure' uses: actions/download-artifact@v8 with: name: GVFS_${{ matrix.configuration }} @@ -172,6 +191,36 @@ jobs: } } + function Assert-HookVersionsMatch { + Write-Host "Verifying hook binary versions in enlistment..." + $hooksDir = Join-Path $enlistment "src\.git\hooks" + + # Native hooks: each has its own source binary in the install directory + # Command hooks: copies of GitHooksLoader.exe + $hooks = @( + @{ Source = "GVFS.ReadObjectHook.exe"; Target = "read-object.exe" } + @{ Source = "GVFS.VirtualFileSystemHook.exe"; Target = "virtual-filesystem.exe" } + @{ Source = "GVFS.PostIndexChangedHook.exe"; Target = "post-index-change.exe" } + @{ Source = "GitHooksLoader.exe"; Target = "pre-command.exe" } + @{ Source = "GitHooksLoader.exe"; Target = "post-command.exe" } + ) + + foreach ($hook in $hooks) { + $sourcePath = Join-Path $installDir $hook.Source + $targetPath = Join-Path $hooksDir $hook.Target + if (-not (Test-Path $targetPath)) { + throw "Hook not found: $targetPath" + } + $sourceVer = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($sourcePath).FileVersion + $targetVer = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($targetPath).FileVersion + if ($sourceVer -ne $targetVer) { + throw "Hook version mismatch: $($hook.Target) is $targetVer, expected $sourceVer (from $($hook.Source))" + } + Write-Host " $($hook.Target): $targetVer OK" + } + Write-Host "All hook binary versions match" + } + # ============================================= # Test scenarios # ============================================= @@ -192,6 +241,11 @@ jobs: Unmount-TestRepo Restart-Service Assert-PendingUpgrade $false + + # Remount and verify all hooks were updated to new version + $null = Mount-TestRepo + Assert-HookVersionsMatch + Unmount-TestRepo Write-Host "PASS: Staging upgrade completed" } @@ -204,6 +258,11 @@ jobs: Install-GVFS $newInstaller @("/STAGEIFMOUNTED=false") Assert-PendingUpgrade $false Assert-ServiceRunning + + # Remount and verify all hooks were updated to new version + $null = Mount-TestRepo + Assert-HookVersionsMatch + Unmount-TestRepo Write-Host "PASS: Clean upgrade completed" } @@ -274,6 +333,36 @@ jobs: Write-Host "PASS: Mount safety deferral works correctly" } + "unmount-all-triggers-upgrade" { + Write-Host "=== Scenario: unmount-all triggers staged upgrade ===" + # Install LKG, mount, staging upgrade with new installer (which + # replaces GVFS.Service.exe in-place with the new version that + # includes PendingUpgradeMonitor). Then unmount via --unmount-all. + # The new service monitors mount process exits and applies the + # upgrade automatically — no pipe message from gvfs.exe needed. + Install-GVFS $lkgInstaller + Assert-ServiceRunning + $mountPid = Mount-TestRepo + + Install-GVFS $newInstaller @("/STAGEIFMOUNTED=true") + Assert-MountAlive $mountPid + Assert-PendingUpgrade $true + + # Unmount via --unmount-all (uses LKG gvfs.exe — no new pipe msg) + & "$installDir\gvfs.exe" service --unmount-all 2>&1 | Write-Host + if ($LASTEXITCODE -ne 0) { throw "unmount-all failed" } + + # The monitor's debounce timer fires 5s after the last mount + # process exits, then applies the upgrade. Wait for completion. + $deadline = (Get-Date).AddSeconds(30) + while ((Test-Path "$installDir\PendingUpgrade") -and (Get-Date) -lt $deadline) { + Start-Sleep -Seconds 2 + } + + Assert-PendingUpgrade $false + Write-Host "PASS: unmount-all triggers staged upgrade via process monitor" + } + default { throw "Unknown scenario: ${{ matrix.scenario }}" } diff --git a/.gitignore b/.gitignore index 7567b5ede..e2d06f87d 100644 --- a/.gitignore +++ b/.gitignore @@ -228,4 +228,5 @@ ModelManifest.xml .vscode/ # ProjFS Kext Unit Test coverage results -ProjFS.Mac/CoverageResult.txt \ No newline at end of file +ProjFS.Mac/CoverageResult.txt + diff --git a/Directory.Build.props b/Directory.Build.props index 16a5a8c68..89953442c 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -33,6 +33,74 @@ $(ProjectOutPath)obj\ + + + $(RepoOutPath)vcpkg_installed\static\x64-windows-static-aot\ + + + + + git2 + + + + + + + + + + + + + + + + + + + + + + PreserveNewest + PreserveNewest + git2.dll + + + PreserveNewest + PreserveNewest + pcre.dll + + + PreserveNewest + PreserveNewest + z.dll + + + x64 diff --git a/Directory.Build.targets b/Directory.Build.targets index 272ea11ec..d04b30b4f 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -12,6 +12,75 @@ + + + <_VcpkgManifestFile>$(RepoSrcPath)vcpkg.json + <_VcpkgConfigFile>$(RepoSrcPath)vcpkg-configuration.json + <_VcpkgStampFile>$(RepoOutPath)vcpkg_installed\.msbuildstamp + + + + <_VcpkgManifestInputs Include="$(_VcpkgManifestFile)" /> + <_VcpkgManifestInputs Include="$(_VcpkgConfigFile)" Condition="Exists('$(_VcpkgConfigFile)')" /> + + + + + + + <_VcpkgExe Condition="Exists('$(VCPKG_INSTALLATION_ROOT)\vcpkg.exe')">$(VCPKG_INSTALLATION_ROOT)\vcpkg.exe + <_VcpkgExe Condition="'$(_VcpkgExe)' == '' and Exists('$(VsInstallRoot)\VC\vcpkg\vcpkg.exe')">$(VsInstallRoot)\VC\vcpkg\vcpkg.exe + <_VcpkgManifestArg>--x-manifest-root="$(RepoSrcPath.TrimEnd('\'))" + + + + + + + + <_VcpkgExe>$(_VsInstallPath)\VC\vcpkg\vcpkg.exe + + + + + <_VcpkgExe>vcpkg + + + + + + + + + + + + + - + diff --git a/GVFS/FastFetch/CheckoutStage.cs b/GVFS/FastFetch/CheckoutStage.cs index 6874eff43..1012b29ff 100644 --- a/GVFS/FastFetch/CheckoutStage.cs +++ b/GVFS/FastFetch/CheckoutStage.cs @@ -173,13 +173,25 @@ private void HandleAllDirectoryOperations() case DiffTreeResult.Operations.Add: try { - Directory.CreateDirectory(absoluteTargetPath); + if (treeOp.SourcePath != null) + { + this.ApplyCaseOnlyDirectoryRename(treeOp, absoluteTargetPath); + } + else + { + Directory.CreateDirectory(absoluteTargetPath); + } } catch (Exception ex) { EventMetadata metadata = new EventMetadata(); - metadata.Add("Operation", "CreateDirectory"); + metadata.Add("Operation", treeOp.SourcePath != null ? "RenameDirectory" : "CreateDirectory"); metadata.Add(nameof(treeOp.TargetPath), absoluteTargetPath); + if (treeOp.SourcePath != null) + { + metadata.Add(nameof(treeOp.SourcePath), treeOp.SourcePath); + } + this.tracer.RelatedError(metadata, ex.Message); this.HasFailures = true; } @@ -222,6 +234,62 @@ private void HandleAllDirectoryOperations() } } + /// + /// Apply a case-only directory rename produced by DiffHelper, where + /// .SourcePath carries the old casing and + /// is the new (post-rename) absolute path. + /// + /// Directory.Move throws IOException for case-only renames on Windows, so the + /// rename is performed in two steps through a temporary name. If the second + /// move fails the directory is moved back to the original casing so a retry + /// sees a consistent working tree. + /// + /// If the source directory is missing it usually means an outer parent rename + /// has already moved the children into place (Windows preserves child casing + /// through a parent rename when the children's tree SHAs were unchanged); the + /// fallback creates the target directory so the operation is idempotent. + /// Exceptions propagate to the caller's existing error handler. + /// + private void ApplyCaseOnlyDirectoryRename(DiffTreeResult treeOp, string absoluteTargetPath) + { + string absoluteSourcePath = Path.Combine(this.enlistment.WorkingDirectoryBackingRoot, treeOp.SourcePath); + if (!Directory.Exists(absoluteSourcePath)) + { + Directory.CreateDirectory(absoluteTargetPath); + return; + } + + string trimmedSourcePath = absoluteSourcePath.TrimEnd(Path.DirectorySeparatorChar); + string trimmedTargetPath = absoluteTargetPath.TrimEnd(Path.DirectorySeparatorChar); + string tempPath = trimmedTargetPath + "_caseRename_" + Guid.NewGuid().ToString("N"); + + Directory.Move(trimmedSourcePath, tempPath); + try + { + Directory.Move(tempPath, trimmedTargetPath); + } + catch + { + // The first move succeeded but the second failed. Try to restore the + // original casing so a retry starts from a consistent state; if + // restoration also fails, the outer catch will log the original + // exception and the temp directory will be left behind for manual + // recovery. + if (Directory.Exists(tempPath) && !Directory.Exists(trimmedSourcePath)) + { + try + { + Directory.Move(tempPath, trimmedSourcePath); + } + catch + { + } + } + + throw; + } + } + private void HandleAllFileDeleteOperations() { string path; diff --git a/GVFS/GVFS.Common/Database/PlaceholderTable.cs b/GVFS/GVFS.Common/Database/PlaceholderTable.cs index 0813a1fb1..c0d98d293 100644 --- a/GVFS/GVFS.Common/Database/PlaceholderTable.cs +++ b/GVFS/GVFS.Common/Database/PlaceholderTable.cs @@ -2,16 +2,17 @@ using System.Collections.Generic; using System.Data; using System.IO; +using System.Threading; namespace GVFS.Common.Database { /// - /// This class is for interacting with the Placeholder table in the SQLite database + /// This class is for interacting with the Placeholder tablein the SQLite database /// public class PlaceholderTable : IPlaceholderCollection { private IGVFSConnectionPool connectionPool; - private object writerLock = new object(); + private Lock writerLock = new Lock(); public PlaceholderTable(IGVFSConnectionPool connectionPool) { diff --git a/GVFS/GVFS.Common/Database/SparseTable.cs b/GVFS/GVFS.Common/Database/SparseTable.cs index 5ead9baed..4a7f3db46 100644 --- a/GVFS/GVFS.Common/Database/SparseTable.cs +++ b/GVFS/GVFS.Common/Database/SparseTable.cs @@ -2,13 +2,14 @@ using System.Collections.Generic; using System.Data; using System.IO; +using System.Threading; namespace GVFS.Common.Database { public class SparseTable : ISparseCollection { private IGVFSConnectionPool connectionPool; - private object writerLock = new object(); + private Lock writerLock = new Lock(); public SparseTable(IGVFSConnectionPool connectionPool) { diff --git a/GVFS/GVFS.Common/FileBasedCollection.cs b/GVFS/GVFS.Common/FileBasedCollection.cs index 1956c086e..1ebe09b33 100644 --- a/GVFS/GVFS.Common/FileBasedCollection.cs +++ b/GVFS/GVFS.Common/FileBasedCollection.cs @@ -27,7 +27,7 @@ public abstract class FileBasedCollection : IDisposable /// private readonly bool collectionAppendsDirectlyToFile; - private readonly object fileLock = new object(); + private readonly Lock fileLock = new Lock(); private readonly PhysicalFileSystem fileSystem; private readonly string dataDirectoryPath; diff --git a/GVFS/GVFS.Common/FileSystem/HooksInstaller.cs b/GVFS/GVFS.Common/FileSystem/HooksInstaller.cs index 7ebd28eec..f7dfae0fc 100644 --- a/GVFS/GVFS.Common/FileSystem/HooksInstaller.cs +++ b/GVFS/GVFS.Common/FileSystem/HooksInstaller.cs @@ -103,6 +103,27 @@ public static bool TryUpdateHooks(GVFSContext context, out string errorMessage) } } + // Update the pre-command and post-command hook loaders (GitHooksLoader copies). + // These are deployed at clone time by InstallHooks but also need updating on + // mount so that upgrading GVFS and remounting refreshes all hooks. + string loaderSourcePath = Path.Combine(ExecutingDirectory, GVFSConstants.DotGit.Hooks.LoaderExecutable); + + string precommandHookPath = Path.Combine( + context.Enlistment.WorkingDirectoryBackingRoot, + GVFSConstants.DotGit.Hooks.PreCommandPath + GVFSPlatform.Instance.Constants.ExecutableExtension); + if (!TryUpdateHook(context, GVFSConstants.DotGit.Hooks.PreCommandHookName, loaderSourcePath, precommandHookPath, out errorMessage)) + { + return false; + } + + string postcommandHookPath = Path.Combine( + context.Enlistment.WorkingDirectoryBackingRoot, + GVFSConstants.DotGit.Hooks.PostCommandPath + GVFSPlatform.Instance.Constants.ExecutableExtension); + if (!TryUpdateHook(context, GVFSConstants.DotGit.Hooks.PostCommandHookName, loaderSourcePath, postcommandHookPath, out errorMessage)) + { + return false; + } + return true; } @@ -161,13 +182,23 @@ private static bool TryUpdateHook( HookData hook, out string errorMessage) { - bool copyHook = false; string enlistmentHookPath = Path.Combine(context.Enlistment.WorkingDirectoryBackingRoot, hook.Path + GVFSPlatform.Instance.Constants.ExecutableExtension); string installedHookPath = Path.Combine(ExecutingDirectory, hook.ExecutableName); + return TryUpdateHook(context, hook.Name, installedHookPath, enlistmentHookPath, out errorMessage); + } + + private static bool TryUpdateHook( + GVFSContext context, + string hookName, + string installedHookPath, + string enlistmentHookPath, + out string errorMessage) + { + bool copyHook = false; if (!context.FileSystem.FileExists(installedHookPath)) { - errorMessage = hook.ExecutableName + " cannot be found at " + installedHookPath; + errorMessage = Path.GetFileName(installedHookPath) + " cannot be found at " + installedHookPath; return false; } @@ -179,8 +210,8 @@ private static bool TryUpdateHook( metadata.Add("Area", "Mount"); metadata.Add(nameof(enlistmentHookPath), enlistmentHookPath); metadata.Add(nameof(installedHookPath), installedHookPath); - metadata.Add(TracingConstants.MessageKey.WarningMessage, hook.Name + " not found in enlistment, copying from installation folder"); - context.Tracer.RelatedWarning(hook.Name + " MissingFromEnlistment", metadata); + metadata.Add(TracingConstants.MessageKey.WarningMessage, hookName + " not found in enlistment, copying from installation folder"); + context.Tracer.RelatedWarning(hookName + " MissingFromEnlistment", metadata); } else { @@ -197,8 +228,8 @@ private static bool TryUpdateHook( metadata.Add(nameof(enlistmentHookPath), enlistmentHookPath); metadata.Add(nameof(installedHookPath), installedHookPath); metadata.Add("Exception", e.ToString()); - context.Tracer.RelatedError(metadata, "Failed to compare " + hook.Name + " version"); - errorMessage = "Error comparing " + hook.Name + " versions. " + ConsoleHelper.GetGVFSLogMessage(context.Enlistment.EnlistmentRoot); + context.Tracer.RelatedError(metadata, "Failed to compare " + hookName + " version"); + errorMessage = "Error comparing " + hookName + " versions. " + ConsoleHelper.GetGVFSLogMessage(context.Enlistment.EnlistmentRoot); return false; } } @@ -216,8 +247,8 @@ private static bool TryUpdateHook( metadata.Add(nameof(enlistmentHookPath), enlistmentHookPath); metadata.Add(nameof(installedHookPath), installedHookPath); metadata.Add("Exception", e.ToString()); - context.Tracer.RelatedError(metadata, "Failed to copy " + hook.Name + " to enlistment"); - errorMessage = "Error copying " + hook.Name + " to enlistment. " + ConsoleHelper.GetGVFSLogMessage(context.Enlistment.EnlistmentRoot); + context.Tracer.RelatedError(metadata, "Failed to copy " + hookName + " to enlistment"); + errorMessage = "Error copying " + hookName + " to enlistment. " + ConsoleHelper.GetGVFSLogMessage(context.Enlistment.EnlistmentRoot); return false; } } diff --git a/GVFS/GVFS.Common/GVFS.Common.csproj b/GVFS/GVFS.Common/GVFS.Common.csproj index 4005b2eca..f8d0c8a4e 100644 --- a/GVFS/GVFS.Common/GVFS.Common.csproj +++ b/GVFS/GVFS.Common/GVFS.Common.csproj @@ -5,7 +5,6 @@ - diff --git a/GVFS/GVFS.Common/GVFSLock.cs b/GVFS/GVFS.Common/GVFSLock.cs index b7324d74c..5bfaa5976 100644 --- a/GVFS/GVFS.Common/GVFSLock.cs +++ b/GVFS/GVFS.Common/GVFSLock.cs @@ -8,7 +8,7 @@ namespace GVFS.Common { public partial class GVFSLock { - private readonly object acquisitionLock = new object(); + private readonly Lock acquisitionLock = new Lock(); private readonly ITracer tracer; private readonly LockHolder currentLockHolder = new LockHolder(); diff --git a/GVFS/GVFS.Common/GVFSPlatform.cs b/GVFS/GVFS.Common/GVFSPlatform.cs index 6723177ba..579e94955 100644 --- a/GVFS/GVFS.Common/GVFSPlatform.cs +++ b/GVFS/GVFS.Common/GVFSPlatform.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.ComponentModel; +using System.Diagnostics; using System.IO; using System.IO.Pipes; @@ -51,7 +52,7 @@ public static void Register(GVFSPlatform platform) /// This method should only be called by processes whose code we own as the background process must /// do some extra work after it starts. /// - public abstract void StartBackgroundVFS4GProcess(ITracer tracer, string programName, string[] args); + public abstract Process StartBackgroundVFS4GProcess(ITracer tracer, string programName, string[] args); /// /// Adjusts the current process for running in the background. diff --git a/GVFS/GVFS.Common/Git/DiffTreeResult.cs b/GVFS/GVFS.Common/Git/DiffTreeResult.cs index 85eaaf559..abafa4597 100644 --- a/GVFS/GVFS.Common/Git/DiffTreeResult.cs +++ b/GVFS/GVFS.Common/Git/DiffTreeResult.cs @@ -38,6 +38,18 @@ public enum Operations public ushort SourceMode { get; set; } public ushort TargetMode { get; set; } + /// + /// Old-cased path of a case-only directory rename, set by DiffHelper when + /// collapsing a Delete+Add pair under the case-insensitive comparer. When + /// non-null the operation represents a rename from SourcePath to TargetPath + /// and consumers (currently CheckoutStage) must rename the directory on + /// disk instead of treating the operation as a plain Add. Always null for + /// file operations, Modify, Delete, and non-rename Add entries. The setter + /// is intentionally restricted to the assembly so only the parser can + /// produce this annotation. + /// + public string SourcePath { get; internal set; } + public static DiffTreeResult ParseFromDiffTreeLine(string line) { if (string.IsNullOrEmpty(line)) diff --git a/GVFS/GVFS.Common/Git/GitAuthentication.cs b/GVFS/GVFS.Common/Git/GitAuthentication.cs index fff37ad9a..faaafba07 100644 --- a/GVFS/GVFS.Common/Git/GitAuthentication.cs +++ b/GVFS/GVFS.Common/Git/GitAuthentication.cs @@ -6,6 +6,7 @@ using System.Net.Http; using System.Security.Cryptography.X509Certificates; using System.Text; +using System.Threading; namespace GVFS.Common.Git { @@ -13,7 +14,7 @@ public class GitAuthentication { private const double MaxBackoffSeconds = 30; - private readonly object gitAuthLock = new object(); + private readonly Lock gitAuthLock = new Lock(); private readonly ICredentialStore credentialStore; private readonly string repoUrl; @@ -199,7 +200,8 @@ public bool TryInitialize(ITracer tracer, Enlistment enlistment, out string erro enlistment, new RetryConfig(), out _, - out errorMessage); + out errorMessage, + out _); } /// @@ -216,7 +218,8 @@ public bool TryInitializeAndQueryGVFSConfig( Enlistment enlistment, RetryConfig retryConfig, out ServerGVFSConfig serverGVFSConfig, - out string errorMessage) + out string errorMessage, + out bool isAuthFailure) { if (this.isInitialized) { @@ -225,6 +228,7 @@ public bool TryInitializeAndQueryGVFSConfig( serverGVFSConfig = null; errorMessage = null; + isAuthFailure = false; using (ConfigHttpRequestor configRequestor = new ConfigHttpRequestor(tracer, enlistment, retryConfig)) { @@ -252,6 +256,7 @@ public bool TryInitializeAndQueryGVFSConfig( if (!this.TryCallGitCredential(tracer, out errorMessage)) { + isAuthFailure = true; tracer.RelatedWarning("{0}: Credential fetch failed: {1}", nameof(this.TryInitializeAndQueryGVFSConfig), errorMessage); return false; } @@ -259,12 +264,18 @@ public bool TryInitializeAndQueryGVFSConfig( this.isInitialized = true; // Retry with credentials using the same ConfigHttpRequestor (reuses HttpClient/connection) - if (configRequestor.TryQueryGVFSConfig(true, out serverGVFSConfig, out _, out errorMessage)) + HttpStatusCode? retryHttpStatus; + if (configRequestor.TryQueryGVFSConfig(true, out serverGVFSConfig, out retryHttpStatus, out errorMessage)) { tracer.RelatedInfo("{0}: Config obtained with credentials", nameof(this.TryInitializeAndQueryGVFSConfig)); return true; } + if (retryHttpStatus == HttpStatusCode.Unauthorized || retryHttpStatus == HttpStatusCode.Forbidden) + { + isAuthFailure = true; + } + tracer.RelatedWarning("{0}: Config query failed with credentials: {1}", nameof(this.TryInitializeAndQueryGVFSConfig), errorMessage); return false; } diff --git a/GVFS/GVFS.Common/Git/GitProcess.cs b/GVFS/GVFS.Common/Git/GitProcess.cs index a6ac3c748..caca4df64 100644 --- a/GVFS/GVFS.Common/Git/GitProcess.cs +++ b/GVFS/GVFS.Common/Git/GitProcess.cs @@ -7,6 +7,7 @@ using System.IO; using System.Linq; using System.Text; +using System.Threading; namespace GVFS.Common.Git { @@ -21,14 +22,14 @@ public class GitProcess : ICredentialStore /// /// Lock taken for duration of running executingProcess. /// - private object executionLock = new object(); + private Lock executionLock = new Lock(); /// /// Lock taken when changing the running state of executingProcess. /// /// Can be taken within executionLock. /// - private object processLock = new object(); + private Lock processLock = new Lock(); private string gitBinPath; private string workingDirectoryRoot; diff --git a/GVFS/GVFS.Common/Git/LibGit2Repo.cs b/GVFS/GVFS.Common/Git/LibGit2Repo.cs index 60e69450d..f0e7bc464 100644 --- a/GVFS/GVFS.Common/Git/LibGit2Repo.cs +++ b/GVFS/GVFS.Common/Git/LibGit2Repo.cs @@ -483,7 +483,7 @@ public static string GetLastError() return Marshal.PtrToStructure(ptr).Message; } - [DllImport(Git2NativeLibName, EntryPoint = "giterr_last")] + [DllImport(Git2NativeLibName, EntryPoint = "git_error_last")] private static extern IntPtr GetLastGitError(); [StructLayout(LayoutKind.Sequential)] diff --git a/GVFS/GVFS.Common/Git/LibGit2RepoInvoker.cs b/GVFS/GVFS.Common/Git/LibGit2RepoInvoker.cs index 44b084049..f3fdf83b3 100644 --- a/GVFS/GVFS.Common/Git/LibGit2RepoInvoker.cs +++ b/GVFS/GVFS.Common/Git/LibGit2RepoInvoker.cs @@ -8,7 +8,7 @@ public class LibGit2RepoInvoker : IDisposable { private readonly Func createRepo; private readonly ITracer tracer; - private readonly object sharedRepoLock = new object(); + private readonly Lock sharedRepoLock = new Lock(); private volatile bool disposing; private volatile int activeCallers; private LibGit2Repo sharedRepo; diff --git a/GVFS/GVFS.Common/GitStatusCache.cs b/GVFS/GVFS.Common/GitStatusCache.cs index efe13a49d..7323ec209 100644 --- a/GVFS/GVFS.Common/GitStatusCache.cs +++ b/GVFS/GVFS.Common/GitStatusCache.cs @@ -56,7 +56,7 @@ public class GitStatusCache : IDisposable private volatile CacheState cacheState = CacheState.Dirty; - private object cacheFileLock = new object(); + private Lock cacheFileLock = new Lock(); internal static bool? TEST_EnableHydrationSummaryOverride = null; @@ -597,7 +597,7 @@ private bool TryRebuildStatusCache() private bool TryDeleteStatusCacheFile() { - Debug.Assert(Monitor.IsEntered(this.cacheFileLock), "Attempting to delete the git status cache file without the cacheFileLock"); + Debug.Assert(this.cacheFileLock.IsHeldByCurrentThread, "Attempting to delete the git status cache file without the cacheFileLock"); try { @@ -635,7 +635,7 @@ private bool TryDeleteStatusCacheFile() /// True on success, False on failure private bool MoveCacheFileToFinalLocation(string tmpStatusFilePath) { - Debug.Assert(Monitor.IsEntered(this.cacheFileLock), "Attempting to update the git status cache file without the cacheFileLock"); + Debug.Assert(this.cacheFileLock.IsHeldByCurrentThread, "Attempting to update the git status cache file without the cacheFileLock"); try { diff --git a/GVFS/GVFS.Common/Maintenance/GitMaintenanceQueue.cs b/GVFS/GVFS.Common/Maintenance/GitMaintenanceQueue.cs index 5ecfa63ff..26d2f8431 100644 --- a/GVFS/GVFS.Common/Maintenance/GitMaintenanceQueue.cs +++ b/GVFS/GVFS.Common/Maintenance/GitMaintenanceQueue.cs @@ -8,7 +8,7 @@ namespace GVFS.Common.Maintenance { public class GitMaintenanceQueue { - private readonly object queueLock = new object(); + private readonly Lock queueLock = new Lock(); private GVFSContext context; private BlockingCollection queue = new BlockingCollection(); private GitMaintenanceStep currentStep; diff --git a/GVFS/GVFS.Common/Maintenance/GitMaintenanceStep.cs b/GVFS/GVFS.Common/Maintenance/GitMaintenanceStep.cs index 29bb3e9e4..ae14ab4ce 100644 --- a/GVFS/GVFS.Common/Maintenance/GitMaintenanceStep.cs +++ b/GVFS/GVFS.Common/Maintenance/GitMaintenanceStep.cs @@ -4,13 +4,14 @@ using System; using System.Collections.Generic; using System.IO; +using System.Threading; namespace GVFS.Common.Maintenance { public abstract class GitMaintenanceStep { public const string ObjectCacheLock = "git-maintenance-step.lock"; - private readonly object gitProcessLock = new object(); + private readonly Lock gitProcessLock = new Lock(); public GitMaintenanceStep(GVFSContext context, bool requireObjectCacheLock, GitProcessChecker gitProcessChecker = null) { diff --git a/GVFS/GVFS.Common/MissingTreeTracker.cs b/GVFS/GVFS.Common/MissingTreeTracker.cs index 3d5ca78a1..fe17c9890 100644 --- a/GVFS/GVFS.Common/MissingTreeTracker.cs +++ b/GVFS/GVFS.Common/MissingTreeTracker.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; using GVFS.Common.Tracing; namespace GVFS.Common @@ -16,7 +17,7 @@ public class MissingTreeTracker private readonly int treeCapacity; private readonly ITracer tracer; - private readonly object syncLock = new object(); + private readonly Lock syncLock = new Lock(); // Primary storage: commit -> set of missing trees private readonly Dictionary> missingTreesByCommit; diff --git a/GVFS/GVFS.Common/NativeMethods.cs b/GVFS/GVFS.Common/NativeMethods.cs index 01282679e..1fae6876d 100644 --- a/GVFS/GVFS.Common/NativeMethods.cs +++ b/GVFS/GVFS.Common/NativeMethods.cs @@ -247,6 +247,19 @@ private static extern bool DeviceIoControl( [DllImport("kernel32.dll")] private static extern ulong GetTickCount64(); + [DllImport("kernel32.dll")] + private static extern int WTSGetActiveConsoleSessionId(); + + /// + /// Returns the session ID of the physical console session, or -1 if + /// no interactive session is active (e.g. at boot before logon). + /// + public static int GetActiveConsoleSessionId() + { + int sessionId = WTSGetActiveConsoleSessionId(); + return sessionId == unchecked((int)0xFFFFFFFF) ? -1 : sessionId; + } + [DllImport("kernel32.dll", SetLastError = true)] private static extern bool SetFileTime( SafeFileHandle hFile, diff --git a/GVFS/GVFS.Common/Prefetch/Git/DiffHelper.cs b/GVFS/GVFS.Common/Prefetch/Git/DiffHelper.cs index f55d47d5f..386e4c214 100644 --- a/GVFS/GVFS.Common/Prefetch/Git/DiffHelper.cs +++ b/GVFS/GVFS.Common/Prefetch/Git/DiffHelper.cs @@ -16,13 +16,28 @@ public class DiffHelper private HashSet exactFileList; private List patternList; private List folderList; - private HashSet filesAdded = new HashSet(GVFSPlatform.Instance.Constants.PathComparer); - - private HashSet stagedDirectoryOperations = new HashSet(new DiffTreeByNameComparer()); - private HashSet stagedFileDeletes = new HashSet(GVFSPlatform.Instance.Constants.PathComparer); + // The staged collections are keyed by the case-insensitive PathComparer on + // case-insensitive platforms so that two paths differing only in case map to + // the same entry. The dictionary value stores the original casing of the + // first path inserted, which case-rename detection compares against the + // incoming path to decide whether the collision is a rename or a true + // duplicate. Dictionary lookups keep this O(1); a HashSet would force a + // linear scan to recover the stored casing. + private Dictionary filesAdded = new Dictionary(GVFSPlatform.Instance.Constants.PathComparer); + + private Dictionary stagedDirectoryOperations = new Dictionary(GVFSPlatform.Instance.Constants.PathComparer); + private Dictionary stagedFileDeletes = new Dictionary(GVFSPlatform.Instance.Constants.PathComparer); + + // Holds the old-cased paths of directories whose Delete was collapsed into an + // Add via case-only rename detection. FlushStagedQueues consults this set to + // suppress child operations (file deletes and child directory Adds) whose + // parent was case-renamed — those children are carried by the parent rename + // on disk and do not need separate operations. + private HashSet directoriesReplacedByCaseRename = new HashSet(GVFSPlatform.Instance.Constants.PathComparer); private Enlistment enlistment; private GitProcess git; + private bool diffPerformed; public DiffHelper(ITracer tracer, Enlistment enlistment, IEnumerable fileList, IEnumerable folderList, bool includeSymLinks) : this(tracer, enlistment, new GitProcess(enlistment), fileList, folderList, includeSymLinks) @@ -93,6 +108,7 @@ public void PerformDiff(string targetCommitSha) public void PerformDiff(string sourceTreeSha, string targetTreeSha) { + this.EnsureSingleUse(); EventMetadata metadata = new EventMetadata(); metadata.Add("TargetTreeSha", targetTreeSha); metadata.Add("HeadTreeSha", sourceTreeSha); @@ -150,6 +166,7 @@ public void PerformDiff(string sourceTreeSha, string targetTreeSha) public void ParseDiffFile(string filename) { + this.EnsureSingleUse(); using (ITracer activity = this.tracer.StartActivity("PerformDiff", EventLevel.Informational)) { using (StreamReader file = new StreamReader(File.OpenRead(filename))) @@ -170,22 +187,33 @@ private void FlushStagedQueues() { HashSet deletedDirectories = new HashSet( - this.stagedDirectoryOperations + this.stagedDirectoryOperations.Values .Where(d => d.Operation == DiffTreeResult.Operations.Delete) .Select(d => d.TargetPath.TrimEnd(Path.DirectorySeparatorChar)), GVFSPlatform.Instance.Constants.PathComparer); - foreach (DiffTreeResult result in this.stagedDirectoryOperations) + // Also include directories that were deleted as part of case-only renames. + // These were replaced by Adds in stagedDirectoryOperations but their children's + // file deletes should still be filtered out (the parent rename handles them). + deletedDirectories.UnionWith(this.directoriesReplacedByCaseRename); + + foreach (DiffTreeResult result in this.stagedDirectoryOperations.Values) { string parentPath = Path.GetDirectoryName(result.TargetPath.TrimEnd(Path.DirectorySeparatorChar)); if (deletedDirectories.Contains(parentPath)) { if (result.Operation != DiffTreeResult.Operations.Delete) { - EventMetadata metadata = new EventMetadata(); - metadata.Add(nameof(result.TargetPath), result.TargetPath); - metadata.Add(TracingConstants.MessageKey.WarningMessage, "An operation is intended to go inside of a deleted folder"); - activity.RelatedError("InvalidOperation", metadata); + // For case renames, child directory Adds inside a case-renamed parent + // are expected. The parent rename will handle moving the children. + // Only warn if the parent is truly deleted (not case-renamed). + if (!this.directoriesReplacedByCaseRename.Contains(parentPath)) + { + EventMetadata metadata = new EventMetadata(); + metadata.Add(nameof(result.TargetPath), result.TargetPath); + metadata.Add(TracingConstants.MessageKey.WarningMessage, "An operation is intended to go inside of a deleted folder"); + activity.RelatedError("InvalidOperation", metadata); + } } } else @@ -194,7 +222,7 @@ private void FlushStagedQueues() } } - foreach (string filePath in this.stagedFileDeletes) + foreach (string filePath in this.stagedFileDeletes.Values) { string parentPath = Path.GetDirectoryName(filePath); if (!deletedDirectories.Contains(parentPath)) @@ -222,16 +250,16 @@ private void EnqueueOperationsFromLsTreeLine(ITracer activity, string line) if (result.TargetIsDirectory) { - if (!this.stagedDirectoryOperations.Add(result)) + if (!this.stagedDirectoryOperations.TryAdd(result.TargetPath, result)) { EventMetadata metadata = new EventMetadata(); metadata.Add(nameof(result.TargetPath), result.TargetPath); metadata.Add(TracingConstants.MessageKey.WarningMessage, "File exists in tree with two different cases. Taking the last one."); this.tracer.RelatedEvent(EventLevel.Warning, "CaseConflict", metadata); - // Since we match only on filename, re-adding is the easiest way to update the set. - this.stagedDirectoryOperations.Remove(result); - this.stagedDirectoryOperations.Add(result); + // Two entries in the same tree differ only in case. Keep the + // last one parsed, matching the historical HashSet behavior. + this.stagedDirectoryOperations[result.TargetPath] = result; } } else @@ -274,27 +302,52 @@ private void EnqueueOperationsFromDiffTreeLine(ITracer activity, string line) switch (result.Operation) { case DiffTreeResult.Operations.Delete: - if (!this.stagedDirectoryOperations.Add(result)) + if (!this.stagedDirectoryOperations.TryAdd(result.TargetPath, result)) { - EventMetadata metadata = new EventMetadata(); - metadata.Add(nameof(result.TargetPath), result.TargetPath); - metadata.Add(TracingConstants.MessageKey.WarningMessage, "A case change was attempted. It will not be reflected in the working directory."); - activity.RelatedEvent(EventLevel.Warning, "CaseConflict", metadata); + // A directory with the same (case-insensitive) path was already + // staged as an Add. This is a case-only rename where diff-tree + // emitted the Add before the Delete. Either emit order is possible + // because git diff-tree compares tree entries by byte order, so + // whichever casing sorts lower appears first. + // + // Annotate the staged Add with the old-cased path so CheckoutStage + // can perform the rename. Keep the Add — never the Delete — to + // avoid deleting a folder out from under ourselves. + DiffTreeResult existingOp = this.stagedDirectoryOperations[result.TargetPath]; + if (!existingOp.TargetPath.Equals(result.TargetPath, StringComparison.Ordinal)) + { + existingOp.SourcePath = result.TargetPath; + this.directoriesReplacedByCaseRename.Add(result.TargetPath.TrimEnd(Path.DirectorySeparatorChar)); + TraceCaseRename(activity, "Directory", oldPath: result.TargetPath, newPath: existingOp.TargetPath); + } + else + { + TraceDuplicate(activity, "Directory", "Delete", result.TargetPath); + } } break; case DiffTreeResult.Operations.Add: case DiffTreeResult.Operations.Modify: - if (!this.stagedDirectoryOperations.Add(result)) + if (!this.stagedDirectoryOperations.TryAdd(result.TargetPath, result)) { - EventMetadata metadata = new EventMetadata(); - metadata.Add(nameof(result.TargetPath), result.TargetPath); - metadata.Add(TracingConstants.MessageKey.WarningMessage, "A case change was attempted. It will not be reflected in the working directory."); - activity.RelatedEvent(EventLevel.Warning, "CaseConflict", metadata); + // A directory with the same path (case-insensitive) was already staged. + // This is a case-only rename: the Delete was staged first, now the Add arrives. + DiffTreeResult existingOp = this.stagedDirectoryOperations[result.TargetPath]; + if (!existingOp.TargetPath.Equals(result.TargetPath, StringComparison.Ordinal)) + { + // Case-only rename: store the old-cased path so CheckoutStage can rename the directory + result.SourcePath = existingOp.TargetPath; + this.directoriesReplacedByCaseRename.Add(existingOp.TargetPath.TrimEnd(Path.DirectorySeparatorChar)); + TraceCaseRename(activity, "Directory", oldPath: existingOp.TargetPath, newPath: result.TargetPath); + } + else + { + TraceDuplicate(activity, "Directory", result.Operation.ToString(), result.TargetPath); + } // Replace the delete with the add to make sure we don't delete a folder from under ourselves - this.stagedDirectoryOperations.Remove(result); - this.stagedDirectoryOperations.Add(result); + this.stagedDirectoryOperations[result.TargetPath] = result; } break; @@ -357,17 +410,24 @@ private bool ShouldIncludeResult(DiffTreeResult blobAdd) private void EnqueueFileDeleteOperation(ITracer activity, string targetPath) { - if (this.filesAdded.Contains(targetPath)) + // Use case-sensitive check: if the exact same path (same casing) was already added, + // this is a true duplicate, not a case rename. Skip it. + // But if it matches case-insensitively only, this is a case rename — allow the delete through + // so the old-cased file is removed before the new-cased file is written. + if (this.filesAdded.TryGetValue(targetPath, out string existingAddedPath)) { - EventMetadata metadata = new EventMetadata(); - metadata.Add(nameof(targetPath), targetPath); - metadata.Add(TracingConstants.MessageKey.WarningMessage, "A case change was attempted. It will not be reflected in the working directory."); - activity.RelatedEvent(EventLevel.Warning, "CaseConflict", metadata); + if (existingAddedPath.Equals(targetPath, StringComparison.Ordinal)) + { + TraceDuplicate(activity, "File", "Delete", targetPath); + return; + } - return; + TraceCaseRename(activity, "File", oldPath: targetPath, newPath: existingAddedPath); } - this.stagedFileDeletes.Add(targetPath); + // Either no prior add, or a case-only difference: allow the delete to be + // staged so the old casing is removed from disk before the new add lands. + this.stagedFileDeletes.TryAdd(targetPath, targetPath); } /// @@ -377,7 +437,7 @@ private void EnqueueFileAddOperation(ITracer activity, DiffTreeResult operation) { // Each filepath should be unique according to GVFSPlatform.Instance.Constants.PathComparer. // If there are duplicates, only the last parsed one should remain. - if (!this.filesAdded.Add(operation.TargetPath)) + if (!this.filesAdded.TryAdd(operation.TargetPath, operation.TargetPath)) { foreach (KeyValuePair> kvp in this.FileAddOperations) { @@ -389,12 +449,21 @@ private void EnqueueFileAddOperation(ITracer activity, DiffTreeResult operation) } } - if (this.stagedFileDeletes.Remove(operation.TargetPath)) + // If a delete is already staged for the same path under the case-insensitive + // comparer, decide whether this is a true duplicate (same casing → drop the + // delete) or a case-only rename (different casing → keep the delete so the + // old casing is removed from disk before the new add lands). + if (this.stagedFileDeletes.TryGetValue(operation.TargetPath, out string existingDeletePath)) { - EventMetadata metadata = new EventMetadata(); - metadata.Add(nameof(operation.TargetPath), operation.TargetPath); - metadata.Add(TracingConstants.MessageKey.WarningMessage, "A case change was attempted. It will not be reflected in the working directory."); - activity.RelatedEvent(EventLevel.Warning, "CaseConflict", metadata); + if (existingDeletePath.Equals(operation.TargetPath, StringComparison.Ordinal)) + { + TraceDuplicate(activity, "File", "Add", operation.TargetPath); + this.stagedFileDeletes.Remove(operation.TargetPath); + } + else + { + TraceCaseRename(activity, "File", oldPath: existingDeletePath, newPath: operation.TargetPath); + } } this.FileAddOperations.AddOrUpdate( @@ -409,31 +478,42 @@ private void EnqueueFileAddOperation(ITracer activity, DiffTreeResult operation) this.RequiredBlobs.Add(operation.TargetSha); } - private class DiffTreeByNameComparer : IEqualityComparer + private static void TraceCaseRename(ITracer activity, string kind, string oldPath, string newPath) { - public bool Equals(DiffTreeResult x, DiffTreeResult y) - { - if (x.TargetPath != null) - { - if (y.TargetPath != null) - { - return x.TargetPath.Equals(y.TargetPath, GVFSPlatform.Instance.Constants.PathComparison); - } + EventMetadata metadata = new EventMetadata(); + metadata.Add("Kind", kind); + metadata.Add("OldPath", oldPath); + metadata.Add("NewPath", newPath); + activity.RelatedEvent(EventLevel.Informational, "CaseRename", metadata); + } - return false; - } - else - { - // both null means they're equal - return y.TargetPath == null; - } - } + private static void TraceDuplicate(ITracer activity, string kind, string operation, string targetPath) + { + EventMetadata metadata = new EventMetadata(); + metadata.Add("Kind", kind); + metadata.Add("Operation", operation); + metadata.Add(nameof(targetPath), targetPath); + metadata.Add(TracingConstants.MessageKey.WarningMessage, "Duplicate diff entry for the same path; later occurrence collapsed into earlier."); + activity.RelatedEvent(EventLevel.Warning, "DuplicateDiffEntry", metadata); + } - public int GetHashCode(DiffTreeResult obj) + private void EnsureSingleUse() + { + // The output collections — DirectoryOperations, FileDeleteOperations, + // FileAddOperations, RequiredBlobs — are populated incrementally and + // RequiredBlobs.CompleteAdding() is called at the end of FlushStagedQueues. + // A second call would attempt to add to a completed BlockingCollection + // and throw deep in the parsing path, leaving partial output. The class + // is therefore intended to be single-use; instantiate a new DiffHelper + // for each diff. + if (this.diffPerformed) { - return obj.TargetPath != null ? - GVFSPlatform.Instance.Constants.PathComparer.GetHashCode(obj.TargetPath) : 0; + throw new InvalidOperationException( + $"{nameof(DiffHelper)} has already produced a diff and cannot be reused. Construct a new instance."); } + + this.diffPerformed = true; } + } } diff --git a/GVFS/GVFS.Common/ReturnCode.cs b/GVFS/GVFS.Common/ReturnCode.cs index 5243cb2f5..09396a861 100644 --- a/GVFS/GVFS.Common/ReturnCode.cs +++ b/GVFS/GVFS.Common/ReturnCode.cs @@ -11,5 +11,6 @@ public enum ReturnCode UnableToRegisterForOfflineIO = 6, DehydrateFolderFailures = 7, MountAlreadyRunning = 8, + AuthenticationError = 9, } } diff --git a/GVFS/GVFS.Common/Tracing/BufferingTelemetryListener.cs b/GVFS/GVFS.Common/Tracing/BufferingTelemetryListener.cs new file mode 100644 index 000000000..58af567ee --- /dev/null +++ b/GVFS/GVFS.Common/Tracing/BufferingTelemetryListener.cs @@ -0,0 +1,83 @@ +using System.Collections.Concurrent; + +namespace GVFS.Common.Tracing +{ + /// + /// An EventListener that buffers telemetry messages in memory. After + /// a real listener is attached via , buffered + /// messages are replayed and this listener becomes a no-op. + /// + public class BufferingTelemetryListener : EventListener + { + public const int DefaultMaxBufferedMessages = 1000; + + private ConcurrentQueue buffer = new ConcurrentQueue(); + private readonly int maxBufferedMessages; + private volatile bool stopped; + + public BufferingTelemetryListener(int maxBufferedMessages = DefaultMaxBufferedMessages) + : base(EventLevel.Verbose, Keywords.Telemetry, eventSink: null) + { + this.maxBufferedMessages = maxBufferedMessages; + } + + /// + /// Number of messages currently buffered. + /// + public int BufferedCount => this.buffer?.Count ?? 0; + + /// + /// Whether this listener has been stopped (replay completed). + /// + public bool IsStopped => this.stopped; + + /// + /// Replays all buffered messages to and + /// stops further buffering. This listener remains in the tracer's + /// listener list but becomes a no-op. Safe to call multiple times; + /// only the first call replays. + /// + /// Number of messages replayed. + public int ReplayAndStop(EventListener target) + { + if (this.stopped) + { + return 0; + } + + this.stopped = true; + ConcurrentQueue queue = this.buffer; + this.buffer = null; + + int count = 0; + if (queue != null) + { + while (queue.TryDequeue(out TraceEventMessage message)) + { + target.RecordMessage(message); + count++; + } + } + + return count; + } + + protected override void RecordMessageInternal(TraceEventMessage message) + { + if (this.stopped) + { + return; + } + + // Soft cap: under high concurrency, a few messages may exceed + // maxBufferedMessages because Count and Enqueue are not atomic. + // This is acceptable — the cap prevents unbounded growth, and + // a small overshoot is harmless. + ConcurrentQueue queue = this.buffer; + if (queue != null && queue.Count < this.maxBufferedMessages) + { + queue.Enqueue(message); + } + } + } +} diff --git a/GVFS/GVFS.Common/Tracing/DeferredTelemetryAttacher.cs b/GVFS/GVFS.Common/Tracing/DeferredTelemetryAttacher.cs new file mode 100644 index 000000000..1dc68a072 --- /dev/null +++ b/GVFS/GVFS.Common/Tracing/DeferredTelemetryAttacher.cs @@ -0,0 +1,208 @@ +using System; +using System.Threading; + +namespace GVFS.Common.Tracing +{ + /// + /// Manages deferred telemetry pipe attachment for processes that cannot + /// read the pipe config at startup (e.g. GVFS.Service running as SYSTEM, + /// or any process started before the telemetry collector is installed). + /// + /// Adds a to the tracer at + /// construction time, then periodically retries creating a real + /// . On success, buffered + /// messages are replayed and the retry timer stops. + /// + /// Callers can also trigger an explicit attach attempt via + /// — e.g. on session logon when the + /// user's HOME is available. + /// + /// Designed for reuse by both GVFS.Service and GVFS.Mount. + /// + public class DeferredTelemetryAttacher : IDisposable + { + private readonly JsonTracer tracer; + private readonly BufferingTelemetryListener buffer; + private readonly string providerName; + private readonly string enlistmentId; + private readonly string mountId; + private readonly Lock attachLock = new Lock(); + + private Timer retryTimer; + private string retryGitBinRoot; + private int retryCount; + private bool attached; + private bool disposed; + + public DeferredTelemetryAttacher( + JsonTracer tracer, + string providerName, + string enlistmentId, + string mountId) + { + this.tracer = tracer; + this.providerName = providerName; + this.enlistmentId = enlistmentId; + this.mountId = mountId; + this.buffer = new BufferingTelemetryListener(); + tracer.AddEventListener(this.buffer); + } + + public bool IsAttached + { + get + { + lock (this.attachLock) + { + return this.attached; + } + } + } + + /// + /// Starts a background retry timer that periodically calls + /// with the given gitBinRoot. Uses + /// exponential backoff: 10s, 30s, 1m, then 5m steady state. + /// + public void StartRetryTimer(string gitBinRoot) + { + lock (this.attachLock) + { + if (this.attached || this.disposed || this.retryTimer != null) + { + return; + } + + this.retryGitBinRoot = gitBinRoot; + this.retryCount = 0; + this.retryTimer = new Timer( + this.OnRetryTimer, + null, + GetRetryInterval(0), + Timeout.Infinite); + } + } + + /// + /// Attempts to create and attach a TelemetryDaemonEventListener. + /// Call this when environment conditions change (e.g. user session + /// becomes available). Replays buffered messages on success. + /// Safe to call multiple times — no-ops after first successful attach. + /// + /// Path to git binary. + /// + /// If non-null, reads this file with git config --file instead + /// of --global. Use this when the caller needs to read another + /// user's .gitconfig without mutating the process-wide HOME variable. + /// + /// true if attached (now or previously). + public bool TryAttach(string gitBinRoot, string globalConfigPath = null) + { + lock (this.attachLock) + { + if (this.attached || this.tracer.HasTelemetryDaemonListener) + { + return true; + } + + if (string.IsNullOrEmpty(gitBinRoot)) + { + return false; + } + + TelemetryDaemonEventListener daemonListener; + try + { + daemonListener = TelemetryDaemonEventListener.CreateIfEnabled( + gitBinRoot, + this.providerName, + this.enlistmentId, + this.mountId, + this.tracer, + globalConfigPath); + } + catch (Exception) + { + return false; + } + + if (daemonListener == null) + { + return false; + } + + this.tracer.AddEventListener(daemonListener); + int replayed = this.buffer.ReplayAndStop(daemonListener); + this.StopRetryTimer(); + this.attached = true; + + this.tracer.RelatedInfo( + "DeferredTelemetryAttacher: Attached, replayed {0} buffered messages", + replayed); + + return true; + } + } + + public void Dispose() + { + lock (this.attachLock) + { + if (this.disposed) + { + return; + } + + this.disposed = true; + this.StopRetryTimer(); + } + } + + internal static int GetRetryInterval(int retryCount) + { + return retryCount switch + { + 0 => 10_000, // 10 seconds + 1 => 30_000, // 30 seconds + 2 => 60_000, // 1 minute + _ => 300_000, // 5 minutes + }; + } + + private void StopRetryTimer() + { + // Must be called while holding attachLock + if (this.retryTimer != null) + { + this.retryTimer.Dispose(); + this.retryTimer = null; + } + } + + private void OnRetryTimer(object state) + { + try + { + bool success = this.TryAttach(this.retryGitBinRoot); + if (!success) + { + lock (this.attachLock) + { + if (this.retryTimer != null && !this.disposed) + { + this.retryCount++; + this.retryTimer.Change( + GetRetryInterval(this.retryCount), + Timeout.Infinite); + } + } + } + } + catch (Exception) + { + // Swallow — timer will not reschedule, but the explicit + // TryAttach path (e.g. on SessionLogon) can still succeed. + } + } + } +} diff --git a/GVFS/GVFS.Common/Tracing/JsonTracer.cs b/GVFS/GVFS.Common/Tracing/JsonTracer.cs index c494bd012..cc74cb71d 100644 --- a/GVFS/GVFS.Common/Tracing/JsonTracer.cs +++ b/GVFS/GVFS.Common/Tracing/JsonTracer.cs @@ -81,6 +81,14 @@ public bool HasLogFileEventListener } } + public bool HasTelemetryDaemonListener + { + get + { + return this.listeners.Any(listener => listener is TelemetryDaemonEventListener); + } + } + public void SetGitCommandSessionId(string sessionId) { TelemetryDaemonEventListener daemonListener = this.listeners.FirstOrDefault(x => x is TelemetryDaemonEventListener) as TelemetryDaemonEventListener; diff --git a/GVFS/GVFS.Common/Tracing/PrettyConsoleEventListener.cs b/GVFS/GVFS.Common/Tracing/PrettyConsoleEventListener.cs index 5999d97ee..1cdefe681 100644 --- a/GVFS/GVFS.Common/Tracing/PrettyConsoleEventListener.cs +++ b/GVFS/GVFS.Common/Tracing/PrettyConsoleEventListener.cs @@ -1,4 +1,5 @@ using System; +using System.Threading; namespace GVFS.Common.Tracing { @@ -9,7 +10,7 @@ namespace GVFS.Common.Tracing /// public class PrettyConsoleEventListener : EventListener { - private static object consoleLock = new object(); + private static Lock consoleLock = new Lock(); public PrettyConsoleEventListener(EventLevel maxVerbosity, Keywords keywordFilter, IEventListenerEventSink eventSink) : base(maxVerbosity, keywordFilter, eventSink) diff --git a/GVFS/GVFS.Common/Tracing/TelemetryDaemonEventListener.cs b/GVFS/GVFS.Common/Tracing/TelemetryDaemonEventListener.cs index 35e517a42..17dc588bb 100644 --- a/GVFS/GVFS.Common/Tracing/TelemetryDaemonEventListener.cs +++ b/GVFS/GVFS.Common/Tracing/TelemetryDaemonEventListener.cs @@ -37,9 +37,22 @@ private TelemetryDaemonEventListener( public string GitCommandSessionId { get; set; } public static TelemetryDaemonEventListener CreateIfEnabled(string gitBinRoot, string providerName, string enlistmentId, string mountId, IEventListenerEventSink eventSink) + { + return CreateIfEnabled(gitBinRoot, providerName, enlistmentId, mountId, eventSink, globalConfigPath: null); + } + + /// + /// Creates a TelemetryDaemonEventListener if the telemetry pipe config + /// is set. When is provided, reads + /// that file directly instead of using git config --global. + /// This avoids mutating the process-wide HOME environment variable + /// when the caller needs to read another user's config (e.g. + /// GVFS.Service reading the logged-on user's .gitconfig). + /// + public static TelemetryDaemonEventListener CreateIfEnabled(string gitBinRoot, string providerName, string enlistmentId, string mountId, IEventListenerEventSink eventSink, string globalConfigPath) { // This listener is disabled unless the user specifies the proper git config setting. - string telemetryPipe = GetConfigValue(gitBinRoot, GVFSConstants.GitConfig.GVFSTelemetryPipe); + string telemetryPipe = GetConfigValue(gitBinRoot, GVFSConstants.GitConfig.GVFSTelemetryPipe, globalConfigPath); if (!string.IsNullOrEmpty(telemetryPipe)) { return new TelemetryDaemonEventListener(providerName, enlistmentId, mountId, telemetryPipe, eventSink); @@ -90,7 +103,7 @@ protected override void RecordMessageInternal(TraceEventMessage message) } } - private static string GetConfigValue(string gitBinRoot, string configKey) + private static string GetConfigValue(string gitBinRoot, string configKey, string globalConfigPath = null) { string value = string.Empty; string error; @@ -98,7 +111,15 @@ private static string GetConfigValue(string gitBinRoot, string configKey) GitProcess.ConfigResult result = GitProcess.GetFromSystemConfig(gitBinRoot, configKey); if (!result.TryParseAsString(out value, out error, defaultValue: string.Empty) || string.IsNullOrWhiteSpace(value)) { - result = GitProcess.GetFromGlobalConfig(gitBinRoot, configKey); + if (!string.IsNullOrEmpty(globalConfigPath)) + { + result = GitProcess.GetFromFileConfig(gitBinRoot, globalConfigPath, configKey); + } + else + { + result = GitProcess.GetFromGlobalConfig(gitBinRoot, configKey); + } + result.TryParseAsString(out value, out error, defaultValue: string.Empty); } diff --git a/GVFS/GVFS.FunctionalTests/Tests/FastFetchTests.cs b/GVFS/GVFS.FunctionalTests/Tests/FastFetchTests.cs index 2a0989327..ad8db56cb 100644 --- a/GVFS/GVFS.FunctionalTests/Tests/FastFetchTests.cs +++ b/GVFS/GVFS.FunctionalTests/Tests/FastFetchTests.cs @@ -320,9 +320,9 @@ public void SuccessfullyChecksOutCaseChanges() try { - // Ignore case differences on case-insensitive filesystems + // Verify FastFetch produces correct casing matching git checkout, including on case-insensitive filesystems this.fastFetchRepoRoot.ShouldBeADirectory(FileSystemRunner.DefaultRunner) - .WithDeepStructure(FileSystemRunner.DefaultRunner, this.fastFetchControlRoot, ignoreCase: !FileSystemHelpers.CaseSensitiveFileSystem); + .WithDeepStructure(FileSystemRunner.DefaultRunner, this.fastFetchControlRoot, ignoreCase: false); } finally { diff --git a/GVFS/GVFS.Hooks/GVFS.Hooks.csproj b/GVFS/GVFS.Hooks/GVFS.Hooks.csproj index b45fbbeef..c960ac430 100644 --- a/GVFS/GVFS.Hooks/GVFS.Hooks.csproj +++ b/GVFS/GVFS.Hooks/GVFS.Hooks.csproj @@ -5,10 +5,6 @@ true - - - -