From 9ba27f1c52d3db76e64e837a98d73809e5acf41e Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Tue, 1 Sep 2026 11:05:20 -0700 Subject: [PATCH 01/35] Report the real reason a mount fails instead of a broken pipe When GVFS.Mount failed to mount, the client printed a transport error: Could not connect to GVFS.Mount: BrokenPipeException: Unable to send: GetStatus The real cause was written only to the mount process log. A user hitting a ProjFS attach failure on a Dev Drive got no cause and no remedy, although the mount process had already diagnosed it. Two defects combined to lose the reason. First, FailMountAndExit called fileSystemCallbacks.Dispose(), which reached GitIndexProjection.Dispose(bool) and called Task.Dispose() on the still running index-parsing thread. Shutdown() had not run, so the task was not in a completion state and Task.Dispose threw InvalidOperationException. That secondary exception killed the process before it could serve a GetStatus response. Second, even without the exception, FailMountAndExit called Environment.Exit immediately after setting MountState.MountFailed. The client polls GetStatus every 100ms, so the process usually died before the client observed the state. MountState.MountFailed and the GetStatus.MountFailed response already existed but were never seen. Changes: - GitIndexProjection.Dispose(bool) only disposes the index-parsing task once it has completed. A Task holds no unmanaged resources, so skipping the call is safe, and it no longer throws during a failed mount. - FailMountAndExit keeps the named pipe answering GetStatus after a failure, and exits once a client has read MountFailed. Callbacks are disposed after the failure is reported, and disposal exceptions are logged instead of terminating the process. A latch makes the first failing thread the only one that tears down and exits. The wait is deliberately short. Until the process exits it still holds the mount lock and its named pipe, and a "gvfs mount" retry inside that window hits IsExistingPipeListening and is told "The repo at ... is already mounted" with exit code Success. So the wait is capped at 2 seconds, or 500ms when no client has polled GetStatus at all, and normally ends on the client's next poll, about 100ms. Failures raised before the pipe server starts do not wait; MountVerb already detects those through the mount process exit code. - GetStatus.Response carries a MountError field, so WaitUntilMounted prints the mount process's own message rather than "Failed to mount at ". - StartVirtualizing failures with 0x800701DE (ERROR_FILE_SYSTEM_VIRTUALIZATION_NOT_AVAILABLE) name the cause and the remedy: PrjFlt cannot attach to the volume, and on a Dev Drive it must be allowed with "fsutil devdrv setfiltersallowed PrjFlt". Tests: disposing a projection whose parsing thread is still running must not throw, and WaitUntilMounted must surface MountError when the mount process sends one, falling back to the generic message when it does not. Both fail against the unfixed code. Assisted-by: Claude Opus 5 Signed-off-by: Tyrie Vella --- GVFS/GVFS.Common/GVFSEnlistment.cs | 4 +- .../NamedPipes/NamedPipeMessages.cs | 8 ++ GVFS/GVFS.Mount/InProcessMount.cs | 125 +++++++++++++++++- .../HResultExtensions.cs | 6 + .../WindowsFileSystemVirtualizer.cs | 8 ++ .../WaitUntilMountedFailureReportingTests.cs | 98 ++++++++++++++ .../GitIndexProjectionDisposeTests.cs | 59 +++++++++ .../Projection/GitIndexProjection.cs | 21 ++- 8 files changed, 322 insertions(+), 7 deletions(-) create mode 100644 GVFS/GVFS.UnitTests/Common/WaitUntilMountedFailureReportingTests.cs create mode 100644 GVFS/GVFS.UnitTests/Virtualization/Projection/GitIndexProjectionDisposeTests.cs diff --git a/GVFS/GVFS.Common/GVFSEnlistment.cs b/GVFS/GVFS.Common/GVFSEnlistment.cs index 7cd441aadb..51779cb385 100644 --- a/GVFS/GVFS.Common/GVFSEnlistment.cs +++ b/GVFS/GVFS.Common/GVFSEnlistment.cs @@ -281,7 +281,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; } 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.Mount/InProcessMount.cs b/GVFS/GVFS.Mount/InProcessMount.cs index 629be66138..b13863f6e1 100644 --- a/GVFS/GVFS.Mount/InProcessMount.cs +++ b/GVFS/GVFS.Mount/InProcessMount.cs @@ -41,6 +41,19 @@ public class InProcessMount // reliably trigger a commit pack download. private const int TrackedTreeCapacity = MissingTreeThresholdForDownloadingCommitPack * 20; + // Bounds how long a failed mount stays alive so a client can read the reason. + // Kept short on purpose: until the process exits it still holds the mount lock + // and its named pipe, so a user retrying "gvfs mount" would be told the repo is + // already mounted. The wait normally ends on the client's next poll (~100ms). + private const int MountFailureReportTimeoutMs = 2000; + + // Used instead when no client has polled GetStatus yet. It only has to cover the + // narrow window between the pipe opening and MountVerb's first poll. + private const int MountFailureNoClientTimeoutMs = 500; + + // Lets the response drain to the client before the pipe goes away. + private const int MountFailureDrainMs = 50; + private readonly bool showDebugWindow; private FileSystemCallbacks fileSystemCallbacks; @@ -59,6 +72,24 @@ public class InProcessMount private volatile MountState currentState; private volatile string mountProgressMessage; + // Why the mount failed. Sent to the client in the GetStatus response so it can + // report the real cause rather than a generic "failed to mount" message. + private volatile string mountFailureMessage; + + // Set once the named pipe server is accepting requests. Failures before that + // point cannot be reported over the pipe, so they must not wait for a reader. + private volatile bool namedPipeReady; + + // Set once a client has polled GetStatus, which means somebody is waiting for + // the mount result and is worth holding the process open for. + private volatile bool clientPolledStatus; + + // Signaled after a GetStatus response carrying MountFailed is written to a client. + private ManualResetEvent mountFailureReported; + + // Ensures only the first thread to fail the mount tears down and exits. + private int mountFailureLatch; + // 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 +113,7 @@ public InProcessMount(ITracer tracer, GVFSEnlistment enlistment, CacheServerInfo this.enlistment = enlistment; this.showDebugWindow = showDebugWindow; this.unmountEvent = new ManualResetEvent(false); + this.mountFailureReported = new ManualResetEvent(false); this.missingTreeTracker = new MissingTreeTracker(tracer, TrackedTreeCapacity); } @@ -258,6 +290,8 @@ private void MountWithLockAcquired(EventLevel verbosity, Keywords keywords) this.mountProgressMessage = "Authenticating and validating"; using (NamedPipeServer pipeServer = this.StartNamedPipe()) { + this.namedPipeReady = true; + this.tracer.RelatedEvent( EventLevel.Informational, $"{nameof(this.Mount)}_StartedNamedPipe", @@ -633,6 +667,24 @@ private bool ShouldReportMountProgress() } } + private static string FormatMountFailure(string error, object[] args) + { + if (args == null || args.Length == 0) + { + return error; + } + + try + { + return string.Format(error, args); + } + catch (FormatException) + { + // Never let message formatting mask the failure we are reporting. + return error; + } + } + private void FailMountAndExit(string error, params object[] args) { this.FailMountAndExit(ReturnCode.GenericError, error, args); @@ -640,6 +692,15 @@ private void FailMountAndExit(string error, params object[] args) private void FailMountAndExit(ReturnCode returnCode, string error, params object[] args) { + if (Interlocked.CompareExchange(ref this.mountFailureLatch, 1, 0) != 0) + { + // Another thread already owns the failure path and will exit the process. + // Block rather than return: every caller of FailMountAndExit assumes it + // never returns and would otherwise run on with a half-initialized mount. + Thread.Sleep(Timeout.Infinite); + } + + this.mountFailureMessage = FormatMountFailure(error, args); this.currentState = MountState.MountFailed; this.tracer.RelatedError(error, args); @@ -649,15 +710,58 @@ private void FailMountAndExit(ReturnCode returnCode, string error, params object Console.ReadLine(); } - if (this.fileSystemCallbacks != null) + // Report the failure before tearing anything down. Disposal can be slow, and a + // secondary exception thrown from it would kill the process before the client + // ever reads the reason -- which is what made mount failures surface as + // BrokenPipeException instead of the real cause. + this.WaitForMountFailureToBeReported(); + + try { - this.fileSystemCallbacks.Dispose(); - this.fileSystemCallbacks = null; + if (this.fileSystemCallbacks != null) + { + this.fileSystemCallbacks.Dispose(); + this.fileSystemCallbacks = null; + } + } + catch (Exception e) + { + this.tracer.RelatedWarning($"{nameof(this.FailMountAndExit)}: Exception while disposing file system callbacks: {e}"); } Environment.Exit((int)returnCode); } + /// + /// Blocks until a client has read the MountFailed status, or until a short + /// timeout elapses. The process keeps the mount lock and the named pipe until it + /// exits, so this wait must stay short or it blocks the user's next mount attempt. + /// + private void WaitForMountFailureToBeReported() + { + if (!this.namedPipeReady) + { + // The pipe is not serving requests yet, so no client can read the + // failure. MountVerb detects this case by watching the mount process + // exit code instead. + return; + } + + int timeoutMs = this.clientPolledStatus + ? MountFailureReportTimeoutMs + : MountFailureNoClientTimeoutMs; + + if (this.mountFailureReported.WaitOne(timeoutMs)) + { + Thread.Sleep(MountFailureDrainMs); + } + else + { + this.tracer.RelatedWarning( + $"{nameof(this.WaitForMountFailureToBeReported)}: No client read the mount failure within {timeoutMs}ms. Exiting anyway."); + } + } + private T CreateOrReportAndExit(Func factory, string reportMessage) { try @@ -1418,6 +1522,10 @@ private void HandlePrefetchBlobsRequest(NamedPipeMessages.Message message, Named private void HandleGetStatusRequest(NamedPipeServer.Connection connection) { + this.clientPolledStatus = true; + + 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; @@ -1426,7 +1534,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; @@ -1448,6 +1556,7 @@ private void HandleGetStatusRequest(NamedPipeServer.Connection connection) case MountState.MountFailed: response.MountStatus = NamedPipeMessages.GetStatus.MountFailed; + response.MountError = this.mountFailureMessage; break; default: @@ -1455,7 +1564,13 @@ private void HandleGetStatusRequest(NamedPipeServer.Connection connection) break; } - connection.TrySendResponse(response.ToJson()); + bool sent = connection.TrySendResponse(response.ToJson()); + + if (sent && state == MountState.MountFailed) + { + // The client now has the reason, so FailMountAndExit can stop waiting. + this.mountFailureReported.Set(); + } } private void HandleUnmountRequest(NamedPipeServer.Connection connection) diff --git a/GVFS/GVFS.Platform.Windows/HResultExtensions.cs b/GVFS/GVFS.Platform.Windows/HResultExtensions.cs index 96df7ae84b..64b7ecf31d 100644 --- a/GVFS/GVFS.Platform.Windows/HResultExtensions.cs +++ b/GVFS/GVFS.Platform.Windows/HResultExtensions.cs @@ -6,6 +6,12 @@ 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_FILE_SYSTEM_VIRTUALIZATION_NOT_AVAILABLE (478)): + // "The file system minifilter cannot attach to the developer volume." Returned by + // StartVirtualizing when PrjFlt is not on the volume's allowed-filter list, which + // is the default on a ReFS Dev Drive. + public const int FileSystemVirtualizationNotAvailable = 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/WindowsFileSystemVirtualizer.cs b/GVFS/GVFS.Platform.Windows/WindowsFileSystemVirtualizer.cs index cc4ff66863..6bc8dca583 100644 --- a/GVFS/GVFS.Platform.Windows/WindowsFileSystemVirtualizer.cs +++ b/GVFS/GVFS.Platform.Windows/WindowsFileSystemVirtualizer.cs @@ -836,6 +836,14 @@ 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.FileSystemVirtualizationNotAvailable) + { + error += ". The ProjFS filter (PrjFlt) cannot attach to this volume. " + + "If the enlistment is on a Dev Drive, allow the filter on that volume from an elevated command prompt: " + + "fsutil devdrv setfiltersallowed PrjFlt"; + } + return false; } diff --git a/GVFS/GVFS.UnitTests/Common/WaitUntilMountedFailureReportingTests.cs b/GVFS/GVFS.UnitTests/Common/WaitUntilMountedFailureReportingTests.cs new file mode 100644 index 0000000000..c8d771dc75 --- /dev/null +++ b/GVFS/GVFS.UnitTests/Common/WaitUntilMountedFailureReportingTests.cs @@ -0,0 +1,98 @@ +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 = "C:\\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 + { + serverTask.Wait(TimeSpan.FromSeconds(30)); + } + } + } + } +} diff --git a/GVFS/GVFS.UnitTests/Virtualization/Projection/GitIndexProjectionDisposeTests.cs b/GVFS/GVFS.UnitTests/Virtualization/Projection/GitIndexProjectionDisposeTests.cs new file mode 100644 index 0000000000..c5b1cf9f54 --- /dev/null +++ b/GVFS/GVFS.UnitTests/Virtualization/Projection/GitIndexProjectionDisposeTests.cs @@ -0,0 +1,59 @@ +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); + + // A failed mount disposes the projection without calling Shutdown, so + // the parsing thread is still running here. Disposing a Task that has + // not completed throws InvalidOperationException, and that secondary + // exception used to kill GVFS.Mount before it could report why the + // mount failed -- leaving the client with a broken pipe. + Assert.DoesNotThrow(() => projection.Dispose()); + } + finally + { + releaseParsingThread.Set(); + parsingThread.Wait(); + parsingThread.Dispose(); + } + } + } + + [TestCase] + public void DisposeDisposesIndexParsingThreadOnceItHasCompleted() + { + // Never use Task.CompletedTask here: it is a runtime-wide singleton, and + // disposing it breaks every later await in the process. + Task parsingThread = Task.Factory.StartNew(() => { }, TaskCreationOptions.LongRunning); + parsingThread.Wait(); + + TestableGitIndexProjection projection = new TestableGitIndexProjection(); + projection.SetIndexParsingThreadForTests(parsingThread); + + Assert.DoesNotThrow(() => projection.Dispose()); + } + + private class TestableGitIndexProjection : GitIndexProjection + { + } + } +} diff --git a/GVFS/GVFS.Virtualization/Projection/GitIndexProjection.cs b/GVFS/GVFS.Virtualization/Projection/GitIndexProjection.cs index 3b8301b125..4c5d377a8d 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,17 @@ protected virtual void Dispose(bool disposing) if (this.indexParsingThread != null) { - this.indexParsingThread.Dispose(); + // Task.Dispose throws InvalidOperationException unless the task has + // reached a completion state. A failed mount disposes the projection + // without calling Shutdown first, so the parsing thread is still + // running here. Skip the Dispose in that case: a Task owns no + // unmanaged resources, and throwing would kill the mount process + // before it can report why the mount failed. + if (this.indexParsingThread.IsCompleted) + { + this.indexParsingThread.Dispose(); + } + this.indexParsingThread = null; } } From d4ddf6bf8277e7283f7da2e61db635f58ef785d1 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Tue, 1 Sep 2026 12:39:39 -0700 Subject: [PATCH 02/35] Address self-review findings on the mount failure reporting fix Verified the original fix end-to-end on a real ReFS Dev Drive with PrjFlt not on the allowed-filter list. The baseline reproduced the reported BrokenPipeException exactly, and this branch now prints the real cause. That run, plus a multi-perspective self-review, turned up several defects in the first version of this change. Fix a second copy of the disposal bug. The repro showed the InvalidOperationException still being thrown after the GitIndexProjection fix, now from BackgroundFileSystemTaskRunner.Dispose, which disposes its own still-running Task. Only the new try/catch kept the report alive. Both classes now drop the Task.Dispose call outright rather than guarding it: a Task holds no unmanaged resources and Task.Dispose is legacy IAsyncResult cleanup. Correct the Dev Drive remedy, which was wrong and destructive. It advised "fsutil devdrv setfiltersallowed PrjFlt". Without /volume that applies to every developer volume on the machine, and setFiltersAllowed replaces the list rather than appending to it, so following the advice would drop filters the machine already requires. The message now names the enlistment's own volume, tells the user to read the current list first, and says the list is replaced. Do not let message formatting hang the mount process. FailMountAndExit formatted the message twice, and the second call was unprotected. A malformed format string threw there, and an outer catch block then re-entered FailMountAndExit on the same thread, where the latch parked it forever holding the mount lock and the pipe. Format once, and make the latch re-entrant for the thread that already owns it. Other fixes from the review: - Only count a client that actually received a status. clientPolledStatus was set before the send succeeded, so a client that connected and disconnected still extended the wait. - Raise the drain above the client's 100ms poll interval. Any client can satisfy the wait, so a concurrent "gvfs status" could consume the signal while the mounting client was still sleeping. - Trace the report timeout and the swallowed disposal exception with Keywords.Telemetry. Without it, a failure that used to be a process-fatal crash became invisible in the field. - Shorten the test's server-task wait so a failing assertion is not delayed. Adds a regression test for the BackgroundFileSystemTaskRunner disposal. Both disposal tests are mutation-checked. Assisted-by: Claude Opus 5 Signed-off-by: Tyrie Vella --- GVFS/GVFS.Mount/InProcessMount.cs | 87 +++++++++++++++---- .../WindowsFileSystemVirtualizer.cs | 46 +++++++++- .../WaitUntilMountedFailureReportingTests.cs | 2 +- ...kgroundFileSystemTaskRunnerDisposeTests.cs | 45 ++++++++++ .../GitIndexProjectionDisposeTests.cs | 23 ++--- .../BackgroundFileSystemTaskRunner.cs | 16 +++- .../Projection/GitIndexProjection.cs | 18 ++-- 7 files changed, 185 insertions(+), 52 deletions(-) create mode 100644 GVFS/GVFS.UnitTests/Virtualization/Background/BackgroundFileSystemTaskRunnerDisposeTests.cs diff --git a/GVFS/GVFS.Mount/InProcessMount.cs b/GVFS/GVFS.Mount/InProcessMount.cs index b13863f6e1..07f0b82299 100644 --- a/GVFS/GVFS.Mount/InProcessMount.cs +++ b/GVFS/GVFS.Mount/InProcessMount.cs @@ -51,8 +51,11 @@ public class InProcessMount // narrow window between the pipe opening and MountVerb's first poll. private const int MountFailureNoClientTimeoutMs = 500; - // Lets the response drain to the client before the pipe goes away. - private const int MountFailureDrainMs = 50; + // Lets the response drain, and covers the case where a different client (a + // concurrent "gvfs status") consumed the report signal. It must exceed the + // client's 100ms GetStatus poll interval so the mounting client still gets its + // answer on the next poll. + private const int MountFailureDrainMs = 150; private readonly bool showDebugWindow; @@ -87,8 +90,9 @@ public class InProcessMount // Signaled after a GetStatus response carrying MountFailed is written to a client. private ManualResetEvent mountFailureReported; - // Ensures only the first thread to fail the mount tears down and exits. - private int mountFailureLatch; + // Identifies the thread that owns the mount-failure path, so a re-entrant call on + // that same thread exits instead of waiting on itself. 0 means no owner yet. + private int mountFailureOwnerThreadId; // 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. @@ -692,18 +696,39 @@ private void FailMountAndExit(string error, params object[] args) private void FailMountAndExit(ReturnCode returnCode, string error, params object[] args) { - if (Interlocked.CompareExchange(ref this.mountFailureLatch, 1, 0) != 0) + // Format before taking the latch. RelatedError would otherwise format a second + // time without protection, and a throw there would re-enter this method from an + // outer catch block. + string failureMessage = FormatMountFailure(error, args); + + int currentThreadId = Environment.CurrentManagedThreadId; + int owningThreadId = Interlocked.CompareExchange(ref this.mountFailureOwnerThreadId, currentThreadId, 0); + + if (owningThreadId == currentThreadId) + { + // Re-entered on the reporting thread, because something below threw and an + // outer catch called back in. The failure was already reported, so exit + // rather than wait again -- waiting here would block forever on a signal + // this thread is itself responsible for observing. + this.tracer.RelatedWarning( + null, + $"{nameof(this.FailMountAndExit)}: Re-entered while reporting a mount failure: {failureMessage}", + Keywords.Telemetry); + Environment.Exit((int)returnCode); + } + + if (owningThreadId != 0) { - // Another thread already owns the failure path and will exit the process. - // Block rather than return: every caller of FailMountAndExit assumes it - // never returns and would otherwise run on with a half-initialized mount. - Thread.Sleep(Timeout.Infinite); + // Another thread owns the failure path and will exit the process. Block + // rather than return: every caller of FailMountAndExit assumes it never + // returns and would otherwise run on with a half-initialized mount. + BlockUntilProcessExits(); } - this.mountFailureMessage = FormatMountFailure(error, args); + this.mountFailureMessage = failureMessage; this.currentState = MountState.MountFailed; - this.tracer.RelatedError(error, args); + this.tracer.RelatedError(failureMessage); if (this.showDebugWindow) { Console.WriteLine("\nPress Enter to Exit"); @@ -726,12 +751,26 @@ private void FailMountAndExit(ReturnCode returnCode, string error, params object } catch (Exception e) { - this.tracer.RelatedWarning($"{nameof(this.FailMountAndExit)}: Exception while disposing file system callbacks: {e}"); + // Traced with Telemetry because this used to be a process-fatal crash. Without + // the keyword the failure it replaced would become invisible in the field. + this.tracer.RelatedWarning( + null, + $"{nameof(this.FailMountAndExit)}: Exception while disposing file system callbacks: {e}", + Keywords.Telemetry); } Environment.Exit((int)returnCode); } + /// + /// Parks the calling thread until another thread exits the process. Used where a + /// method must not return but is not the thread that owns the exit. + /// + private static void BlockUntilProcessExits() + { + Thread.Sleep(Timeout.Infinite); + } + /// /// Blocks until a client has read the MountFailed status, or until a short /// timeout elapses. The process keeps the mount lock and the named pipe until it @@ -753,12 +792,18 @@ private void WaitForMountFailureToBeReported() if (this.mountFailureReported.WaitOne(timeoutMs)) { + // Any client can satisfy the wait, and the one that read the failure is not + // necessarily the one that is mounting -- a concurrent "gvfs status" polls + // the same pipe. Staying alive for longer than the client's poll interval + // means the mounting client still gets its answer on its next poll. Thread.Sleep(MountFailureDrainMs); } else { this.tracer.RelatedWarning( - $"{nameof(this.WaitForMountFailureToBeReported)}: No client read the mount failure within {timeoutMs}ms. Exiting anyway."); + null, + $"{nameof(this.WaitForMountFailureToBeReported)}: No client read the mount failure within {timeoutMs}ms. Exiting anyway.", + Keywords.Telemetry); } } @@ -1522,8 +1567,6 @@ private void HandlePrefetchBlobsRequest(NamedPipeMessages.Message message, Named private void HandleGetStatusRequest(NamedPipeServer.Connection connection) { - this.clientPolledStatus = true; - MountState state = this.currentState; NamedPipeMessages.GetStatus.Response response = new NamedPipeMessages.GetStatus.Response(); @@ -1566,10 +1609,18 @@ private void HandleGetStatusRequest(NamedPipeServer.Connection connection) bool sent = connection.TrySendResponse(response.ToJson()); - if (sent && state == MountState.MountFailed) + if (sent) { - // The client now has the reason, so FailMountAndExit can stop waiting. - this.mountFailureReported.Set(); + // Only count a client that actually received a status. Setting this on + // entry would let a client that connected and then disconnected extend the + // post-failure wait. + this.clientPolledStatus = true; + + if (state == MountState.MountFailed) + { + // The client now has the reason, so FailMountAndExit can stop waiting. + this.mountFailureReported.Set(); + } } } diff --git a/GVFS/GVFS.Platform.Windows/WindowsFileSystemVirtualizer.cs b/GVFS/GVFS.Platform.Windows/WindowsFileSystemVirtualizer.cs index 6bc8dca583..357f77c3c5 100644 --- a/GVFS/GVFS.Platform.Windows/WindowsFileSystemVirtualizer.cs +++ b/GVFS/GVFS.Platform.Windows/WindowsFileSystemVirtualizer.cs @@ -839,9 +839,7 @@ public override bool TryStart(out string error) if ((int)result == HResultExtensions.FileSystemVirtualizationNotAvailable) { - error += ". The ProjFS filter (PrjFlt) cannot attach to this volume. " - + "If the enlistment is on a Dev Drive, allow the filter on that volume from an elevated command prompt: " - + "fsutil devdrv setfiltersallowed PrjFlt"; + error += ". " + BuildProjFsAttachRemedy(this.Context.Enlistment.WorkingDirectoryRoot); } return false; @@ -855,6 +853,48 @@ protected override void OnPossibleTombstoneFolderCreated(string relativePath) this.FileSystemCallbacks.OnPossibleTombstoneFolderCreated(relativePath); } + /// + /// Builds the user-facing remedy for + /// ERROR_FILE_SYSTEM_VIRTUALIZATION_NOT_AVAILABLE, 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). + /// + private 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/Common/WaitUntilMountedFailureReportingTests.cs b/GVFS/GVFS.UnitTests/Common/WaitUntilMountedFailureReportingTests.cs index c8d771dc75..2f89013c88 100644 --- a/GVFS/GVFS.UnitTests/Common/WaitUntilMountedFailureReportingTests.cs +++ b/GVFS/GVFS.UnitTests/Common/WaitUntilMountedFailureReportingTests.cs @@ -90,7 +90,7 @@ private static string RunAgainstMountFailedServer(string mountError) } finally { - serverTask.Wait(TimeSpan.FromSeconds(30)); + serverTask.Wait(TimeSpan.FromSeconds(5)); } } } diff --git a/GVFS/GVFS.UnitTests/Virtualization/Background/BackgroundFileSystemTaskRunnerDisposeTests.cs b/GVFS/GVFS.UnitTests/Virtualization/Background/BackgroundFileSystemTaskRunnerDisposeTests.cs new file mode 100644 index 0000000000..8a390510fc --- /dev/null +++ b/GVFS/GVFS.UnitTests/Virtualization/Background/BackgroundFileSystemTaskRunnerDisposeTests.cs @@ -0,0 +1,45 @@ +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); + + // FileSystemCallbacks.Dispose() disposes this runner on the failed-mount + // path, without calling Shutdown first, so the background thread is still + // running. Calling Task.Dispose on a task that has not completed throws + // InvalidOperationException, which stopped GVFS.Mount from reporting why + // the mount failed. This guards against reintroducing that call. + 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 index c5b1cf9f54..68ae38b29f 100644 --- a/GVFS/GVFS.UnitTests/Virtualization/Projection/GitIndexProjectionDisposeTests.cs +++ b/GVFS/GVFS.UnitTests/Virtualization/Projection/GitIndexProjectionDisposeTests.cs @@ -23,10 +23,11 @@ public void DisposeDoesNotThrowWhenIndexParsingThreadIsStillRunning() projection.SetIndexParsingThreadForTests(parsingThread); // A failed mount disposes the projection without calling Shutdown, so - // the parsing thread is still running here. Disposing a Task that has - // not completed throws InvalidOperationException, and that secondary - // exception used to kill GVFS.Mount before it could report why the - // mount failed -- leaving the client with a broken pipe. + // the parsing thread is still running here. Calling Task.Dispose on a + // task that has not completed throws InvalidOperationException, and + // that secondary exception used to kill GVFS.Mount before it could + // report why the mount failed -- leaving the client with a broken pipe. + // This guards against reintroducing that call. Assert.DoesNotThrow(() => projection.Dispose()); } finally @@ -38,20 +39,6 @@ public void DisposeDoesNotThrowWhenIndexParsingThreadIsStillRunning() } } - [TestCase] - public void DisposeDisposesIndexParsingThreadOnceItHasCompleted() - { - // Never use Task.CompletedTask here: it is a runtime-wide singleton, and - // disposing it breaks every later await in the process. - Task parsingThread = Task.Factory.StartNew(() => { }, TaskCreationOptions.LongRunning); - parsingThread.Wait(); - - TestableGitIndexProjection projection = new TestableGitIndexProjection(); - projection.SetIndexParsingThreadForTests(parsingThread); - - Assert.DoesNotThrow(() => projection.Dispose()); - } - private class TestableGitIndexProjection : GitIndexProjection { } diff --git a/GVFS/GVFS.Virtualization/Background/BackgroundFileSystemTaskRunner.cs b/GVFS/GVFS.Virtualization/Background/BackgroundFileSystemTaskRunner.cs index 88ea897098..21316fbf6d 100644 --- a/GVFS/GVFS.Virtualization/Background/BackgroundFileSystemTaskRunner.cs +++ b/GVFS/GVFS.Virtualization/Background/BackgroundFileSystemTaskRunner.cs @@ -122,13 +122,27 @@ 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(); + // Deliberately not calling Task.Dispose. A Task holds no unmanaged + // resources, and Task.Dispose throws InvalidOperationException unless + // the task has reached a completion state. A failed mount disposes the + // callbacks without calling Shutdown first, so this thread is still + // running and that throw would prevent the mount process from + // reporting why the mount failed. this.backgroundThread = null; } if (this.backgroundTasks != null) diff --git a/GVFS/GVFS.Virtualization/Projection/GitIndexProjection.cs b/GVFS/GVFS.Virtualization/Projection/GitIndexProjection.cs index 4c5d377a8d..131449e640 100644 --- a/GVFS/GVFS.Virtualization/Projection/GitIndexProjection.cs +++ b/GVFS/GVFS.Virtualization/Projection/GitIndexProjection.cs @@ -739,17 +739,13 @@ protected virtual void Dispose(bool disposing) if (this.indexParsingThread != null) { - // Task.Dispose throws InvalidOperationException unless the task has - // reached a completion state. A failed mount disposes the projection - // without calling Shutdown first, so the parsing thread is still - // running here. Skip the Dispose in that case: a Task owns no - // unmanaged resources, and throwing would kill the mount process - // before it can report why the mount failed. - if (this.indexParsingThread.IsCompleted) - { - this.indexParsingThread.Dispose(); - } - + // Deliberately not calling Task.Dispose. A Task holds no unmanaged + // resources, Task.Dispose is legacy IAsyncResult wait-handle cleanup, + // and it throws InvalidOperationException unless the task has reached + // a completion state. A failed mount disposes the projection without + // calling Shutdown first, so the parsing thread is still running here + // and that throw would kill the mount process before it can report + // why the mount failed. this.indexParsingThread = null; } } From 14cdfdf935b0fc4c2c37d71091f2593dad8ca248 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Wed, 2 Sep 2026 09:53:45 -0700 Subject: [PATCH 03/35] Extract the mount-failure handshake into MountFailureReporter The failure-reporting change had grown to three constants, four fields and two methods inside InProcessMount, a 2158-line class that already carried 74 private fields. The state was cohesive but scattered, and it was reachable from only five places, so it moves out cleanly. MountFailureReporter now owns the handshake: the ownership latch, the published failure message, the pipe-ready and client-received flags, the bounded wait, and the safe message formatting. InProcessMount keeps the policy that belongs to it -- the MountFailed state transition, tracing, disposal, and process exit. Ownership is expressed as a MountFailureOwnership enum (Owner, Reentrant, NotOwner) rather than a bare interlocked int, so the re-entrant case is named instead of implied by a thread-id comparison at the call site. The class lives in GVFS.Common/NamedPipes beside the protocol it implements. The client half of the same handshake, GVFSEnlistment.WaitUntilMounted, is already in GVFS.Common, so both halves now sit together. Placing it there also makes the state machine testable. GVFS.UnitTests references GVFS.Common but not GVFS.Mount, so while this logic lived in InProcessMount none of it could be covered, and CI runs only GVFS.UnitTests. Adds 11 tests for ownership and re-entrancy, the wait and its two timeouts, the distinction between a status that carried the failure and one that did not, and the formatting fallback. Two are mutation-checked. No behavior change. Verified on the same ReFS Dev Drive repro: the mount still prints the real ProjFS cause, and the mount log has no disposal exception and no report timeout. Assisted-by: Claude Opus 5 Signed-off-by: Tyrie Vella --- .../NamedPipes/MountFailureReporter.cs | 222 ++++++++++++++++++ GVFS/GVFS.Mount/InProcessMount.cs | 151 ++---------- .../Common/MountFailureReporterTests.cs | 205 ++++++++++++++++ 3 files changed, 453 insertions(+), 125 deletions(-) create mode 100644 GVFS/GVFS.Common/NamedPipes/MountFailureReporter.cs create mode 100644 GVFS/GVFS.UnitTests/Common/MountFailureReporterTests.cs diff --git a/GVFS/GVFS.Common/NamedPipes/MountFailureReporter.cs b/GVFS/GVFS.Common/NamedPipes/MountFailureReporter.cs new file mode 100644 index 0000000000..a1260d2cac --- /dev/null +++ b/GVFS/GVFS.Common/NamedPipes/MountFailureReporter.cs @@ -0,0 +1,222 @@ +using GVFS.Common.Tracing; +using System; +using System.Threading; + +namespace GVFS.Common.NamedPipes +{ + /// + /// The result of a thread trying to take ownership of the mount-failure path. + /// + public enum MountFailureOwnership + { + /// + /// The calling thread now owns reporting and must drive it to process exit. + /// + Owner, + + /// + /// The calling thread already owned reporting and has re-entered, because + /// something threw while it was reporting and an outer catch block called back + /// in. It must not wait again: it would block forever on a signal that it is + /// itself responsible for observing. + /// + Reentrant, + + /// + /// Another thread owns reporting and will exit the process. The calling thread + /// must not continue with a half-initialized mount. + /// + NotOwner, + } + + /// + /// Server half of the mount-failure handshake: keeps a failed mount process alive + /// just long enough for a client to read the reason over the named pipe. + /// + /// + /// Without this, the mount process sets MountFailed and exits immediately. + /// The client polls GetStatus every 100ms, so it usually loses the race, the pipe + /// breaks mid-request, and the user sees a transport error instead of the cause. + /// + /// The wait is deliberately short. Until the process exits it still holds the mount + /// lock and the named pipe, so a longer wait delays the user's next mount attempt. + /// + /// + /// This type owns only the handshake state. Process-exit policy stays with the + /// caller. The client half of the same protocol is + /// . + /// + /// + public sealed class MountFailureReporter : IDisposable + { + /// + /// Cap on how long to wait once a client is known to be polling. + /// + public 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. + /// + public const int DefaultNoClientTimeoutMs = 500; + + /// + /// Grace period after a client reads the failure. Any client can satisfy the + /// wait, and it is not necessarily the one that is mounting -- a concurrent + /// "gvfs status" polls the same pipe. Keeping this above the client's 100ms poll + /// interval means the mounting client still gets its answer on its next poll. + /// + public const int DefaultDrainMs = 150; + + private readonly ITracer tracer; + private readonly int reportTimeoutMs; + private readonly int noClientTimeoutMs; + private readonly int drainMs; + private readonly ManualResetEvent failureReported; + + 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, DefaultReportTimeoutMs, DefaultNoClientTimeoutMs, DefaultDrainMs) + { + } + + /// + /// Test constructor. Lets unit tests use short timeouts so they do not pay the + /// production waits. + /// + internal MountFailureReporter(ITracer tracer, int reportTimeoutMs, int noClientTimeoutMs, int drainMs) + { + this.tracer = tracer; + this.reportTimeoutMs = reportTimeoutMs; + this.noClientTimeoutMs = noClientTimeoutMs; + this.drainMs = drainMs; + this.failureReported = new ManualResetEvent(false); + } + + /// + /// 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. Failures before this + /// point cannot be reported over the pipe and must not wait for a reader. + /// + public void OnNamedPipeReady() + { + this.namedPipeReady = true; + } + + /// + /// Attempts to take ownership of the mount-failure path, publishing + /// as the reason if this call wins. + /// + public MountFailureOwnership TryTakeOwnership(string message) + { + int currentThreadId = Environment.CurrentManagedThreadId; + int previousOwner = Interlocked.CompareExchange(ref this.owningThreadId, currentThreadId, 0); + + if (previousOwner == currentThreadId) + { + return MountFailureOwnership.Reentrant; + } + + if (previousOwner != 0) + { + return MountFailureOwnership.NotOwner; + } + + this.failureMessage = message; + return MountFailureOwnership.Owner; + } + + /// + /// Records that a GetStatus response reached a client, and releases + /// when that response carried the + /// failure. + /// + /// + /// True when the delivered response reported MountFailed. + /// + public void OnStatusDelivered(bool carriedMountFailure) + { + // Only count a client that actually received a status. Recording this before + // the send succeeds would let a client that connected and then disconnected + // extend the post-failure wait. + this.clientReceivedStatus = true; + + if (carriedMountFailure) + { + this.failureReported.Set(); + } + } + + /// + /// Blocks until a client has read the failure, or until a short timeout elapses. + /// Returns immediately when no client could possibly read it. + /// + public void WaitForClientToReadFailure() + { + if (!this.namedPipeReady) + { + // No client can read the failure. The caller detects this case by + // watching the mount process exit code instead. + return; + } + + int timeoutMs = this.clientReceivedStatus ? this.reportTimeoutMs : this.noClientTimeoutMs; + + if (this.failureReported.WaitOne(timeoutMs)) + { + Thread.Sleep(this.drainMs); + } + else + { + this.tracer.RelatedWarning( + null, + $"{nameof(this.WaitForClientToReadFailure)}: No client read the mount failure within {timeoutMs}ms. Exiting anyway.", + Keywords.Telemetry); + } + } + + public void Dispose() + { + this.failureReported.Dispose(); + } + } +} diff --git a/GVFS/GVFS.Mount/InProcessMount.cs b/GVFS/GVFS.Mount/InProcessMount.cs index 07f0b82299..d1e5815966 100644 --- a/GVFS/GVFS.Mount/InProcessMount.cs +++ b/GVFS/GVFS.Mount/InProcessMount.cs @@ -41,22 +41,6 @@ public class InProcessMount // reliably trigger a commit pack download. private const int TrackedTreeCapacity = MissingTreeThresholdForDownloadingCommitPack * 20; - // Bounds how long a failed mount stays alive so a client can read the reason. - // Kept short on purpose: until the process exits it still holds the mount lock - // and its named pipe, so a user retrying "gvfs mount" would be told the repo is - // already mounted. The wait normally ends on the client's next poll (~100ms). - private const int MountFailureReportTimeoutMs = 2000; - - // Used instead when no client has polled GetStatus yet. It only has to cover the - // narrow window between the pipe opening and MountVerb's first poll. - private const int MountFailureNoClientTimeoutMs = 500; - - // Lets the response drain, and covers the case where a different client (a - // concurrent "gvfs status") consumed the report signal. It must exceed the - // client's 100ms GetStatus poll interval so the mounting client still gets its - // answer on the next poll. - private const int MountFailureDrainMs = 150; - private readonly bool showDebugWindow; private FileSystemCallbacks fileSystemCallbacks; @@ -75,24 +59,9 @@ public class InProcessMount private volatile MountState currentState; private volatile string mountProgressMessage; - // Why the mount failed. Sent to the client in the GetStatus response so it can - // report the real cause rather than a generic "failed to mount" message. - private volatile string mountFailureMessage; - - // Set once the named pipe server is accepting requests. Failures before that - // point cannot be reported over the pipe, so they must not wait for a reader. - private volatile bool namedPipeReady; - - // Set once a client has polled GetStatus, which means somebody is waiting for - // the mount result and is worth holding the process open for. - private volatile bool clientPolledStatus; - - // Signaled after a GetStatus response carrying MountFailed is written to a client. - private ManualResetEvent mountFailureReported; - - // Identifies the thread that owns the mount-failure path, so a re-entrant call on - // that same thread exits instead of waiting on itself. 0 means no owner yet. - private int mountFailureOwnerThreadId; + // 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. @@ -117,7 +86,7 @@ public InProcessMount(ITracer tracer, GVFSEnlistment enlistment, CacheServerInfo this.enlistment = enlistment; this.showDebugWindow = showDebugWindow; this.unmountEvent = new ManualResetEvent(false); - this.mountFailureReported = new ManualResetEvent(false); + this.mountFailureReporter = new MountFailureReporter(tracer); this.missingTreeTracker = new MissingTreeTracker(tracer, TrackedTreeCapacity); } @@ -294,7 +263,7 @@ private void MountWithLockAcquired(EventLevel verbosity, Keywords keywords) this.mountProgressMessage = "Authenticating and validating"; using (NamedPipeServer pipeServer = this.StartNamedPipe()) { - this.namedPipeReady = true; + this.mountFailureReporter.OnNamedPipeReady(); this.tracer.RelatedEvent( EventLevel.Informational, @@ -671,24 +640,6 @@ private bool ShouldReportMountProgress() } } - private static string FormatMountFailure(string error, object[] args) - { - if (args == null || args.Length == 0) - { - return error; - } - - try - { - return string.Format(error, args); - } - catch (FormatException) - { - // Never let message formatting mask the failure we are reporting. - return error; - } - } - private void FailMountAndExit(string error, params object[] args) { this.FailMountAndExit(ReturnCode.GenericError, error, args); @@ -696,36 +647,31 @@ private void FailMountAndExit(string error, params object[] args) private void FailMountAndExit(ReturnCode returnCode, string error, params object[] args) { - // Format before taking the latch. RelatedError would otherwise format a second + // Format before taking ownership. RelatedError would otherwise format a second // time without protection, and a throw there would re-enter this method from an // outer catch block. - string failureMessage = FormatMountFailure(error, args); - - int currentThreadId = Environment.CurrentManagedThreadId; - int owningThreadId = Interlocked.CompareExchange(ref this.mountFailureOwnerThreadId, currentThreadId, 0); + string failureMessage = MountFailureReporter.FormatFailure(error, args); - if (owningThreadId == currentThreadId) + switch (this.mountFailureReporter.TryTakeOwnership(failureMessage)) { - // Re-entered on the reporting thread, because something below threw and an - // outer catch called back in. The failure was already reported, so exit - // rather than wait again -- waiting here would block forever on a signal - // this thread is itself responsible for observing. - this.tracer.RelatedWarning( - null, - $"{nameof(this.FailMountAndExit)}: Re-entered while reporting a mount failure: {failureMessage}", - Keywords.Telemetry); - Environment.Exit((int)returnCode); - } + case MountFailureOwnership.Reentrant: + // Something below threw and an outer catch called back in. The failure + // was already reported, so exit rather than wait on ourselves. + this.tracer.RelatedWarning( + null, + $"{nameof(this.FailMountAndExit)}: Re-entered while reporting a mount failure: {failureMessage}", + Keywords.Telemetry); + Environment.Exit((int)returnCode); + break; - if (owningThreadId != 0) - { - // Another thread owns the failure path and will exit the process. Block - // rather than return: every caller of FailMountAndExit assumes it never - // returns and would otherwise run on with a half-initialized mount. - BlockUntilProcessExits(); + case MountFailureOwnership.NotOwner: + // Another thread owns the failure path and will exit the process. Block + // rather than return: every caller of FailMountAndExit assumes it never + // returns and would otherwise run on with a half-initialized mount. + BlockUntilProcessExits(); + break; } - this.mountFailureMessage = failureMessage; this.currentState = MountState.MountFailed; this.tracer.RelatedError(failureMessage); @@ -739,7 +685,7 @@ private void FailMountAndExit(ReturnCode returnCode, string error, params object // secondary exception thrown from it would kill the process before the client // ever reads the reason -- which is what made mount failures surface as // BrokenPipeException instead of the real cause. - this.WaitForMountFailureToBeReported(); + this.mountFailureReporter.WaitForClientToReadFailure(); try { @@ -771,42 +717,6 @@ private static void BlockUntilProcessExits() Thread.Sleep(Timeout.Infinite); } - /// - /// Blocks until a client has read the MountFailed status, or until a short - /// timeout elapses. The process keeps the mount lock and the named pipe until it - /// exits, so this wait must stay short or it blocks the user's next mount attempt. - /// - private void WaitForMountFailureToBeReported() - { - if (!this.namedPipeReady) - { - // The pipe is not serving requests yet, so no client can read the - // failure. MountVerb detects this case by watching the mount process - // exit code instead. - return; - } - - int timeoutMs = this.clientPolledStatus - ? MountFailureReportTimeoutMs - : MountFailureNoClientTimeoutMs; - - if (this.mountFailureReported.WaitOne(timeoutMs)) - { - // Any client can satisfy the wait, and the one that read the failure is not - // necessarily the one that is mounting -- a concurrent "gvfs status" polls - // the same pipe. Staying alive for longer than the client's poll interval - // means the mounting client still gets its answer on its next poll. - Thread.Sleep(MountFailureDrainMs); - } - else - { - this.tracer.RelatedWarning( - null, - $"{nameof(this.WaitForMountFailureToBeReported)}: No client read the mount failure within {timeoutMs}ms. Exiting anyway.", - Keywords.Telemetry); - } - } - private T CreateOrReportAndExit(Func factory, string reportMessage) { try @@ -1599,7 +1509,7 @@ private void HandleGetStatusRequest(NamedPipeServer.Connection connection) case MountState.MountFailed: response.MountStatus = NamedPipeMessages.GetStatus.MountFailed; - response.MountError = this.mountFailureMessage; + response.MountError = this.mountFailureReporter.FailureMessage; break; default: @@ -1611,16 +1521,7 @@ private void HandleGetStatusRequest(NamedPipeServer.Connection connection) if (sent) { - // Only count a client that actually received a status. Setting this on - // entry would let a client that connected and then disconnected extend the - // post-failure wait. - this.clientPolledStatus = true; - - if (state == MountState.MountFailed) - { - // The client now has the reason, so FailMountAndExit can stop waiting. - this.mountFailureReported.Set(); - } + this.mountFailureReporter.OnStatusDelivered(carriedMountFailure: state == MountState.MountFailed); } } diff --git a/GVFS/GVFS.UnitTests/Common/MountFailureReporterTests.cs b/GVFS/GVFS.UnitTests/Common/MountFailureReporterTests.cs new file mode 100644 index 0000000000..18964f174c --- /dev/null +++ b/GVFS/GVFS.UnitTests/Common/MountFailureReporterTests.cs @@ -0,0 +1,205 @@ +using GVFS.Common.NamedPipes; +using GVFS.Tests.Should; +using GVFS.UnitTests.Mock.Common; +using NUnit.Framework; +using System; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; + +namespace GVFS.UnitTests.Common +{ + /// + /// Covers the server half of the mount-failure handshake. Before this state machine + /// was extracted from InProcessMount it lived in GVFS.Mount, which GVFS.UnitTests + /// cannot reference, so none of it was covered. + /// + [TestFixture] + public class MountFailureReporterTests + { + private const int ReportTimeoutMs = 2000; + private const int NoClientTimeoutMs = 200; + private const int DrainMs = 20; + + [TestCase] + public void FirstThreadTakesOwnershipAndPublishesTheMessage() + { + using (MountFailureReporter reporter = CreateReporter()) + { + reporter.FailureMessage.ShouldBeNull(); + + reporter.TryTakeOwnership("the real reason").ShouldEqual(MountFailureOwnership.Owner); + reporter.FailureMessage.ShouldEqual("the real reason"); + } + } + + [TestCase] + public void SameThreadCallingTwiceIsReentrantRatherThanBlocked() + { + using (MountFailureReporter reporter = CreateReporter()) + { + reporter.TryTakeOwnership("first").ShouldEqual(MountFailureOwnership.Owner); + + // A throw inside the reporting path can make an outer catch block call + // back in on the same thread. That must not be treated as a second thread, + // or the caller would park forever holding the mount lock and the pipe. + reporter.TryTakeOwnership("second").ShouldEqual(MountFailureOwnership.Reentrant); + + // The re-entrant call must not overwrite the reason already published. + reporter.FailureMessage.ShouldEqual("first"); + } + } + + [TestCase] + public void SecondThreadIsNotTheOwner() + { + using (MountFailureReporter reporter = CreateReporter()) + { + reporter.TryTakeOwnership("first").ShouldEqual(MountFailureOwnership.Owner); + + MountFailureOwnership fromOtherThread = MountFailureOwnership.Owner; + Task other = Task.Run(() => fromOtherThread = reporter.TryTakeOwnership("second")); + other.Wait(TimeSpan.FromSeconds(5)).ShouldBeTrue(); + + fromOtherThread.ShouldEqual(MountFailureOwnership.NotOwner); + reporter.FailureMessage.ShouldEqual("first"); + } + } + + [TestCase] + public void WaitReturnsImmediatelyWhenThePipeNeverOpened() + { + using (MountFailureReporter reporter = CreateReporter()) + { + reporter.TryTakeOwnership("failed before the pipe opened"); + + // No client can read the failure, so the process must not linger holding + // the mount lock. The caller falls back to the process exit code. + Stopwatch elapsed = Stopwatch.StartNew(); + reporter.WaitForClientToReadFailure(); + elapsed.Stop(); + + Assert.That(elapsed.ElapsedMilliseconds, Is.LessThan(NoClientTimeoutMs)); + } + } + + [TestCase] + public void WaitUsesTheShortTimeoutWhenNoClientHasReceivedAStatus() + { + using (MountFailureReporter reporter = CreateReporter()) + { + reporter.OnNamedPipeReady(); + reporter.TryTakeOwnership("nobody is listening"); + + Stopwatch elapsed = Stopwatch.StartNew(); + reporter.WaitForClientToReadFailure(); + elapsed.Stop(); + + // Should give up after the no-client timeout, well short of the full one. + Assert.That(elapsed.ElapsedMilliseconds, Is.GreaterThanOrEqualTo(NoClientTimeoutMs - 50)); + Assert.That(elapsed.ElapsedMilliseconds, Is.LessThan(ReportTimeoutMs)); + } + } + + [TestCase] + public void WaitReturnsOnceAClientReadsTheFailure() + { + using (MountFailureReporter reporter = CreateReporter()) + { + reporter.OnNamedPipeReady(); + reporter.TryTakeOwnership("the real reason"); + reporter.OnStatusDelivered(carriedMountFailure: true); + + Stopwatch elapsed = Stopwatch.StartNew(); + reporter.WaitForClientToReadFailure(); + elapsed.Stop(); + + // Returns as soon as the failure was delivered, plus the drain. + Assert.That(elapsed.ElapsedMilliseconds, Is.LessThan(NoClientTimeoutMs)); + } + } + + [TestCase] + public void DeliveringANonFailureStatusDoesNotReleaseTheWait() + { + using (MountFailureReporter reporter = CreateReporter()) + { + reporter.OnNamedPipeReady(); + + // A client polled while the mount was still in progress. That proves + // somebody is listening, but it did not carry the failure. + reporter.OnStatusDelivered(carriedMountFailure: false); + reporter.TryTakeOwnership("the real reason"); + + Stopwatch elapsed = Stopwatch.StartNew(); + reporter.WaitForClientToReadFailure(); + elapsed.Stop(); + + // Must wait for the failure itself to be read, using the longer timeout + // because a client is known to be polling. + Assert.That(elapsed.ElapsedMilliseconds, Is.GreaterThanOrEqualTo(ReportTimeoutMs - 100)); + } + } + + [TestCase] + public void WaitIsReleasedByAClientThatReadsTheFailureLater() + { + using (MountFailureReporter reporter = CreateReporter()) + { + reporter.OnNamedPipeReady(); + reporter.OnStatusDelivered(carriedMountFailure: false); + reporter.TryTakeOwnership("the real reason"); + + Task client = Task.Run(() => + { + Thread.Sleep(100); + reporter.OnStatusDelivered(carriedMountFailure: true); + }); + + Stopwatch elapsed = Stopwatch.StartNew(); + reporter.WaitForClientToReadFailure(); + elapsed.Stop(); + + client.Wait(TimeSpan.FromSeconds(5)).ShouldBeTrue(); + + // Released by the delivery, not by the timeout. + Assert.That(elapsed.ElapsedMilliseconds, Is.LessThan(ReportTimeoutMs - 100)); + } + } + + [TestCase] + public void FormatFailureAppliesArguments() + { + MountFailureReporter.FormatFailure("Error: {0}", new object[] { "boom" }) + .ShouldEqual("Error: boom"); + } + + [TestCase] + public void FormatFailureLeavesTheMessageAloneWhenThereAreNoArguments() + { + // A pre-built message can legitimately 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] + public void FormatFailureFallsBackWhenTheFormatStringIsMalformed() + { + // Failure text is built from exception messages and server responses, which + // can contain unbalanced braces. Formatting must never throw and mask the + // failure being reported. + string result = MountFailureReporter.FormatFailure("unbalanced {0} and {", new object[] { "value" }); + + result.ShouldEqual("unbalanced {0} and {"); + } + + private static MountFailureReporter CreateReporter() + { + return new MountFailureReporter(new MockTracer(), ReportTimeoutMs, NoClientTimeoutMs, DrainMs); + } + } +} From 974b651c71ccd189974d3a1f4910a659883e6644 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Wed, 2 Sep 2026 10:19:55 -0700 Subject: [PATCH 04/35] Tighten the MountFailureReporter surface and its test coverage A second self-review pass over the extraction. No defects were found -- every finding was a judgement call, and the security, async and rollout lenses were clean -- but three were worth acting on. Narrow the public surface. The three Default* timeout constants are read nowhere outside the class, so they are now internal. GVFS.Common already grants InternalsVisibleTo to GVFS.UnitTests, so the tests still see them. The class and the ownership enum stay public because GVFS.Mount consumes them across an assembly boundary. Cover the public constructor. Every test built the reporter through the internal test constructor, so nothing exercised the public one or its forwarding of the Default* constants. Swapping DefaultReportTimeoutMs and DefaultNoClientTimeoutMs would have shipped unnoticed. Two tests now close that: one drives the public constructor down the no-client path, and one asserts the ordering invariants the constants have to satisfy. Mutation-checked by swapping the two values. Cut the fixture's wall-clock cost. One test has to wait out the long timeout to prove the long timeout was selected, and the fixture's own constants made that 2000ms. They are arbitrary -- they only have to be far enough apart to tell the two paths apart -- so the long one drops to 600ms. The assertion now checks against the short timeout rather than a fixed margin below the long one, which is what actually proves the selection. Fixture time falls from ~2.9s to ~2.0s. Also: document why InProcessMount deliberately never disposes the reporter, and stop discarding the result of the stand-in server's Wait so a hung server task is reported instead of silently faulting. Measured on this machine: 977 tests pass, and the timing assertions survived six consecutive runs with all twelve cores saturated. Assisted-by: Claude Opus 5 Signed-off-by: Tyrie Vella --- .../NamedPipes/MountFailureReporter.cs | 10 +++- .../Common/MountFailureReporterTests.cs | 58 +++++++++++++++++-- .../WaitUntilMountedFailureReportingTests.cs | 6 +- 3 files changed, 64 insertions(+), 10 deletions(-) diff --git a/GVFS/GVFS.Common/NamedPipes/MountFailureReporter.cs b/GVFS/GVFS.Common/NamedPipes/MountFailureReporter.cs index a1260d2cac..07d417059d 100644 --- a/GVFS/GVFS.Common/NamedPipes/MountFailureReporter.cs +++ b/GVFS/GVFS.Common/NamedPipes/MountFailureReporter.cs @@ -52,13 +52,13 @@ public sealed class MountFailureReporter : IDisposable /// /// Cap on how long to wait once a client is known to be polling. /// - public const int DefaultReportTimeoutMs = 2000; + 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. /// - public const int DefaultNoClientTimeoutMs = 500; + internal const int DefaultNoClientTimeoutMs = 500; /// /// Grace period after a client reads the failure. Any client can satisfy the @@ -66,7 +66,7 @@ public sealed class MountFailureReporter : IDisposable /// "gvfs status" polls the same pipe. Keeping this above the client's 100ms poll /// interval means the mounting client still gets its answer on its next poll. /// - public const int DefaultDrainMs = 150; + internal const int DefaultDrainMs = 150; private readonly ITracer tracer; private readonly int reportTimeoutMs; @@ -216,6 +216,10 @@ public void WaitForClientToReadFailure() public void Dispose() { + // InProcessMount deliberately does not call this: it holds the reporter for + // the life of the process and leaves through Environment.Exit, which reclaims + // the handle. Do not dispose while a thread is inside + // WaitForClientToReadFailure -- WaitOne on a disposed handle throws. this.failureReported.Dispose(); } } diff --git a/GVFS/GVFS.UnitTests/Common/MountFailureReporterTests.cs b/GVFS/GVFS.UnitTests/Common/MountFailureReporterTests.cs index 18964f174c..eff6091bdd 100644 --- a/GVFS/GVFS.UnitTests/Common/MountFailureReporterTests.cs +++ b/GVFS/GVFS.UnitTests/Common/MountFailureReporterTests.cs @@ -17,7 +17,10 @@ namespace GVFS.UnitTests.Common [TestFixture] public class MountFailureReporterTests { - private const int ReportTimeoutMs = 2000; + // Deliberately much smaller than the production defaults: these only have to be + // far enough apart to tell the two timeout paths apart, and one test has to wait + // the long one out in real time. + private const int ReportTimeoutMs = 600; private const int NoClientTimeoutMs = 200; private const int DrainMs = 20; @@ -96,7 +99,7 @@ public void WaitUsesTheShortTimeoutWhenNoClientHasReceivedAStatus() elapsed.Stop(); // Should give up after the no-client timeout, well short of the full one. - Assert.That(elapsed.ElapsedMilliseconds, Is.GreaterThanOrEqualTo(NoClientTimeoutMs - 50)); + Assert.That(elapsed.ElapsedMilliseconds, Is.GreaterThan(NoClientTimeoutMs / 2)); Assert.That(elapsed.ElapsedMilliseconds, Is.LessThan(ReportTimeoutMs)); } } @@ -136,8 +139,9 @@ public void DeliveringANonFailureStatusDoesNotReleaseTheWait() elapsed.Stop(); // Must wait for the failure itself to be read, using the longer timeout - // because a client is known to be polling. - Assert.That(elapsed.ElapsedMilliseconds, Is.GreaterThanOrEqualTo(ReportTimeoutMs - 100)); + // because a client is known to be polling. Asserting above the SHORT + // timeout is what proves the long path was selected. + Assert.That(elapsed.ElapsedMilliseconds, Is.GreaterThan(NoClientTimeoutMs * 2)); } } @@ -152,7 +156,7 @@ public void WaitIsReleasedByAClientThatReadsTheFailureLater() Task client = Task.Run(() => { - Thread.Sleep(100); + Thread.Sleep(50); reporter.OnStatusDelivered(carriedMountFailure: true); }); @@ -163,10 +167,52 @@ public void WaitIsReleasedByAClientThatReadsTheFailureLater() client.Wait(TimeSpan.FromSeconds(5)).ShouldBeTrue(); // Released by the delivery, not by the timeout. - Assert.That(elapsed.ElapsedMilliseconds, Is.LessThan(ReportTimeoutMs - 100)); + Assert.That(elapsed.ElapsedMilliseconds, Is.LessThan(ReportTimeoutMs)); + } + } + + [TestCase] + public void ProductionDefaultsSelectTheShorterTimeoutWhenNoClientIsListening() + { + // Everything else builds the reporter through the internal test constructor, + // so this is the only cover for the public constructor and its forwarding of + // the Default* constants. Without it, swapping DefaultReportTimeoutMs and + // DefaultNoClientTimeoutMs would ship unnoticed. + using (MountFailureReporter reporter = new MountFailureReporter(new MockTracer())) + { + reporter.OnNamedPipeReady(); + reporter.TryTakeOwnership("nobody is listening"); + + Stopwatch elapsed = Stopwatch.StartNew(); + reporter.WaitForClientToReadFailure(); + elapsed.Stop(); + + // The no-client timeout must be the one used, and it must be meaningfully + // shorter than the timeout used once a client is known to be polling. + Assert.That(elapsed.ElapsedMilliseconds, Is.GreaterThan(MountFailureReporter.DefaultNoClientTimeoutMs / 2)); + Assert.That(elapsed.ElapsedMilliseconds, Is.LessThan(MountFailureReporter.DefaultReportTimeoutMs)); } } + [TestCase] + public void ProductionDefaultsAreOrderedSensibly() + { + // A failed mount holds the mount lock and the named pipe until it exits, so + // these bound how long a user's next "gvfs mount" is delayed. + Assert.That( + MountFailureReporter.DefaultNoClientTimeoutMs, + Is.LessThan(MountFailureReporter.DefaultReportTimeoutMs), + "Waiting for a client that has never polled must not take longer than waiting for one that has."); + + // The drain has to outlast the client's 100ms GetStatus poll interval, or a + // concurrent "gvfs status" can consume the report signal and the mounting + // client never gets its answer. + Assert.That( + MountFailureReporter.DefaultDrainMs, + Is.GreaterThan(100), + "The drain must exceed the client's GetStatus poll interval."); + } + [TestCase] public void FormatFailureAppliesArguments() { diff --git a/GVFS/GVFS.UnitTests/Common/WaitUntilMountedFailureReportingTests.cs b/GVFS/GVFS.UnitTests/Common/WaitUntilMountedFailureReportingTests.cs index 2f89013c88..cbe6288222 100644 --- a/GVFS/GVFS.UnitTests/Common/WaitUntilMountedFailureReportingTests.cs +++ b/GVFS/GVFS.UnitTests/Common/WaitUntilMountedFailureReportingTests.cs @@ -90,7 +90,11 @@ private static string RunAgainstMountFailedServer(string mountError) } finally { - serverTask.Wait(TimeSpan.FromSeconds(5)); + // 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."); } } } From 3f62f715ff9ec50361beff41f58b970aecf80dd8 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Wed, 23 Sep 2026 11:48:06 -0700 Subject: [PATCH 05/35] Use worktree-specific git index paths Route index generation, repair, FastFetch, and dehydrate backups through GitIndexPath. Reject full dehydrate for linked worktrees before it moves the .git file. Assisted-by: GPT-5.6 Sol Signed-off-by: Tyrie Vella --- GVFS/FastFetch/CheckoutPrefetcher.cs | 4 +- GVFS/GVFS.Common/Git/GitIndexGenerator.cs | 11 +- .../CommandLine/GitIndexPathCallerTests.cs | 185 ++++++++++++++++++ GVFS/GVFS/CommandLine/DehydrateVerb.cs | 56 ++++-- GVFS/GVFS/RepairJobs/GitIndexRepairJob.cs | 4 +- 5 files changed, 234 insertions(+), 26 deletions(-) create mode 100644 GVFS/GVFS.UnitTests/CommandLine/GitIndexPathCallerTests.cs diff --git a/GVFS/FastFetch/CheckoutPrefetcher.cs b/GVFS/FastFetch/CheckoutPrefetcher.cs index 4064aa7bc0..92afc28244 100644 --- a/GVFS/FastFetch/CheckoutPrefetcher.cs +++ b/GVFS/FastFetch/CheckoutPrefetcher.cs @@ -136,7 +136,7 @@ public override void Prefetch(string branchOrCommit, bool isBranch) // All the slow stuff is over, so we will now move the final index into .git\index, 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/Git/GitIndexGenerator.cs b/GVFS/GVFS.Common/Git/GitIndexGenerator.cs index 19c5981358..07b441716b 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; @@ -245,11 +247,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.UnitTests/CommandLine/GitIndexPathCallerTests.cs b/GVFS/GVFS.UnitTests/CommandLine/GitIndexPathCallerTests.cs new file mode 100644 index 0000000000..f3ef2e31d3 --- /dev/null +++ b/GVFS/GVFS.UnitTests/CommandLine/GitIndexPathCallerTests.cs @@ -0,0 +1,185 @@ +using GVFS.CommandLine; +using GVFS.Common; +using GVFS.Common.Git; +using GVFS.RepairJobs; +using GVFS.Tests.Should; +using GVFS.UnitTests.Mock.Common; +using NUnit.Framework; +using System; +using System.IO; + +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); + 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)] + [TestCase(true, true)] + 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)] + [TestCase(true, true)] + 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); + + result.ShouldBeTrue(); + 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(); + } + } + + private GVFSEnlistment CreateEnlistment(bool isWorktree) + { + string 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, "https://mock/repo", gitBinPath, authentication: null); + } + + string worktreePath = Path.Combine(this.testRoot, "linked-worktree"); + 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: "https://mock/repo"); + } + } +} diff --git a/GVFS/GVFS/CommandLine/DehydrateVerb.cs b/GVFS/GVFS/CommandLine/DehydrateVerb.cs index 28ce303572..4b8b092572 100644 --- a/GVFS/GVFS/CommandLine/DehydrateVerb.cs +++ b/GVFS/GVFS/CommandLine/DehydrateVerb.cs @@ -144,6 +144,11 @@ protected override void Execute(GVFSEnlistment enlistment) this.ReportErrorAndExit("Cannot combine --full with --folders."); } + if (fullDehydrate && enlistment.IsWorktree) + { + this.ReportErrorAndExit("Dehydrate --full is not supported for git worktrees."); + } + if (!this.Confirmed && fullDehydrate) { this.Output.WriteLine( @@ -634,13 +639,7 @@ private bool TryBackupNonSrcFiles(ITracer tracer, GVFSEnlistment enlistment, str } // ... 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,8 +651,8 @@ 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)) { errorMessage = "Failed to backup .git lock files."; return false; @@ -721,13 +720,7 @@ private bool TryBackupFiles(ITracer tracer, GVFSEnlistment enlistment, string ba } // ... 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 +732,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)) { return false; } @@ -758,6 +751,33 @@ private bool TryBackupFiles(ITracer tracer, GVFSEnlistment enlistment, string ba return true; } + internal bool TryBackupGitIndex( + ITracer tracer, + GVFSEnlistment enlistment, + string backupGit, + bool move, + out string errorMessage) + { + string backupIndexPath = Path.Combine(backupGit, GVFSConstants.DotGit.IndexName); + Action backupIndex = move + ? () => File.Move(enlistment.GitIndexPath, backupIndexPath) + : () => File.Copy(enlistment.GitIndexPath, backupIndexPath); + + return this.TryIO(tracer, backupIndex, "Backup the git index", out errorMessage); + } + + internal bool TryBackupGitLocks( + ITracer tracer, + GVFSEnlistment enlistment, + string backupGit, + bool move) + { + string gitIndexDirectory = Path.GetDirectoryName(enlistment.GitIndexPath); + return move + ? this.TryBackupFilesInFolder(tracer, gitIndexDirectory, backupGit, searchPattern: "*.lock") + : this.TryCopyFilesInFolder(tracer, gitIndexDirectory, backupGit, searchPattern: "*.lock"); + } + private bool TryBackupFilesInFolder(ITracer tracer, string folderPath, string backupPath, string searchPattern, params string[] filenamesToSkip) { string errorMessage; 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; } From bdb1223b0547fe44cc80aa658906a4779a414a7e Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Wed, 23 Sep 2026 13:28:37 -0700 Subject: [PATCH 06/35] Harden worktree dehydrate safeguards Reject full dehydrate when linked worktrees exist. Store worktree backups beside each worktree with unique paths. Preserve backup errors and add regression coverage. Assisted-by: GPT-5.6 Sol Signed-off-by: Tyrie Vella --- GVFS/FastFetch/CheckoutPrefetcher.cs | 2 +- .../CommandLine/GitIndexPathCallerTests.cs | 122 +++++++++++++++++- GVFS/GVFS/CommandLine/DehydrateVerb.cs | 82 ++++++++++-- 3 files changed, 187 insertions(+), 19 deletions(-) diff --git a/GVFS/FastFetch/CheckoutPrefetcher.cs b/GVFS/FastFetch/CheckoutPrefetcher.cs index 92afc28244..2b2f827979 100644 --- a/GVFS/FastFetch/CheckoutPrefetcher.cs +++ b/GVFS/FastFetch/CheckoutPrefetcher.cs @@ -134,7 +134,7 @@ 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 = this.Enlistment.GitIndexPath; this.Tracer.RelatedEvent(EventLevel.Informational, "MoveUpdatedIndexToFinalLocation", new EventMetadata() { { "UpdatedIndex", indexGen.TemporaryIndexFilePath }, { "Index", indexPath } }); diff --git a/GVFS/GVFS.UnitTests/CommandLine/GitIndexPathCallerTests.cs b/GVFS/GVFS.UnitTests/CommandLine/GitIndexPathCallerTests.cs index f3ef2e31d3..065754fe71 100644 --- a/GVFS/GVFS.UnitTests/CommandLine/GitIndexPathCallerTests.cs +++ b/GVFS/GVFS.UnitTests/CommandLine/GitIndexPathCallerTests.cs @@ -75,7 +75,6 @@ public void GitIndexRepairJobUsesEnlistmentIndexPath(bool isWorktree) [TestCase(false, false)] [TestCase(false, true)] [TestCase(true, false)] - [TestCase(true, true)] public void DehydrateBacksUpEnlistmentIndexPath(bool isWorktree, bool move) { GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree); @@ -112,7 +111,6 @@ public void DehydrateBacksUpEnlistmentIndexPath(bool isWorktree, bool move) [TestCase(false, false)] [TestCase(false, true)] [TestCase(true, false)] - [TestCase(true, true)] public void DehydrateBacksUpOnlyCurrentWorkingTreeLocks(bool isWorktree, bool move) { GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree); @@ -135,9 +133,10 @@ public void DehydrateBacksUpOnlyCurrentWorkingTreeLocks(bool isWorktree, bool mo new MockTracer(), enlistment, backupGit, - move); + move, + out string errorMessage); - result.ShouldBeTrue(); + result.ShouldEqual(true, errorMessage); File.ReadAllText(Path.Combine(backupGit, GVFSConstants.DotGit.IndexName + ".lock")).ShouldEqual("working tree lock"); File.Exists(indexLockPath).ShouldEqual(!move); if (isWorktree) @@ -147,6 +146,121 @@ public void DehydrateBacksUpOnlyCurrentWorkingTreeLocks(bool isWorktree, bool mo } } + [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)); + + 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."); + } + + [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(false)] + [TestCase(true)] + public void BackupRootIsUniqueAndWorktreeLocal(bool isWorktree) + { + GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree); + DateTime timestamp = new DateTime(2026, 9, 23, 13, 0, 0, 123); + + string backupRoot = DehydrateVerb.GetBackupRoot(enlistment, timestamp); + + string expectedParent = isWorktree + ? Path.GetDirectoryName(enlistment.WorkingDirectoryBackingRoot) + : enlistment.PrimaryEnlistmentRoot; + string expectedSubfolder = isWorktree + ? Path.Combine("dehydrate_backup", enlistment.Worktree.Name) + : "dehydrate_backup"; + backupRoot.ShouldEqual( + Path.Combine( + expectedParent, + expectedSubfolder, + timestamp.ToString("yyyyMMdd_HHmmss_fffffff"))); + } + + [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.ShouldNotBeNull(); + } + + [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(); + } + private GVFSEnlistment CreateEnlistment(bool isWorktree) { string gitBinPath = Path.Combine(this.testRoot, "git.exe"); diff --git a/GVFS/GVFS/CommandLine/DehydrateVerb.cs b/GVFS/GVFS/CommandLine/DehydrateVerb.cs index 4b8b092572..7adf9bd19b 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,10 +145,7 @@ protected override void Execute(GVFSEnlistment enlistment) this.ReportErrorAndExit("Cannot combine --full with --folders."); } - if (fullDehydrate && enlistment.IsWorktree) - { - this.ReportErrorAndExit("Dehydrate --full is not supported for git worktrees."); - } + this.ValidateFullDehydrate(tracer, enlistment); if (!this.Confirmed && fullDehydrate) { @@ -207,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) @@ -652,9 +650,8 @@ private bool TryBackupNonSrcFiles(ITracer tracer, GVFSEnlistment enlistment, str } // ... backup all lock files for this working tree - if (!this.TryBackupGitLocks(tracer, enlistment, backupGit, move: false)) + if (!this.TryBackupGitLocks(tracer, enlistment, backupGit, move: false, out errorMessage)) { - errorMessage = "Failed to backup .git lock files."; return false; } @@ -733,7 +730,7 @@ private bool TryBackupFiles(ITracer tracer, GVFSEnlistment enlistment, string ba } // ... backup all lock files for this working tree - if (!this.TryBackupGitLocks(tracer, enlistment, backupGit, move: true)) + if (!this.TryBackupGitLocks(tracer, enlistment, backupGit, move: true, out errorMessage)) { return false; } @@ -766,16 +763,73 @@ internal bool TryBackupGitIndex( return this.TryIO(tracer, backupIndex, "Backup the git index", out errorMessage); } + 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."); + } + + if (GVFSEnlistment.GetKnownWorktreePaths(enlistment.DotGitRoot).Length > 0) + { + this.ReportErrorAndExit( + tracer, + "Dehydrate --full is not supported while linked worktrees exist. Remove all linked worktrees first."); + } + } + + internal static string GetBackupRoot(GVFSEnlistment enlistment, DateTime timestamp) + { + string backupParent = enlistment.PrimaryEnlistmentRoot; + string backupSubfolder = string.Empty; + if (enlistment.IsWorktree) + { + backupParent = Path.GetDirectoryName(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) + bool move, + out string errorMessage) { string gitIndexDirectory = Path.GetDirectoryName(enlistment.GitIndexPath); - return move - ? this.TryBackupFilesInFolder(tracer, gitIndexDirectory, backupGit, searchPattern: "*.lock") - : this.TryCopyFilesInFolder(tracer, gitIndexDirectory, backupGit, searchPattern: "*.lock"); + bool backupSucceeded = false; + if (!this.TryIO( + tracer, + () => + { + backupSucceeded = move + ? this.TryBackupFilesInFolder(tracer, gitIndexDirectory, backupGit, searchPattern: "*.lock") + : this.TryCopyFilesInFolder(tracer, gitIndexDirectory, backupGit, searchPattern: "*.lock"); + }, + "Backup git lock files", + out errorMessage)) + { + return false; + } + + if (!backupSucceeded) + { + errorMessage = "Failed to backup .git lock files."; + } + + return backupSucceeded; } private bool TryBackupFilesInFolder(ITracer tracer, string folderPath, string backupPath, string searchPattern, params string[] filenamesToSkip) @@ -788,7 +842,7 @@ private bool TryBackupFilesInFolder(ITracer tracer, string folderPath, string ba { if (!this.TryIO( tracer, - () => File.Move(file, file.Replace(folderPath, backupPath)), + () => File.Move(file, Path.Combine(backupPath, fileName)), $"Backing up {Path.GetFileName(file)}", out errorMessage)) { @@ -810,7 +864,7 @@ private bool TryCopyFilesInFolder(ITracer tracer, string folderPath, string back { if (!this.TryIO( tracer, - () => File.Copy(file, file.Replace(folderPath, backupPath)), + () => File.Copy(file, Path.Combine(backupPath, fileName)), $"Backing up {Path.GetFileName(file)}", out errorMessage)) { From f6fbfc415538c007bd9b6a266c356d72f9c1996f Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Thu, 24 Sep 2026 12:03:54 -0700 Subject: [PATCH 07/35] Improve missing disk layout metadata error When the disk layout version is missing from .gvfs metadata, report that the metadata is incomplete and tell the user to reclone the enlistment. Return a distinct mount exit code for that case so automation can identify it. Keep generic upgrade failures on the existing error path. Cover both a missing layout key and a missing repo metadata file in mount functional tests. Assisted-by: GitHub Copilot Signed-off-by: Tyrie Vella --- .../DiskLayoutUpgrades/DiskLayoutUpgrade.cs | 64 +++++++++++++++++-- GVFS/GVFS.Common/RepoMetadata.cs | 15 ++++- GVFS/GVFS.Common/ReturnCode.cs | 1 + .../Tests/EnlistmentPerFixture/MountTests.cs | 34 +++++++++- .../GVFS.FunctionalTests/Tools/GVFSHelpers.cs | 39 ++++++++++- GVFS/GVFS/CommandLine/MountVerb.cs | 16 +++-- 6 files changed, 155 insertions(+), 14 deletions(-) diff --git a/GVFS/GVFS.Common/DiskLayoutUpgrades/DiskLayoutUpgrade.cs b/GVFS/GVFS.Common/DiskLayoutUpgrades/DiskLayoutUpgrade.cs index d268191abc..dcb0668fe5 100644 --- a/GVFS/GVFS.Common/DiskLayoutUpgrades/DiskLayoutUpgrade.cs +++ b/GVFS/GVFS.Common/DiskLayoutUpgrades/DiskLayoutUpgrade.cs @@ -17,9 +17,18 @@ public abstract class DiskLayoutUpgrade protected abstract bool IsMajorUpgrade { get; } public static bool TryRunAllUpgrades(string enlistmentRoot) + { + string error; + ReturnCode returnCode; + return TryRunAllUpgrades(enlistmentRoot, out error, out returnCode); + } + + 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 +40,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 +49,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 +66,8 @@ public static bool TryRunAllUpgrades(string enlistmentRoot) { StartLogFile(enlistmentRoot, tracer); tracer.RelatedError(e.ToString()); + error = e.ToString(); + returnCode = ReturnCode.GenericError; return false; } finally @@ -65,8 +78,15 @@ public static bool TryRunAllUpgrades(string enlistmentRoot) } public static bool TryCheckDiskLayoutVersion(ITracer tracer, string enlistmentRoot, out string error) + { + ReturnCode returnCode; + return TryCheckDiskLayoutVersion(tracer, enlistmentRoot, out error, out returnCode); + } + + 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 @@ -75,6 +95,7 @@ public static bool TryCheckDiskLayoutVersion(ITracer tracer, string enlistmentRo { 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 +106,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 +116,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 +135,16 @@ public static bool TryCheckDiskLayoutVersion(ITracer tracer, string enlistmentRo RepoMetadata.Shutdown(); } - error = "Failed to read disk layout version. " + ConsoleHelper.GetGVFSLogMessage(enlistmentRoot); + if (error == RepoMetadata.MissingDiskLayoutVersionMessage) + { + returnCode = ReturnCode.MissingDiskLayoutVersion; + } + else + { + returnCode = ReturnCode.GenericError; + error = "Failed to read disk layout version. " + ConsoleHelper.GetGVFSLogMessage(enlistmentRoot); + } + return false; } @@ -257,12 +289,18 @@ private static bool CheckLayoutVersionWasIncremented(JsonTracer tracer, string e } private static bool TryFindUpgrade(JsonTracer tracer, string enlistmentRoot, out DiskLayoutUpgrade upgrade) + { + string error; + ReturnCode returnCode; + return TryFindUpgrade(tracer, enlistmentRoot, out upgrade, out error, out returnCode); + } + + 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 +338,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 +349,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 +376,14 @@ 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)) { return false; } } error = null; + returnCode = ReturnCode.Success; return true; } diff --git a/GVFS/GVFS.Common/RepoMetadata.cs b/GVFS/GVFS.Common/RepoMetadata.cs index ea3d3ebf1e..d7475a6289 100644 --- a/GVFS/GVFS.Common/RepoMetadata.cs +++ b/GVFS/GVFS.Common/RepoMetadata.cs @@ -1,4 +1,4 @@ -using GVFS.Common.FileSystem; +using GVFS.Common.FileSystem; using GVFS.Common.Tracing; using System; using System.Collections.Generic; @@ -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. Recloning this enlistment is the recommended fix."; + 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.FunctionalTests/Tests/EnlistmentPerFixture/MountTests.cs b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/MountTests.cs index 576534d1cc..429d123c90 100644 --- a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/MountTests.cs +++ b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/MountTests.cs @@ -1,4 +1,4 @@ -using GVFS.FunctionalTests.FileSystemRunners; +using GVFS.FunctionalTests.FileSystemRunners; using GVFS.FunctionalTests.Properties; using GVFS.FunctionalTests.Should; using GVFS.FunctionalTests.Tools; @@ -18,6 +18,7 @@ namespace GVFS.FunctionalTests.Tests.EnlistmentPerFixture public class MountTests : TestsWithEnlistmentPerFixture { private const int GVFSGenericError = 3; + private const int GVFSMissingDiskLayoutVersion = 12; private const uint GenericRead = 2147483648; private const uint FileFlagBackupSemantics = 3355443; private readonly int fileDeletedBackgroundOperationCode; @@ -99,6 +100,33 @@ 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, + "The .gvfs metadata is incomplete. Recloning this enlistment is the recommended fix."); + } + finally + { + GVFSHelpers.SaveDiskLayoutVersion(this.Enlistment.DotGVFSRoot, majorVersion, minorVersion); + } + + this.Enlistment.MountGVFS(); + } + [TestCase] public void MountFailsWhenNoOnDiskVersion() { @@ -126,7 +154,9 @@ public void MountFailsWhenNoOnDiskVersion() this.fileSystem.MoveFile(versionDatabasePath, tempDatabasePath); versionDatabasePath.ShouldNotExistOnDisk(this.fileSystem); - this.MountShouldFail("Failed to upgrade repo disk layout"); + this.MountShouldFail( + GVFSMissingDiskLayoutVersion, + "The .gvfs metadata is incomplete. Recloning this enlistment is the recommended fix."); } finally { diff --git a/GVFS/GVFS.FunctionalTests/Tools/GVFSHelpers.cs b/GVFS/GVFS.FunctionalTests/Tools/GVFSHelpers.cs index d1b356f3d8..ea0bdae6d4 100644 --- a/GVFS/GVFS.FunctionalTests/Tools/GVFSHelpers.cs +++ b/GVFS/GVFS.FunctionalTests/Tools/GVFSHelpers.cs @@ -1,4 +1,4 @@ -using GVFS.Common; +using GVFS.Common; using GVFS.FunctionalTests.FileSystemRunners; using GVFS.FunctionalTests.Should; using GVFS.Tests.Should; @@ -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); @@ -366,6 +371,38 @@ private static void SavePersistedValue(string dotGVFSRoot, string key, string va File.WriteAllText(metadataPath, newRepoMetadataContents); } + private static void DeletePersistedValue(string dotGVFSRoot, string key) + { + 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)) + using (StreamReader reader = new StreamReader(fs)) + { + while (!reader.EndOfStream) + { + json = reader.ReadLine(); + json.Substring(0, 2).ShouldEqual("A "); + + KeyValuePair kvp = GVFSJsonOptions.Deserialize>(json.Substring(2)); + if (kvp.Key != key) + { + repoMetadata[kvp.Key] = kvp.Value; + } + } + } + + string newRepoMetadataContents = string.Empty; + + foreach (KeyValuePair kvp in repoMetadata) + { + newRepoMetadataContents += "A " + GVFSJsonOptions.Serialize(kvp).Trim() + "\r\n"; + } + + File.WriteAllText(metadataPath, newRepoMetadataContents); + } + [DllImport(PrjFSLibPath, EntryPoint = "PrjFS_RegisterForOfflineIO")] private static extern uint PrjFSRegisterForOfflineIO(); diff --git a/GVFS/GVFS/CommandLine/MountVerb.cs b/GVFS/GVFS/CommandLine/MountVerb.cs index 5ea8c5781e..4a38b37dcc 100644 --- a/GVFS/GVFS/CommandLine/MountVerb.cs +++ b/GVFS/GVFS/CommandLine/MountVerb.cs @@ -1,4 +1,4 @@ -using GVFS.Common; +using GVFS.Common; using GVFS.Common.Http; using GVFS.Common.NamedPipes; using GVFS.Common.Tracing; @@ -130,15 +130,23 @@ protected override void PreCreateEnlistment() } } - if (!DiskLayoutUpgrade.TryRunAllUpgrades(enlistmentRoot)) + string diskLayoutError; + ReturnCode diskLayoutReturnCode; + if (!DiskLayoutUpgrade.TryRunAllUpgrades(enlistmentRoot, out diskLayoutError, out diskLayoutReturnCode)) { + if (diskLayoutReturnCode == ReturnCode.MissingDiskLayoutVersion) + { + this.ReportErrorAndExit(tracer: null, exitCode: diskLayoutReturnCode, error: "Error: " + diskLayoutError); + } + this.ReportErrorAndExit("Failed to upgrade repo disk layout. " + 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); } } From 881644ee58c2b474405f50cb3231cfd0f6a974ba Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Fri, 25 Sep 2026 16:52:58 -0700 Subject: [PATCH 08/35] Address missing layout review feedback Use structured return codes throughout disk layout version checks instead of deriving the missing-layout case from message text. Propagate the new return code through repair and dehydrate as well as mount. Keep functional tests tied to the production return code and message constants, and share repo metadata read/write helper logic in test utilities. Assisted-by: GitHub Copilot Signed-off-by: Tyrie Vella --- .../DiskLayoutUpgrades/DiskLayoutUpgrade.cs | 15 +--- GVFS/GVFS.Common/RepoMetadata.cs | 2 +- .../Tests/EnlistmentPerFixture/MountTests.cs | 12 ++-- .../GVFS.FunctionalTests/Tools/GVFSHelpers.cs | 72 ++++++------------- GVFS/GVFS/CommandLine/DehydrateVerb.cs | 5 +- GVFS/GVFS/CommandLine/MountVerb.cs | 2 +- GVFS/GVFS/CommandLine/RepairVerb.cs | 5 +- 7 files changed, 38 insertions(+), 75 deletions(-) diff --git a/GVFS/GVFS.Common/DiskLayoutUpgrades/DiskLayoutUpgrade.cs b/GVFS/GVFS.Common/DiskLayoutUpgrades/DiskLayoutUpgrade.cs index dcb0668fe5..a9a6e82c1f 100644 --- a/GVFS/GVFS.Common/DiskLayoutUpgrades/DiskLayoutUpgrade.cs +++ b/GVFS/GVFS.Common/DiskLayoutUpgrades/DiskLayoutUpgrade.cs @@ -16,13 +16,6 @@ public abstract class DiskLayoutUpgrade protected abstract int SourceMinorVersion { get; } protected abstract bool IsMajorUpgrade { get; } - public static bool TryRunAllUpgrades(string enlistmentRoot) - { - string error; - ReturnCode returnCode; - return TryRunAllUpgrades(enlistmentRoot, out error, out returnCode); - } - public static bool TryRunAllUpgrades(string enlistmentRoot, out string error, out ReturnCode returnCode) { majorVersionUpgrades = new Dictionary(); @@ -91,7 +84,7 @@ public static bool TryCheckDiskLayoutVersion(ITracer tracer, string enlistmentRo 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) { @@ -135,11 +128,7 @@ public static bool TryCheckDiskLayoutVersion(ITracer tracer, string enlistmentRo RepoMetadata.Shutdown(); } - if (error == RepoMetadata.MissingDiskLayoutVersionMessage) - { - returnCode = ReturnCode.MissingDiskLayoutVersion; - } - else + if (returnCode != ReturnCode.MissingDiskLayoutVersion) { returnCode = ReturnCode.GenericError; error = "Failed to read disk layout version. " + ConsoleHelper.GetGVFSLogMessage(enlistmentRoot); diff --git a/GVFS/GVFS.Common/RepoMetadata.cs b/GVFS/GVFS.Common/RepoMetadata.cs index d7475a6289..f3213903b9 100644 --- a/GVFS/GVFS.Common/RepoMetadata.cs +++ b/GVFS/GVFS.Common/RepoMetadata.cs @@ -1,4 +1,4 @@ -using GVFS.Common.FileSystem; +using GVFS.Common.FileSystem; using GVFS.Common.Tracing; using System; using System.Collections.Generic; diff --git a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/MountTests.cs b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/MountTests.cs index 429d123c90..ff2ad39b8c 100644 --- a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/MountTests.cs +++ b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/MountTests.cs @@ -1,4 +1,4 @@ -using GVFS.FunctionalTests.FileSystemRunners; +using GVFS.FunctionalTests.FileSystemRunners; using GVFS.FunctionalTests.Properties; using GVFS.FunctionalTests.Should; using GVFS.FunctionalTests.Tools; @@ -11,14 +11,16 @@ using System.IO; using System.Linq; using System.Runtime.InteropServices; +using RepoMetadata = GVFS.Common.RepoMetadata; +using ReturnCode = GVFS.Common.ReturnCode; namespace GVFS.FunctionalTests.Tests.EnlistmentPerFixture { [TestFixture] public class MountTests : TestsWithEnlistmentPerFixture { - private const int GVFSGenericError = 3; - private const int GVFSMissingDiskLayoutVersion = 12; + private const int GVFSGenericError = (int)ReturnCode.GenericError; + private const int GVFSMissingDiskLayoutVersion = (int)ReturnCode.MissingDiskLayoutVersion; private const uint GenericRead = 2147483648; private const uint FileFlagBackupSemantics = 3355443; private readonly int fileDeletedBackgroundOperationCode; @@ -117,7 +119,7 @@ public void MountFailsWithUniqueExitCodeWhenDiskLayoutVersionIsMissing() this.MountShouldFail( GVFSMissingDiskLayoutVersion, - "The .gvfs metadata is incomplete. Recloning this enlistment is the recommended fix."); + RepoMetadata.MissingDiskLayoutVersionMessage); } finally { @@ -156,7 +158,7 @@ public void MountFailsWhenNoOnDiskVersion() this.MountShouldFail( GVFSMissingDiskLayoutVersion, - "The .gvfs metadata is incomplete. Recloning this enlistment is the recommended fix."); + RepoMetadata.MissingDiskLayoutVersionMessage); } finally { diff --git a/GVFS/GVFS.FunctionalTests/Tools/GVFSHelpers.cs b/GVFS/GVFS.FunctionalTests/Tools/GVFSHelpers.cs index ea0bdae6d4..632a5869f7 100644 --- a/GVFS/GVFS.FunctionalTests/Tools/GVFSHelpers.cs +++ b/GVFS/GVFS.FunctionalTests/Tools/GVFSHelpers.cs @@ -1,4 +1,4 @@ -using GVFS.Common; +using GVFS.Common; using GVFS.FunctionalTests.FileSystemRunners; using GVFS.FunctionalTests.Should; using GVFS.Tests.Should; @@ -319,62 +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 "); - - KeyValuePair kvp = GVFSJsonOptions.Deserialize>(json.Substring(2)); - if (kvp.Key == key) - { - return kvp.Value; - } - } - } - - return null; + Dictionary repoMetadata = ReadPersistedValues(dotGVFSRoot); + string value; + repoMetadata.TryGetValue(key, out value); + return value; } private static void SavePersistedValue(string dotGVFSRoot, string key, string value) { - 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)) - using (StreamReader reader = new StreamReader(fs)) - { - while (!reader.EndOfStream) - { - json = reader.ReadLine(); - json.Substring(0, 2).ShouldEqual("A "); - - KeyValuePair kvp = GVFSJsonOptions.Deserialize>(json.Substring(2)); - repoMetadata.Add(kvp.Key, kvp.Value); - } - } - + Dictionary repoMetadata = ReadPersistedValues(dotGVFSRoot); repoMetadata[key] = value; - - string newRepoMetadataContents = string.Empty; - - foreach (KeyValuePair kvp in repoMetadata) - { - newRepoMetadataContents += "A " + GVFSJsonOptions.Serialize(kvp).Trim() + "\r\n"; - } - - File.WriteAllText(metadataPath, newRepoMetadataContents); + WritePersistedValues(dotGVFSRoot, repoMetadata); } private static void DeletePersistedValue(string dotGVFSRoot, string key) { - string metadataPath = Path.Combine(dotGVFSRoot, RepoMetadataName); + Dictionary repoMetadata = ReadPersistedValues(dotGVFSRoot); + repoMetadata.Remove(key); + WritePersistedValues(dotGVFSRoot, repoMetadata); + } + 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)) @@ -386,13 +353,16 @@ private static void DeletePersistedValue(string dotGVFSRoot, string key) json.Substring(0, 2).ShouldEqual("A "); KeyValuePair kvp = GVFSJsonOptions.Deserialize>(json.Substring(2)); - if (kvp.Key != key) - { - repoMetadata[kvp.Key] = kvp.Value; - } + repoMetadata[kvp.Key] = kvp.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/CommandLine/DehydrateVerb.cs b/GVFS/GVFS/CommandLine/DehydrateVerb.cs index 28ce303572..9c3922ede9 100644 --- a/GVFS/GVFS/CommandLine/DehydrateVerb.cs +++ b/GVFS/GVFS/CommandLine/DehydrateVerb.cs @@ -223,9 +223,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; diff --git a/GVFS/GVFS/CommandLine/MountVerb.cs b/GVFS/GVFS/CommandLine/MountVerb.cs index 4a38b37dcc..7a3091aba5 100644 --- a/GVFS/GVFS/CommandLine/MountVerb.cs +++ b/GVFS/GVFS/CommandLine/MountVerb.cs @@ -1,4 +1,4 @@ -using GVFS.Common; +using GVFS.Common; using GVFS.Common.Http; using GVFS.Common.NamedPipes; using GVFS.Common.Tracing; 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( From 17678b454d9e438d2c4fd1a0c1bdd1a6dd87a00f Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Wed, 30 Sep 2026 12:22:32 -0700 Subject: [PATCH 09/35] Capture and surface the real failure when placeholder hydration fails HydrateFile swallowed IOException and UnauthorizedAccessException without recording anything about them, so a failed hydration only ever produced "Failed to read " in telemetry with no exception type, HResult, or Win32 error code. That makes every such failure unattributable after the fact. HydrateFile on IPlatformFileSystem now has an `out Exception failure` parameter. WindowsFileSystem populates it with the caught exception instead of discarding it; the out parameter costs nothing on the (much more common) success path, so the ~36-40K files/s hydration pipeline is unaffected there. HydrateFilesStage and the `gvfs prefetch --hydrate` verb turn that exception into structured EventMetadata (exception type, HResult, and the Win32 error code derived from the HResult when it is Win32-facility) via a new HydrationFailureDiagnostics helper, so the fields are queryable in telemetry instead of being buried in a concatenated string. The existing "Failed to read " message text is unchanged. The per-thread activity summary also now reports a count per distinct failure signature, so a large failed prefetch can be triaged from the summary event alone instead of requiring one query per file. Checked whether the prior P/Invoke-based implementation (replaced by the managed FileStream rewrite) ever surfaced a Win32 error: it did not - `CreateFile` failures produced an invalid handle and the method returned false with no call to GetLastError. Neither implementation ever captured diagnostics here, so this is a long-standing gap rather than a regression. It is also a pure instrumentation change with no effect on control flow or return values for any input. Self-review found that the new failure-signature summary and the `--hydrate` verb's warning both defaulted to Keywords.None, so neither ever reached the telemetry pipe the aggregation was built for (the per-file error already did). Both now pass Keywords.Telemetry explicitly. Also added: a HydrateFilesStage test covering the aggregation path (the mock file system is now scriptable instead of always throwing), pinned Win32 HResult assertions in WindowsFileSystemTests instead of only checking non-zero, a missing-file test case, and null-argument guards in HydrationFailureDiagnostics. Assisted-by: Claude Sonnet 5 Signed-off-by: Tyrie Vella --- .../FileSystem/HydrationFailureDiagnostics.cs | 83 ++++++++++ .../FileSystem/IPlatformFileSystem.cs | 11 +- .../Prefetch/Pipeline/HydrateFilesStage.cs | 46 ++++-- .../WindowsFileSystem.cs | 15 +- .../HydrationFailureDiagnosticsTests.cs | 131 +++++++++++++++ GVFS/GVFS.UnitTests/Mock/Common/MockTracer.cs | 6 + .../Mock/FileSystem/MockPlatformFileSystem.cs | 15 +- .../Prefetch/HydrateFilesStageTests.cs | 151 ++++++++++++++++++ .../Windows/WindowsFileSystemTests.cs | 100 ++++++++++++ GVFS/GVFS/CommandLine/PrefetchVerb.cs | 9 +- 10 files changed, 548 insertions(+), 19 deletions(-) create mode 100644 GVFS/GVFS.Common/FileSystem/HydrationFailureDiagnostics.cs create mode 100644 GVFS/GVFS.UnitTests/Common/HydrationFailureDiagnosticsTests.cs create mode 100644 GVFS/GVFS.UnitTests/Prefetch/HydrateFilesStageTests.cs create mode 100644 GVFS/GVFS.UnitTests/Windows/WindowsFileSystemTests.cs 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/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.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.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/Mock/Common/MockTracer.cs b/GVFS/GVFS.UnitTests/Mock/Common/MockTracer.cs index 47ad66e295..be94a0715e 100644 --- a/GVFS/GVFS.UnitTests/Mock/Common/MockTracer.cs +++ b/GVFS/GVFS.UnitTests/Mock/Common/MockTracer.cs @@ -19,6 +19,7 @@ public MockTracer() this.RelatedEventNames = new List(); this.RelatedEventKeywords = new List(); this.RelatedEventMetadata = new List(); + this.StopMetadata = new List(); } public MockTracer StartActivityTracer { get; private set; } @@ -34,6 +35,10 @@ 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; } + public void WaitForRelatedEvent() { this.waitEvent.WaitOne(); @@ -144,6 +149,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/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/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/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); } }); From 0ccec54f6713ae067321bbddeacff982fd53a03b Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Tue, 13 Oct 2026 13:02:36 -0700 Subject: [PATCH 10/35] Address disk layout review follow-up Qualify the missing disk layout recovery guidance and keep the log pointer on that path. Remove unused disk layout helper overloads, make the mount upgrade failure branch explicit, and pin the functional test to the shipped exit-code contract. Add RepoMetadata unit coverage for missing and invalid disk layout values. Assisted-by: GitHub Copilot Signed-off-by: Tyrie Vella --- .../DiskLayoutUpgrades/DiskLayoutUpgrade.cs | 18 ++-- GVFS/GVFS.Common/RepoMetadata.cs | 2 +- .../Tests/EnlistmentPerFixture/MountTests.cs | 12 +-- .../Common/RepoMetadataTests.cs | 82 +++++++++++++++++++ GVFS/GVFS/CommandLine/MountVerb.cs | 6 +- 5 files changed, 98 insertions(+), 22 deletions(-) create mode 100644 GVFS/GVFS.UnitTests/Common/RepoMetadataTests.cs diff --git a/GVFS/GVFS.Common/DiskLayoutUpgrades/DiskLayoutUpgrade.cs b/GVFS/GVFS.Common/DiskLayoutUpgrades/DiskLayoutUpgrade.cs index a9a6e82c1f..2dccefa75b 100644 --- a/GVFS/GVFS.Common/DiskLayoutUpgrades/DiskLayoutUpgrade.cs +++ b/GVFS/GVFS.Common/DiskLayoutUpgrades/DiskLayoutUpgrade.cs @@ -70,12 +70,6 @@ public static bool TryRunAllUpgrades(string enlistmentRoot, out string error, ou } } - public static bool TryCheckDiskLayoutVersion(ITracer tracer, string enlistmentRoot, out string error) - { - ReturnCode returnCode; - return TryCheckDiskLayoutVersion(tracer, enlistmentRoot, out error, out returnCode); - } - public static bool TryCheckDiskLayoutVersion(ITracer tracer, string enlistmentRoot, out string error, out ReturnCode returnCode) { error = string.Empty; @@ -277,13 +271,6 @@ private static bool CheckLayoutVersionWasIncremented(JsonTracer tracer, string e return true; } - private static bool TryFindUpgrade(JsonTracer tracer, string enlistmentRoot, out DiskLayoutUpgrade upgrade) - { - string error; - ReturnCode returnCode; - return TryFindUpgrade(tracer, enlistmentRoot, out upgrade, out error, out returnCode); - } - private static bool TryFindUpgrade(JsonTracer tracer, string enlistmentRoot, out DiskLayoutUpgrade upgrade, out string error, out ReturnCode returnCode) { int majorVersion; @@ -367,6 +354,11 @@ private static bool TryGetDiskLayoutVersion( if (!RepoMetadata.Instance.TryGetOnDiskLayoutVersion(out majorVersion, out minorVersion, out error, out returnCode)) { + if (returnCode == ReturnCode.MissingDiskLayoutVersion) + { + error += " " + ConsoleHelper.GetGVFSLogMessage(enlistmentRoot); + } + return false; } } diff --git a/GVFS/GVFS.Common/RepoMetadata.cs b/GVFS/GVFS.Common/RepoMetadata.cs index f3213903b9..00854fc69b 100644 --- a/GVFS/GVFS.Common/RepoMetadata.cs +++ b/GVFS/GVFS.Common/RepoMetadata.cs @@ -8,7 +8,7 @@ namespace GVFS.Common { public class RepoMetadata { - public const string MissingDiskLayoutVersionMessage = "Enlistment disk layout version not found. The .gvfs metadata is incomplete. Recloning this enlistment is the recommended fix."; + 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; diff --git a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/MountTests.cs b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/MountTests.cs index ff2ad39b8c..b47a00b774 100644 --- a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/MountTests.cs +++ b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/MountTests.cs @@ -11,16 +11,16 @@ using System.IO; using System.Linq; using System.Runtime.InteropServices; -using RepoMetadata = GVFS.Common.RepoMetadata; -using ReturnCode = GVFS.Common.ReturnCode; namespace GVFS.FunctionalTests.Tests.EnlistmentPerFixture { [TestFixture] public class MountTests : TestsWithEnlistmentPerFixture { - private const int GVFSGenericError = (int)ReturnCode.GenericError; - private const int GVFSMissingDiskLayoutVersion = (int)ReturnCode.MissingDiskLayoutVersion; + 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 uint GenericRead = 2147483648; private const uint FileFlagBackupSemantics = 3355443; private readonly int fileDeletedBackgroundOperationCode; @@ -119,7 +119,7 @@ public void MountFailsWithUniqueExitCodeWhenDiskLayoutVersionIsMissing() this.MountShouldFail( GVFSMissingDiskLayoutVersion, - RepoMetadata.MissingDiskLayoutVersionMessage); + MissingDiskLayoutVersionMessage); } finally { @@ -158,7 +158,7 @@ public void MountFailsWhenNoOnDiskVersion() this.MountShouldFail( GVFSMissingDiskLayoutVersion, - RepoMetadata.MissingDiskLayoutVersionMessage); + MissingDiskLayoutVersionMessage); } finally { 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/CommandLine/MountVerb.cs b/GVFS/GVFS/CommandLine/MountVerb.cs index 7a3091aba5..700cb85289 100644 --- a/GVFS/GVFS/CommandLine/MountVerb.cs +++ b/GVFS/GVFS/CommandLine/MountVerb.cs @@ -138,8 +138,10 @@ protected override void PreCreateEnlistment() { this.ReportErrorAndExit(tracer: null, exitCode: diskLayoutReturnCode, error: "Error: " + diskLayoutError); } - - this.ReportErrorAndExit("Failed to upgrade repo disk layout. " + ConsoleHelper.GetGVFSLogMessage(enlistmentRoot)); + else + { + this.ReportErrorAndExit("Failed to upgrade repo disk layout. " + ConsoleHelper.GetGVFSLogMessage(enlistmentRoot)); + } } string error; From 6635e93522da322e0fdfcbf3ec3acbf0a1066a12 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Fri, 28 Aug 2026 15:53:10 -0700 Subject: [PATCH 11/35] Pin and document GitProcess git-dir resolution in linked worktrees In a linked git worktree, Enlistment.DotGitRoot and the git directory that GitProcess passes as --git-dir point at different locations: Enlistment.DotGitRoot -> the shared .git directory of the main repo GitProcess git dir -> "\.git", a file holding "gitdir: /.git/worktrees/" The two values look inconsistent, but each is correct for its purpose. Git follows the gitdir: pointer, so the worktree .git file resolves the per-worktree state (HEAD, index, per-worktree refs, reflog) and, through commondir, the shared state (config, objects, packed-refs). DotGitRoot is used for shared state that GVFS reads or writes directly on disk (objects, objects/info/alternates, hooks). Making GitProcess use DotGitRoot would be a regression. Every command would bind to the main worktree. From inside a linked worktree, "rev-parse HEAD" and "name-rev --name-only HEAD" would report the main worktree's branch and commit. No behavior changes. This adds comments on both sides of the divergence, an internal GitDirPath accessor, and tests that pin the current values. The tests cover both linked-worktree and regular enlistment behavior, plus the null working-directory constructor case. Assisted-by: Claude Opus 4.5 Signed-off-by: Tyrie Vella --- GVFS/GVFS.Common/Enlistment.cs | 4 ++++ GVFS/GVFS.Common/Git/GitProcess.cs | 24 +++++++++++++++---- .../Common/WorktreeEnlistmentTests.cs | 14 ++++++++++- GVFS/GVFS.UnitTests/Git/GitProcessTests.cs | 21 ++++++++++++++++ 4 files changed, 58 insertions(+), 5 deletions(-) diff --git a/GVFS/GVFS.Common/Enlistment.cs b/GVFS/GVFS.Common/Enlistment.cs index a9208b0edf..503a29b61a 100644 --- a/GVFS/GVFS.Common/Enlistment.cs +++ b/GVFS/GVFS.Common/Enlistment.cs @@ -62,6 +62,10 @@ protected Enlistment( public string WorkingDirectoryRoot { get; } public string WorkingDirectoryBackingRoot { get; } + /// + /// Path to the shared repository metadata directory that GVFS reads or writes directly. + /// In a linked worktree this is the common git directory, not the worktree's .git file. + /// 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/Git/GitProcess.cs b/GVFS/GVFS.Common/Git/GitProcess.cs index df54900b7c..e6c3f72ab0 100644 --- a/GVFS/GVFS.Common/Git/GitProcess.cs +++ b/GVFS/GVFS.Common/Git/GitProcess.cs @@ -55,7 +55,7 @@ public class GitProcess : ICredentialStore private string gitBinPath; private string workingDirectoryRoot; - private string dotGitRoot; + private string gitDirPath; private Process executingProcess; private bool stopping; @@ -101,10 +101,25 @@ 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 + // point at the worktree's .git file, not the gitdir target named inside it. Git + // follows the gitdir: pointer, resolves per-worktree HEAD/index/refs, and then + // follows commondir to shared state. + // Passing Enlistment.DotGitRoot would bind commands to the main worktree instead. + this.gitDirPath = Path.Combine(this.workingDirectoryRoot, GVFSConstants.DotGit.Root); } } + /// + /// Path passed as --git-dir to InvokeGitAgainstDotGitFolder. + /// In a linked worktree this is the worktree's .git file, not the gitdir target + /// named inside it or . + /// + internal string GitDirPath + { + get { return this.gitDirPath; } + } + public static string ExpireTimeDateString { get @@ -1204,7 +1219,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 +1244,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.UnitTests/Common/WorktreeEnlistmentTests.cs b/GVFS/GVFS.UnitTests/Common/WorktreeEnlistmentTests.cs index dd072b1e5b..4351189d2b 100644 --- a/GVFS/GVFS.UnitTests/Common/WorktreeEnlistmentTests.cs +++ b/GVFS/GVFS.UnitTests/Common/WorktreeEnlistmentTests.cs @@ -1,4 +1,5 @@ using GVFS.Common; +using GVFS.Common.Git; using GVFS.Tests.Should; using NUnit.Framework; using System.IO; @@ -91,6 +92,17 @@ public void DotGitRootPointsToSharedGitDir() enlistment.DotGitRoot.ShouldEqual(this.sharedGitDir); } + [TestCase] + public void GitProcessUsesWorktreeGitFileNotSharedGitDir() + { + // GitProcess passes the worktree's .git file as --git-dir. It does not pre-resolve + // the gitdir target named inside that file. This pins the string contract. + GVFSEnlistment enlistment = this.CreateWorktreeEnlistment(); + GitProcess gitProcess = new GitProcess(enlistment); + + gitProcess.GitDirPath.ShouldEqual(Path.Combine(this.worktreePath, ".git")); + } + [TestCase] public void WorkingDirectoryRootIsWorktreePath() { @@ -155,4 +167,4 @@ public void RepoUrlIsReadFromSharedConfig() enlistment.RepoUrl.ShouldEqual("https://mock/repo"); } } -} +} diff --git a/GVFS/GVFS.UnitTests/Git/GitProcessTests.cs b/GVFS/GVFS.UnitTests/Git/GitProcessTests.cs index 8182f8dfb5..6c92585193 100644 --- a/GVFS/GVFS.UnitTests/Git/GitProcessTests.cs +++ b/GVFS/GVFS.UnitTests/Git/GitProcessTests.cs @@ -102,6 +102,27 @@ public void TryKillRunningProcess_NeverRan() error.ShouldBeNull(); } + [TestCase] + public void GitDirPathMatchesDotGitRootForRegularEnlistment() + { + // Regular enlistments do not have a separate worktree .git file, + // so GitProcess and Enlistment use the same .git directory path. + MockGVFSEnlistment enlistment = new MockGVFSEnlistment(); + GitProcess process = new GitProcess(enlistment); + + process.GitDirPath.ShouldEqual(enlistment.DotGitRoot); + } + + [TestCase] + public void GitDirPathIsNullWhenWorkingDirectoryRootIsNull() + { + // Some callers only need global Git operations. Those instances have no enlistment + // root and cannot pass a --git-dir path. + GitProcess process = new GitProcess("git.exe", workingDirectoryRoot: null); + + process.GitDirPath.ShouldBeNull(); + } + [TestCase] public void ResultHasNoErrors() { From 5acdc4db72fdcaac3a9090dd535189d9041ff638 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Wed, 14 Oct 2026 10:23:04 -0700 Subject: [PATCH 12/35] Address review feedback on git-dir resolution comments and tests Correct the GitProcess comment: only HEAD, index, ORIG_HEAD, logs/HEAD, refs/bisect and refs/worktree are per-worktree. config, objects, packed-refs and refs/heads stay in the shared git directory. Reword it so it no longer reads as following the gitdir: target. Document Enlistment.DotGitRoot as a contract that subclasses supporting linked worktrees must satisfy. The base constructor sets it to a path that is a file in a linked worktree. Replace the tests that re-derived the path formula with tests that assert the --git-dir value that reaches InvokeGitImpl. MockGitProcess now records that value and has constructors for a specific enlistment and a bare working directory root. Remove the internal GitDirPath accessor, which only the old tests used. Assisted-by: Claude Opus 4.5 Signed-off-by: Tyrie Vella --- GVFS/GVFS.Common/Enlistment.cs | 6 ++- GVFS/GVFS.Common/Git/GitProcess.cs | 20 +++------ .../Common/WorktreeEnlistmentTests.cs | 22 +++++++--- GVFS/GVFS.UnitTests/Git/GitProcessTests.cs | 33 ++++++++++----- .../GVFS.UnitTests/Mock/Git/MockGitProcess.cs | 42 ++++++++++++------- 5 files changed, 75 insertions(+), 48 deletions(-) diff --git a/GVFS/GVFS.Common/Enlistment.cs b/GVFS/GVFS.Common/Enlistment.cs index 503a29b61a..7bc826b1f9 100644 --- a/GVFS/GVFS.Common/Enlistment.cs +++ b/GVFS/GVFS.Common/Enlistment.cs @@ -63,8 +63,10 @@ protected Enlistment( public string WorkingDirectoryBackingRoot { get; } /// - /// Path to the shared repository metadata directory that GVFS reads or writes directly. - /// In a linked worktree this is the common git directory, not the worktree's .git file. + /// 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 override it with + /// the shared (common) git directory. /// public string DotGitRoot { get; protected set; } public abstract string GitObjectsRoot { get; protected set; } diff --git a/GVFS/GVFS.Common/Git/GitProcess.cs b/GVFS/GVFS.Common/Git/GitProcess.cs index e6c3f72ab0..bc7e224436 100644 --- a/GVFS/GVFS.Common/Git/GitProcess.cs +++ b/GVFS/GVFS.Common/Git/GitProcess.cs @@ -101,25 +101,17 @@ public GitProcess(string gitBinPath, string workingDirectoryRoot) if (this.workingDirectoryRoot != null) { - // Deliberately not Enlistment.DotGitRoot. In a linked worktree, --git-dir must - // point at the worktree's .git file, not the gitdir target named inside it. Git - // follows the gitdir: pointer, resolves per-worktree HEAD/index/refs, and then - // follows commondir to shared state. + // 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 the + // per-worktree files (HEAD, index, ORIG_HEAD, logs/HEAD, refs/bisect, refs/worktree) + // there, and everything else (config, objects, packed-refs, refs/heads) through + // commondir in the shared git directory. // Passing Enlistment.DotGitRoot would bind commands to the main worktree instead. this.gitDirPath = Path.Combine(this.workingDirectoryRoot, GVFSConstants.DotGit.Root); } } - /// - /// Path passed as --git-dir to InvokeGitAgainstDotGitFolder. - /// In a linked worktree this is the worktree's .git file, not the gitdir target - /// named inside it or . - /// - internal string GitDirPath - { - get { return this.gitDirPath; } - } - public static string ExpireTimeDateString { get diff --git a/GVFS/GVFS.UnitTests/Common/WorktreeEnlistmentTests.cs b/GVFS/GVFS.UnitTests/Common/WorktreeEnlistmentTests.cs index 4351189d2b..9ad8ab241a 100644 --- a/GVFS/GVFS.UnitTests/Common/WorktreeEnlistmentTests.cs +++ b/GVFS/GVFS.UnitTests/Common/WorktreeEnlistmentTests.cs @@ -1,6 +1,7 @@ using GVFS.Common; using GVFS.Common.Git; using GVFS.Tests.Should; +using GVFS.UnitTests.Mock.Git; using NUnit.Framework; using System.IO; @@ -93,14 +94,23 @@ public void DotGitRootPointsToSharedGitDir() } [TestCase] - public void GitProcessUsesWorktreeGitFileNotSharedGitDir() + public void GitProcessPassesWorktreeGitFileAsGitDir() { - // GitProcess passes the worktree's .git file as --git-dir. It does not pre-resolve - // the gitdir target named inside that file. This pins the string contract. + // 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(); - GitProcess gitProcess = new GitProcess(enlistment); - - gitProcess.GitDirPath.ShouldEqual(Path.Combine(this.worktreePath, ".git")); + 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] diff --git a/GVFS/GVFS.UnitTests/Git/GitProcessTests.cs b/GVFS/GVFS.UnitTests/Git/GitProcessTests.cs index 6c92585193..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 { @@ -103,24 +105,35 @@ public void TryKillRunningProcess_NeverRan() } [TestCase] - public void GitDirPathMatchesDotGitRootForRegularEnlistment() + public void GitDirectoryMatchesDotGitRootForRegularEnlistment() { - // Regular enlistments do not have a separate worktree .git file, - // so GitProcess and Enlistment use the same .git directory path. + // Regular enlistments have no separate worktree .git file, + // so the --git-dir that reaches git matches Enlistment.DotGitRoot. MockGVFSEnlistment enlistment = new MockGVFSEnlistment(); - GitProcess process = new GitProcess(enlistment); + MockGitProcess process = new MockGitProcess(enlistment); + process.SetExpectedCommandResult( + "rev-parse HEAD", + () => new GitProcess.Result("abc123", string.Empty, GitProcess.Result.SuccessCode)); - process.GitDirPath.ShouldEqual(enlistment.DotGitRoot); + process.RevParse("HEAD"); + + process.InvocationsRun.Count.ShouldEqual(1); + process.InvocationsRun[0].DotGitDirectory.ShouldEqual(enlistment.DotGitRoot); } [TestCase] - public void GitDirPathIsNullWhenWorkingDirectoryRootIsNull() + public void NoGitDirectoryIsPassedWhenWorkingDirectoryRootIsNull() { - // Some callers only need global Git operations. Those instances have no enlistment - // root and cannot pass a --git-dir path. - GitProcess process = new GitProcess("git.exe", workingDirectoryRoot: null); + // 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.GitDirPath.ShouldBeNull(); + process.InvocationsRun.Count.ShouldEqual(1); + process.InvocationsRun[0].DotGitDirectory.ShouldBeNull(); } [TestCase] 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; } } From c813da59e9016779ea865457c814c4e5e96c2a3c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:56:47 +0000 Subject: [PATCH 13/35] Bump actions/upload-artifact from 7.0.1 to 7.0.2 Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 7.0.1 to 7.0.2. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/043fb46d1a93c77aae656e7c1c64a875d1fc6a0a...cf430e030ddbb5b0abf93d22962f4752f3646cd9) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: 7.0.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/build.yaml | 8 ++++---- .github/workflows/functional-tests.yaml | 8 ++++---- .github/workflows/upgrade-tests.yaml | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 32706260ff..c0f4ec649b 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -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..b8c5104aa2 100644 --- a/.github/workflows/functional-tests.yaml +++ b/.github/workflows/functional-tests.yaml @@ -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..adc6c8f2e8 100644 --- a/.github/workflows/upgrade-tests.yaml +++ b/.github/workflows/upgrade-tests.yaml @@ -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 }} From 2131775935d16073508566b83df12094a36218f0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:57:00 +0000 Subject: [PATCH 14/35] Bump actions/download-artifact from 8.0.1 to 8.0.2 Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 8.0.1 to 8.0.2. - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c...9000827ccba6bdab643e8b6fd33ac0654aef8333) --- updated-dependencies: - dependency-name: actions/download-artifact dependency-version: 8.0.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/functional-tests.yaml | 14 +++++++------- .github/workflows/upgrade-tests.yaml | 8 ++++---- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/functional-tests.yaml b/.github/workflows/functional-tests.yaml index 110e3c14b8..f53a482a7b 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 diff --git a/.github/workflows/upgrade-tests.yaml b/.github/workflows/upgrade-tests.yaml index 04cb26f6b1..f8a49767a4 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 From d84568bf727c1fac5c31ab277273885d667df98b Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Wed, 14 Oct 2026 13:57:46 -0700 Subject: [PATCH 15/35] Keep a failing mount from crashing, and make its tests deterministic A reviewer found that the failure handshake could bring back the failure it exists to remove. This commit fixes that and the other review findings. HandleRequest answered every request except GetStatus while the mount was in the MountFailed state. HandleReleaseLockRequest then read fileSystemCallbacks, which is still null when the mount fails before that field is set. The NullReferenceException ended the process through NamedPipeServer, and the client saw a broken pipe with the real reason lost. A mount that fails in ValidateHooksVersion reproduces it: a ReleaseLock sent while the process waits for the client killed it. While the state is MountFailed, the mount process now answers only GetStatus and Unmount. It answers every other request with MountNotReady, the same reply it gives while mounting. An Unmount request in the MountFailed state used to start a teardown on the pipe thread, race the thread that reports the failure, and exit with Success. The process now replies MountFailed and does not tear down, because the failing mount is already ending the process. UnmountVerb already prints "previous mount attempt failed" for that reply. gvfs service --unmount-all now skips a repo in the MountFailed state, as it already skips one that is unmounting, so the installer does not report a failure for it. Other changes: - Rename the HRESULT constant to CantAttachToDevVolume. 0x800701DE is HRESULT_FROM_WIN32(ERROR_CANT_ATTACH_TO_DEV_VOLUME), which is 478. - Remove IDisposable from MountFailureReporter. Nothing disposes it, and a Dispose during a wait would make WaitOne throw. - Inject the wait, sleep, exit, and block operations through an internal IMountFailureEnvironment. The tests assert on the timeouts that the reporter requests, so they no longer measure elapsed time or sleep. - Move the ownership dispatch into TryBeginFailure and the send-then-record step into TrySendStatus, so both can be tested. The tests cover the re-entrant and other-thread outcomes, a failed send, and both wait timeouts. - Add GVFSEnlistment.MountStatusPollIntervalMs. The client poll and the drain after the failure now derive from it. - Test BuildProjFsAttachRemedy. - Mark the malformed-format test ExceptionExpected, and use a mock: path. - Reduce the history comments to the invariants that they protect. Verified on a ReFS Dev Drive. On the old build, a ReleaseLock or Unmount sent during the failure killed the process or started a teardown, and the client printed a broken pipe. On this build, the client prints the real reason in three of three runs, with no NullReferenceException. Assisted-by: Claude Opus 5 Signed-off-by: Tyrie Vella --- GVFS/GVFS.Common/GVFSEnlistment.cs | 10 +- .../NamedPipes/MountFailureEnvironment.cs | 48 +++ .../NamedPipes/MountFailureReporter.cs | 174 +++++------ GVFS/GVFS.Mount/InProcessMount.cs | 93 +++--- .../HResultExtensions.cs | 9 +- .../WindowsFileSystemVirtualizer.cs | 9 +- .../Common/MountFailureReporterTests.cs | 285 ++++++++---------- .../WaitUntilMountedFailureReportingTests.cs | 2 +- ...kgroundFileSystemTaskRunnerDisposeTests.cs | 7 +- .../GitIndexProjectionDisposeTests.cs | 8 +- .../Virtualization/ProjFsAttachRemedyTests.cs | 45 +++ .../BackgroundFileSystemTaskRunner.cs | 9 +- .../Projection/GitIndexProjection.cs | 10 +- GVFS/GVFS/CommandLine/ServiceVerb.cs | 9 +- 14 files changed, 366 insertions(+), 352 deletions(-) create mode 100644 GVFS/GVFS.Common/NamedPipes/MountFailureEnvironment.cs create mode 100644 GVFS/GVFS.UnitTests/Windows/Virtualization/ProjFsAttachRemedyTests.cs diff --git a/GVFS/GVFS.Common/GVFSEnlistment.cs b/GVFS/GVFS.Common/GVFSEnlistment.cs index 51779cb385..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; @@ -294,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/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 index 07d417059d..3cbcb64962 100644 --- a/GVFS/GVFS.Common/NamedPipes/MountFailureReporter.cs +++ b/GVFS/GVFS.Common/NamedPipes/MountFailureReporter.cs @@ -5,49 +5,17 @@ namespace GVFS.Common.NamedPipes { /// - /// The result of a thread trying to take ownership of the mount-failure path. - /// - public enum MountFailureOwnership - { - /// - /// The calling thread now owns reporting and must drive it to process exit. - /// - Owner, - - /// - /// The calling thread already owned reporting and has re-entered, because - /// something threw while it was reporting and an outer catch block called back - /// in. It must not wait again: it would block forever on a signal that it is - /// itself responsible for observing. - /// - Reentrant, - - /// - /// Another thread owns reporting and will exit the process. The calling thread - /// must not continue with a half-initialized mount. - /// - NotOwner, - } - - /// - /// Server half of the mount-failure handshake: keeps a failed mount process alive - /// just long enough for a client to read the reason over the named pipe. + /// 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. /// /// - /// Without this, the mount process sets MountFailed and exits immediately. - /// The client polls GetStatus every 100ms, so it usually loses the race, the pipe - /// breaks mid-request, and the user sees a transport error instead of the cause. - /// - /// The wait is deliberately short. Until the process exits it still holds the mount - /// lock and the named pipe, so a longer wait delays the user's next mount attempt. - /// - /// - /// This type owns only the handshake state. Process-exit policy stays with the - /// caller. The client half of the same protocol is + /// 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 : IDisposable + public sealed class MountFailureReporter { /// /// Cap on how long to wait once a client is known to be polling. @@ -61,18 +29,18 @@ public sealed class MountFailureReporter : IDisposable internal const int DefaultNoClientTimeoutMs = 500; /// - /// Grace period after a client reads the failure. Any client can satisfy the - /// wait, and it is not necessarily the one that is mounting -- a concurrent - /// "gvfs status" polls the same pipe. Keeping this above the client's 100ms poll - /// interval means the mounting client still gets its answer on its next poll. + /// 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 = 150; + internal const int DefaultDrainMs = GVFSEnlistment.MountStatusPollIntervalMs * 3 / 2; private readonly ITracer tracer; - private readonly int reportTimeoutMs; - private readonly int noClientTimeoutMs; - private readonly int drainMs; - private readonly ManualResetEvent failureReported; + 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; @@ -82,21 +50,14 @@ public sealed class MountFailureReporter : IDisposable private int owningThreadId; public MountFailureReporter(ITracer tracer) - : this(tracer, DefaultReportTimeoutMs, DefaultNoClientTimeoutMs, DefaultDrainMs) + : this(tracer, new ProcessMountFailureEnvironment()) { } - /// - /// Test constructor. Lets unit tests use short timeouts so they do not pay the - /// production waits. - /// - internal MountFailureReporter(ITracer tracer, int reportTimeoutMs, int noClientTimeoutMs, int drainMs) + internal MountFailureReporter(ITracer tracer, IMountFailureEnvironment environment) { this.tracer = tracer; - this.reportTimeoutMs = reportTimeoutMs; - this.noClientTimeoutMs = noClientTimeoutMs; - this.drainMs = drainMs; - this.failureReported = new ManualResetEvent(false); + this.environment = environment; } /// @@ -134,8 +95,8 @@ public static string FormatFailure(string error, object[] args) } /// - /// Records that the named pipe server is accepting requests. Failures before this - /// point cannot be reported over the pipe and must not wait for a reader. + /// 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() { @@ -143,84 +104,97 @@ public void OnNamedPipeReady() } /// - /// Attempts to take ownership of the mount-failure path, publishing - /// as the reason if this call wins. + /// Takes ownership of the mount-failure path. /// - public MountFailureOwnership TryTakeOwnership(string message) + /// + /// 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) { - return MountFailureOwnership.Reentrant; + 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) { - return MountFailureOwnership.NotOwner; + this.environment.BlockUntilProcessExits(); + return false; } this.failureMessage = message; - return MountFailureOwnership.Owner; + return true; } /// - /// Records that a GetStatus response reached a client, and releases - /// when that response carried the - /// failure. + /// Sends a status response, and records the outcome only if the send succeeded. /// - /// - /// True when the delivered response reported MountFailed. - /// - public void OnStatusDelivered(bool carriedMountFailure) + /// 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) { - // Only count a client that actually received a status. Recording this before - // the send succeeds would let a client that connected and then disconnected - // extend the post-failure wait. + if (!trySend()) + { + return false; + } + this.clientReceivedStatus = true; - if (carriedMountFailure) + 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 possibly read it. + /// Returns immediately when no client could read it. /// - public void WaitForClientToReadFailure() + /// True if a client read the failure. + public bool WaitForClientToReadFailure() { if (!this.namedPipeReady) { - // No client can read the failure. The caller detects this case by - // watching the mount process exit code instead. - return; + return false; } - int timeoutMs = this.clientReceivedStatus ? this.reportTimeoutMs : this.noClientTimeoutMs; + int timeoutMs = this.clientReceivedStatus ? DefaultReportTimeoutMs : DefaultNoClientTimeoutMs; - if (this.failureReported.WaitOne(timeoutMs)) - { - Thread.Sleep(this.drainMs); - } - else + if (this.environment.WaitForSignal(this.failureReported, timeoutMs)) { - this.tracer.RelatedWarning( - null, - $"{nameof(this.WaitForClientToReadFailure)}: No client read the mount failure within {timeoutMs}ms. Exiting anyway.", - Keywords.Telemetry); + this.environment.Sleep(DefaultDrainMs); + return true; } - } - public void Dispose() - { - // InProcessMount deliberately does not call this: it holds the reporter for - // the life of the process and leaves through Environment.Exit, which reclaims - // the handle. Do not dispose while a thread is inside - // WaitForClientToReadFailure -- WaitOne on a disposed handle throws. - this.failureReported.Dispose(); + 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.Mount/InProcessMount.cs b/GVFS/GVFS.Mount/InProcessMount.cs index d1e5815966..306a0f29b8 100644 --- a/GVFS/GVFS.Mount/InProcessMount.cs +++ b/GVFS/GVFS.Mount/InProcessMount.cs @@ -647,29 +647,14 @@ private void FailMountAndExit(string error, params object[] args) private void FailMountAndExit(ReturnCode returnCode, string error, params object[] args) { - // Format before taking ownership. RelatedError would otherwise format a second - // time without protection, and a throw there would re-enter this method from an - // outer catch block. + // 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); - switch (this.mountFailureReporter.TryTakeOwnership(failureMessage)) + if (!this.mountFailureReporter.TryBeginFailure((int)returnCode, failureMessage)) { - case MountFailureOwnership.Reentrant: - // Something below threw and an outer catch called back in. The failure - // was already reported, so exit rather than wait on ourselves. - this.tracer.RelatedWarning( - null, - $"{nameof(this.FailMountAndExit)}: Re-entered while reporting a mount failure: {failureMessage}", - Keywords.Telemetry); - Environment.Exit((int)returnCode); - break; - - case MountFailureOwnership.NotOwner: - // Another thread owns the failure path and will exit the process. Block - // rather than return: every caller of FailMountAndExit assumes it never - // returns and would otherwise run on with a half-initialized mount. - BlockUntilProcessExits(); - break; + // Unreachable in production: every path that returns false ends the process. + return; } this.currentState = MountState.MountFailed; @@ -681,10 +666,8 @@ private void FailMountAndExit(ReturnCode returnCode, string error, params object Console.ReadLine(); } - // Report the failure before tearing anything down. Disposal can be slow, and a - // secondary exception thrown from it would kill the process before the client - // ever reads the reason -- which is what made mount failures surface as - // BrokenPipeException instead of the real cause. + // The failure response must reach the client before the process exits, so + // report it before tearing anything down. this.mountFailureReporter.WaitForClientToReadFailure(); try @@ -697,8 +680,8 @@ private void FailMountAndExit(ReturnCode returnCode, string error, params object } catch (Exception e) { - // Traced with Telemetry because this used to be a process-fatal crash. Without - // the keyword the failure it replaced would become invisible in the field. + // 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}", @@ -708,15 +691,6 @@ private void FailMountAndExit(ReturnCode returnCode, string error, params object Environment.Exit((int)returnCode); } - /// - /// Parks the calling thread until another thread exits the process. Used where a - /// method must not return but is not the thread that owns the exit. - /// - private static void BlockUntilProcessExits() - { - Thread.Sleep(Timeout.Infinite); - } - private T CreateOrReportAndExit(Func factory, string reportMessage) { try @@ -730,16 +704,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; @@ -1517,12 +1512,9 @@ private void HandleGetStatusRequest(NamedPipeServer.Connection connection) break; } - bool sent = connection.TrySendResponse(response.ToJson()); - - if (sent) - { - this.mountFailureReporter.OnStatusDelivered(carriedMountFailure: state == MountState.MountFailed); - } + this.mountFailureReporter.TrySendStatus( + () => connection.TrySendResponse(response.ToJson()), + carriesMountFailure: state == MountState.MountFailed); } private void HandleUnmountRequest(NamedPipeServer.Connection connection) @@ -1533,10 +1525,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 64b7ecf31d..332297e038 100644 --- a/GVFS/GVFS.Platform.Windows/HResultExtensions.cs +++ b/GVFS/GVFS.Platform.Windows/HResultExtensions.cs @@ -6,11 +6,10 @@ 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_FILE_SYSTEM_VIRTUALIZATION_NOT_AVAILABLE (478)): - // "The file system minifilter cannot attach to the developer volume." Returned by - // StartVirtualizing when PrjFlt is not on the volume's allowed-filter list, which - // is the default on a ReFS Dev Drive. - public const int FileSystemVirtualizationNotAvailable = unchecked((int)0x800701DE); + // 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/WindowsFileSystemVirtualizer.cs b/GVFS/GVFS.Platform.Windows/WindowsFileSystemVirtualizer.cs index 357f77c3c5..fde263b8a6 100644 --- a/GVFS/GVFS.Platform.Windows/WindowsFileSystemVirtualizer.cs +++ b/GVFS/GVFS.Platform.Windows/WindowsFileSystemVirtualizer.cs @@ -837,7 +837,7 @@ 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.FileSystemVirtualizationNotAvailable) + if ((int)result == HResultExtensions.CantAttachToDevVolume) { error += ". " + BuildProjFsAttachRemedy(this.Context.Enlistment.WorkingDirectoryRoot); } @@ -854,9 +854,8 @@ protected override void OnPossibleTombstoneFolderCreated(string relativePath) } /// - /// Builds the user-facing remedy for - /// ERROR_FILE_SYSTEM_VIRTUALIZATION_NOT_AVAILABLE, which a Dev Drive returns when - /// PrjFlt is not on its allowed-filter list. + /// 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 @@ -865,7 +864,7 @@ protected override void OnPossibleTombstoneFolderCreated(string relativePath) /// following this advice would drop filters the machine already requires (an /// anti-malware filter, for example). /// - private static string BuildProjFsAttachRemedy(string enlistmentRoot) + internal static string BuildProjFsAttachRemedy(string enlistmentRoot) { string volume; try diff --git a/GVFS/GVFS.UnitTests/Common/MountFailureReporterTests.cs b/GVFS/GVFS.UnitTests/Common/MountFailureReporterTests.cs index eff6091bdd..1660868f05 100644 --- a/GVFS/GVFS.UnitTests/Common/MountFailureReporterTests.cs +++ b/GVFS/GVFS.UnitTests/Common/MountFailureReporterTests.cs @@ -1,216 +1,148 @@ +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; -using System.Diagnostics; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; namespace GVFS.UnitTests.Common { - /// - /// Covers the server half of the mount-failure handshake. Before this state machine - /// was extracted from InProcessMount it lived in GVFS.Mount, which GVFS.UnitTests - /// cannot reference, so none of it was covered. - /// [TestFixture] public class MountFailureReporterTests { - // Deliberately much smaller than the production defaults: these only have to be - // far enough apart to tell the two timeout paths apart, and one test has to wait - // the long one out in real time. - private const int ReportTimeoutMs = 600; - private const int NoClientTimeoutMs = 200; - private const int DrainMs = 20; - [TestCase] - public void FirstThreadTakesOwnershipAndPublishesTheMessage() + public void FirstCallerOwnsTheFailureAndPublishesTheMessage() { - using (MountFailureReporter reporter = CreateReporter()) - { - reporter.FailureMessage.ShouldBeNull(); + FakeEnvironment environment = new FakeEnvironment(); + MountFailureReporter reporter = CreateReporter(environment); - reporter.TryTakeOwnership("the real reason").ShouldEqual(MountFailureOwnership.Owner); - reporter.FailureMessage.ShouldEqual("the real reason"); - } - } + reporter.FailureMessage.ShouldBeNull(); - [TestCase] - public void SameThreadCallingTwiceIsReentrantRatherThanBlocked() - { - using (MountFailureReporter reporter = CreateReporter()) - { - reporter.TryTakeOwnership("first").ShouldEqual(MountFailureOwnership.Owner); + reporter.TryBeginFailure(exitCode: 3, message: "the real reason").ShouldBeTrue(); - // A throw inside the reporting path can make an outer catch block call - // back in on the same thread. That must not be treated as a second thread, - // or the caller would park forever holding the mount lock and the pipe. - reporter.TryTakeOwnership("second").ShouldEqual(MountFailureOwnership.Reentrant); - - // The re-entrant call must not overwrite the reason already published. - reporter.FailureMessage.ShouldEqual("first"); - } + reporter.FailureMessage.ShouldEqual("the real reason"); + environment.ExitCodes.Count.ShouldEqual(0); + environment.BlockCount.ShouldEqual(0); } [TestCase] - public void SecondThreadIsNotTheOwner() + public void ReentrantCallOnTheOwningThreadExitsAndKeepsTheFirstMessage() { - using (MountFailureReporter reporter = CreateReporter()) - { - reporter.TryTakeOwnership("first").ShouldEqual(MountFailureOwnership.Owner); + FakeEnvironment environment = new FakeEnvironment(); + MountFailureReporter reporter = CreateReporter(environment); + reporter.TryBeginFailure(exitCode: 3, message: "first").ShouldBeTrue(); - MountFailureOwnership fromOtherThread = MountFailureOwnership.Owner; - Task other = Task.Run(() => fromOtherThread = reporter.TryTakeOwnership("second")); - other.Wait(TimeSpan.FromSeconds(5)).ShouldBeTrue(); + reporter.TryBeginFailure(exitCode: 7, message: "second").ShouldBeFalse(); - fromOtherThread.ShouldEqual(MountFailureOwnership.NotOwner); - reporter.FailureMessage.ShouldEqual("first"); - } + environment.ExitCodes.ShouldMatchInOrder(7); + environment.BlockCount.ShouldEqual(0); + reporter.FailureMessage.ShouldEqual("first"); } [TestCase] - public void WaitReturnsImmediatelyWhenThePipeNeverOpened() + public void CallFromAnotherThreadBlocksUntilTheProcessExits() { - using (MountFailureReporter reporter = CreateReporter()) - { - reporter.TryTakeOwnership("failed before the pipe opened"); - - // No client can read the failure, so the process must not linger holding - // the mount lock. The caller falls back to the process exit code. - Stopwatch elapsed = Stopwatch.StartNew(); - reporter.WaitForClientToReadFailure(); - elapsed.Stop(); - - Assert.That(elapsed.ElapsedMilliseconds, Is.LessThan(NoClientTimeoutMs)); - } + 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 WaitUsesTheShortTimeoutWhenNoClientHasReceivedAStatus() + public void WaitDoesNothingWhenThePipeNeverOpened() { - using (MountFailureReporter reporter = CreateReporter()) - { - reporter.OnNamedPipeReady(); - reporter.TryTakeOwnership("nobody is listening"); + FakeEnvironment environment = new FakeEnvironment(); + MountFailureReporter reporter = CreateReporter(environment); + reporter.TryBeginFailure(exitCode: 3, message: "failed before the pipe opened"); - Stopwatch elapsed = Stopwatch.StartNew(); - reporter.WaitForClientToReadFailure(); - elapsed.Stop(); + reporter.WaitForClientToReadFailure().ShouldBeFalse(); - // Should give up after the no-client timeout, well short of the full one. - Assert.That(elapsed.ElapsedMilliseconds, Is.GreaterThan(NoClientTimeoutMs / 2)); - Assert.That(elapsed.ElapsedMilliseconds, Is.LessThan(ReportTimeoutMs)); - } + environment.WaitTimeouts.Count.ShouldEqual(0); + environment.Sleeps.Count.ShouldEqual(0); } [TestCase] - public void WaitReturnsOnceAClientReadsTheFailure() + public void WaitUsesTheShortTimeoutWhenNoClientHasReceivedAStatus() { - using (MountFailureReporter reporter = CreateReporter()) - { - reporter.OnNamedPipeReady(); - reporter.TryTakeOwnership("the real reason"); - reporter.OnStatusDelivered(carriedMountFailure: true); + FakeEnvironment environment = new FakeEnvironment(); + MountFailureReporter reporter = CreateReporter(environment); + reporter.OnNamedPipeReady(); + reporter.TryBeginFailure(exitCode: 3, message: "nobody is listening"); - Stopwatch elapsed = Stopwatch.StartNew(); - reporter.WaitForClientToReadFailure(); - elapsed.Stop(); + reporter.WaitForClientToReadFailure().ShouldBeFalse(); - // Returns as soon as the failure was delivered, plus the drain. - Assert.That(elapsed.ElapsedMilliseconds, Is.LessThan(NoClientTimeoutMs)); - } + environment.WaitTimeouts.ShouldMatchInOrder(MountFailureReporter.DefaultNoClientTimeoutMs); + environment.Sleeps.Count.ShouldEqual(0); } [TestCase] - public void DeliveringANonFailureStatusDoesNotReleaseTheWait() + public void WaitUsesTheLongTimeoutOnceAClientHasReceivedAStatus() { - using (MountFailureReporter reporter = CreateReporter()) - { - reporter.OnNamedPipeReady(); + FakeEnvironment environment = new FakeEnvironment(); + MountFailureReporter reporter = CreateReporter(environment); + reporter.OnNamedPipeReady(); - // A client polled while the mount was still in progress. That proves - // somebody is listening, but it did not carry the failure. - reporter.OnStatusDelivered(carriedMountFailure: false); - reporter.TryTakeOwnership("the real reason"); + // 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"); - Stopwatch elapsed = Stopwatch.StartNew(); - reporter.WaitForClientToReadFailure(); - elapsed.Stop(); + reporter.WaitForClientToReadFailure().ShouldBeFalse(); - // Must wait for the failure itself to be read, using the longer timeout - // because a client is known to be polling. Asserting above the SHORT - // timeout is what proves the long path was selected. - Assert.That(elapsed.ElapsedMilliseconds, Is.GreaterThan(NoClientTimeoutMs * 2)); - } + environment.WaitTimeouts.ShouldMatchInOrder(MountFailureReporter.DefaultReportTimeoutMs); } [TestCase] - public void WaitIsReleasedByAClientThatReadsTheFailureLater() + public void WaitReturnsAndDrainsOnceAClientHasReadTheFailure() { - using (MountFailureReporter reporter = CreateReporter()) - { - reporter.OnNamedPipeReady(); - reporter.OnStatusDelivered(carriedMountFailure: false); - reporter.TryTakeOwnership("the real reason"); - - Task client = Task.Run(() => - { - Thread.Sleep(50); - reporter.OnStatusDelivered(carriedMountFailure: true); - }); + FakeEnvironment environment = new FakeEnvironment(); + MountFailureReporter reporter = CreateReporter(environment); + reporter.OnNamedPipeReady(); + reporter.TryBeginFailure(exitCode: 3, message: "the real reason"); + reporter.TrySendStatus(() => true, carriesMountFailure: true).ShouldBeTrue(); - Stopwatch elapsed = Stopwatch.StartNew(); - reporter.WaitForClientToReadFailure(); - elapsed.Stop(); + reporter.WaitForClientToReadFailure().ShouldBeTrue(); - client.Wait(TimeSpan.FromSeconds(5)).ShouldBeTrue(); - - // Released by the delivery, not by the timeout. - Assert.That(elapsed.ElapsedMilliseconds, Is.LessThan(ReportTimeoutMs)); - } + environment.Sleeps.ShouldMatchInOrder(MountFailureReporter.DefaultDrainMs); } [TestCase] - public void ProductionDefaultsSelectTheShorterTimeoutWhenNoClientIsListening() + public void AFailedSendIsNotRecordedAndDoesNotReleaseTheWait() { - // Everything else builds the reporter through the internal test constructor, - // so this is the only cover for the public constructor and its forwarding of - // the Default* constants. Without it, swapping DefaultReportTimeoutMs and - // DefaultNoClientTimeoutMs would ship unnoticed. - using (MountFailureReporter reporter = new MountFailureReporter(new MockTracer())) - { - reporter.OnNamedPipeReady(); - reporter.TryTakeOwnership("nobody is listening"); + FakeEnvironment environment = new FakeEnvironment(); + MountFailureReporter reporter = CreateReporter(environment); + reporter.OnNamedPipeReady(); + reporter.TryBeginFailure(exitCode: 3, message: "the real reason"); - Stopwatch elapsed = Stopwatch.StartNew(); - reporter.WaitForClientToReadFailure(); - elapsed.Stop(); + // 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(); - // The no-client timeout must be the one used, and it must be meaningfully - // shorter than the timeout used once a client is known to be polling. - Assert.That(elapsed.ElapsedMilliseconds, Is.GreaterThan(MountFailureReporter.DefaultNoClientTimeoutMs / 2)); - Assert.That(elapsed.ElapsedMilliseconds, Is.LessThan(MountFailureReporter.DefaultReportTimeoutMs)); - } + reporter.WaitForClientToReadFailure().ShouldBeFalse(); + + environment.WaitTimeouts.ShouldMatchInOrder(MountFailureReporter.DefaultNoClientTimeoutMs); + environment.Sleeps.Count.ShouldEqual(0); } [TestCase] - public void ProductionDefaultsAreOrderedSensibly() + public void DrainOutlastsTheClientPollIntervalAndTheNoClientWaitIsShorter() { - // A failed mount holds the mount lock and the named pipe until it exits, so - // these bound how long a user's next "gvfs mount" is delayed. - Assert.That( - MountFailureReporter.DefaultNoClientTimeoutMs, - Is.LessThan(MountFailureReporter.DefaultReportTimeoutMs), - "Waiting for a client that has never polled must not take longer than waiting for one that has."); - - // The drain has to outlast the client's 100ms GetStatus poll interval, or a - // concurrent "gvfs status" can consume the report signal and the mounting - // client never gets its answer. - Assert.That( - MountFailureReporter.DefaultDrainMs, - Is.GreaterThan(100), - "The drain must exceed the client's GetStatus poll interval."); + // 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] @@ -223,8 +155,8 @@ public void FormatFailureAppliesArguments() [TestCase] public void FormatFailureLeavesTheMessageAloneWhenThereAreNoArguments() { - // A pre-built message can legitimately contain braces, so it must not be run - // through string.Format when there is nothing to substitute. + // 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"); @@ -233,19 +165,52 @@ public void FormatFailureLeavesTheMessageAloneWhenThereAreNoArguments() } [TestCase] + [Category(CategoryConstants.ExceptionExpected)] public void FormatFailureFallsBackWhenTheFormatStringIsMalformed() { - // Failure text is built from exception messages and server responses, which - // can contain unbalanced braces. Formatting must never throw and mask the - // failure being reported. - string result = MountFailureReporter.FormatFailure("unbalanced {0} and {", new object[] { "value" }); + // 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 {"); + } - result.ShouldEqual("unbalanced {0} and {"); + private static MountFailureReporter CreateReporter(FakeEnvironment environment) + { + return new MountFailureReporter(new MockTracer(), environment); } - private static MountFailureReporter CreateReporter() + private class FakeEnvironment : IMountFailureEnvironment { - return new MountFailureReporter(new MockTracer(), ReportTimeoutMs, NoClientTimeoutMs, DrainMs); + 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/WaitUntilMountedFailureReportingTests.cs b/GVFS/GVFS.UnitTests/Common/WaitUntilMountedFailureReportingTests.cs index cbe6288222..6f28e51b84 100644 --- a/GVFS/GVFS.UnitTests/Common/WaitUntilMountedFailureReportingTests.cs +++ b/GVFS/GVFS.UnitTests/Common/WaitUntilMountedFailureReportingTests.cs @@ -12,7 +12,7 @@ namespace GVFS.UnitTests.Common [TestFixture] public class WaitUntilMountedFailureReportingTests { - private const string EnlistmentRoot = "C:\\fake\\root"; + private const string EnlistmentRoot = "mock:\\fake\\root"; [TestCase] public void ReportsTheMountErrorSentByTheMountProcess() diff --git a/GVFS/GVFS.UnitTests/Virtualization/Background/BackgroundFileSystemTaskRunnerDisposeTests.cs b/GVFS/GVFS.UnitTests/Virtualization/Background/BackgroundFileSystemTaskRunnerDisposeTests.cs index 8a390510fc..abaa969d61 100644 --- a/GVFS/GVFS.UnitTests/Virtualization/Background/BackgroundFileSystemTaskRunnerDisposeTests.cs +++ b/GVFS/GVFS.UnitTests/Virtualization/Background/BackgroundFileSystemTaskRunnerDisposeTests.cs @@ -22,11 +22,8 @@ public void DisposeDoesNotThrowWhenBackgroundThreadIsStillRunning() TestableBackgroundFileSystemTaskRunner runner = new TestableBackgroundFileSystemTaskRunner(); runner.SetBackgroundThreadForTests(backgroundThread); - // FileSystemCallbacks.Dispose() disposes this runner on the failed-mount - // path, without calling Shutdown first, so the background thread is still - // running. Calling Task.Dispose on a task that has not completed throws - // InvalidOperationException, which stopped GVFS.Mount from reporting why - // the mount failed. This guards against reintroducing that call. + // Dispose without Shutdown leaves the task running. Task.Dispose throws + // InvalidOperationException for a task that has not completed. Assert.DoesNotThrow(() => runner.Dispose()); } finally diff --git a/GVFS/GVFS.UnitTests/Virtualization/Projection/GitIndexProjectionDisposeTests.cs b/GVFS/GVFS.UnitTests/Virtualization/Projection/GitIndexProjectionDisposeTests.cs index 68ae38b29f..7174df650b 100644 --- a/GVFS/GVFS.UnitTests/Virtualization/Projection/GitIndexProjectionDisposeTests.cs +++ b/GVFS/GVFS.UnitTests/Virtualization/Projection/GitIndexProjectionDisposeTests.cs @@ -22,12 +22,8 @@ public void DisposeDoesNotThrowWhenIndexParsingThreadIsStillRunning() TestableGitIndexProjection projection = new TestableGitIndexProjection(); projection.SetIndexParsingThreadForTests(parsingThread); - // A failed mount disposes the projection without calling Shutdown, so - // the parsing thread is still running here. Calling Task.Dispose on a - // task that has not completed throws InvalidOperationException, and - // that secondary exception used to kill GVFS.Mount before it could - // report why the mount failed -- leaving the client with a broken pipe. - // This guards against reintroducing that call. + // Dispose without Shutdown leaves the task running. Task.Dispose throws + // InvalidOperationException for a task that has not completed. Assert.DoesNotThrow(() => projection.Dispose()); } finally 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.Virtualization/Background/BackgroundFileSystemTaskRunner.cs b/GVFS/GVFS.Virtualization/Background/BackgroundFileSystemTaskRunner.cs index 21316fbf6d..e520f83e9f 100644 --- a/GVFS/GVFS.Virtualization/Background/BackgroundFileSystemTaskRunner.cs +++ b/GVFS/GVFS.Virtualization/Background/BackgroundFileSystemTaskRunner.cs @@ -137,12 +137,9 @@ protected void Dispose(bool disposing) { if (this.backgroundThread != null) { - // Deliberately not calling Task.Dispose. A Task holds no unmanaged - // resources, and Task.Dispose throws InvalidOperationException unless - // the task has reached a completion state. A failed mount disposes the - // callbacks without calling Shutdown first, so this thread is still - // running and that throw would prevent the mount process from - // reporting why the mount failed. + // 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/Projection/GitIndexProjection.cs b/GVFS/GVFS.Virtualization/Projection/GitIndexProjection.cs index 131449e640..0b4552796f 100644 --- a/GVFS/GVFS.Virtualization/Projection/GitIndexProjection.cs +++ b/GVFS/GVFS.Virtualization/Projection/GitIndexProjection.cs @@ -739,13 +739,9 @@ protected virtual void Dispose(bool disposing) if (this.indexParsingThread != null) { - // Deliberately not calling Task.Dispose. A Task holds no unmanaged - // resources, Task.Dispose is legacy IAsyncResult wait-handle cleanup, - // and it throws InvalidOperationException unless the task has reached - // a completion state. A failed mount disposes the projection without - // calling Shutdown first, so the parsing thread is still running here - // and that throw would kill the mount process before it can report - // why the mount failed. + // 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/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) From 171f14a584f153ea98c5c5f5683704ae9129d3b8 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Fri, 28 Aug 2026 15:52:59 -0700 Subject: [PATCH 16/35] Do not trace raw credential output Trace only allow-listed credential keys on parse failure. Map unknown or malformed output lines to placeholders so arbitrary helper text cannot appear in telemetry as a key name. Add tests that use a secret sentinel in both a password value and a malformed helper line. Assert that neither value appears in activity metadata. Assisted-by: Claude Opus 5 Signed-off-by: Tyrie Vella --- GVFS/GVFS.Common/Git/GitProcess.cs | 46 ++++++- .../Common/Git/GitProcessCredentialTests.cs | 119 ++++++++++++++++++ 2 files changed, 163 insertions(+), 2 deletions(-) create mode 100644 GVFS/GVFS.UnitTests/Common/Git/GitProcessCredentialTests.cs diff --git a/GVFS/GVFS.Common/Git/GitProcess.cs b/GVFS/GVFS.Common/Git/GitProcess.cs index df54900b7c..e27caee649 100644 --- a/GVFS/GVFS.Common/Git/GitProcess.cs +++ b/GVFS/GVFS.Common/Git/GitProcess.cs @@ -38,6 +38,16 @@ public class GitProcess : ICredentialStore private const int MaxCapturedStdOutChars = 128 * 1024 * 1024; // ~256 MB of UTF-16 private static readonly Encoding UTF8NoBOM = new UTF8Encoding(false); + private static readonly HashSet CredentialOutputKeyAllowlist = new HashSet(StringComparer.Ordinal) + { + "host", + "password", + "path", + "protocol", + "url", + "username" + }; + private static bool failedToSetEncoding = false; private static string expireTimeDateString; @@ -316,7 +326,7 @@ public virtual bool TryGetCertificatePassword( if (!success) { - metadata.Add("Output", gitCredentialOutput.Output); + metadata.Add("OutputKeys", GetCredentialOutputKeys(gitCredentialOutput.Output)); } activity.Stop(metadata); @@ -397,7 +407,7 @@ public virtual bool TryGetCredential( metadata.Add("Success", success); if (!success) { - metadata.Add("Output", gitCredentialOutput.Output); + metadata.Add("OutputKeys", GetCredentialOutputKeys(gitCredentialOutput.Output)); } activity.Stop(metadata); @@ -1138,6 +1148,38 @@ private static bool CurrentProcessHasVisibleConsoleWindowSafe() } } + /// + /// 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 values never reach telemetry or the log. + /// Each line maps to a known key name, "<other>" for an unknown key, + /// or "<malformed>" for a line without a key. + /// + private static string GetCredentialOutputKeys(string credentialOutput) + { + if (string.IsNullOrEmpty(credentialOutput)) + { + return string.Empty; + } + + IEnumerable keys = credentialOutput + .Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries) + .Where(line => !string.IsNullOrWhiteSpace(line)) + .Select(line => + { + int separatorIndex = line.IndexOf('='); + if (separatorIndex <= 0) + { + return ""; + } + + string key = line.Substring(0, separatorIndex); + return CredentialOutputKeyAllowlist.Contains(key) ? key : ""; + }); + + return string.Join(",", keys); + } + private static string ParseValue(string contents, string prefix) { int startIndex = contents.IndexOf(prefix) + prefix.Length; diff --git a/GVFS/GVFS.UnitTests/Common/Git/GitProcessCredentialTests.cs b/GVFS/GVFS.UnitTests/Common/Git/GitProcessCredentialTests.cs new file mode 100644 index 0000000000..c6ec86c673 --- /dev/null +++ b/GVFS/GVFS.UnitTests/Common/Git/GitProcessCredentialTests.cs @@ -0,0 +1,119 @@ +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"; + + [TestCase] + public void TryGetCredentialDoesNotTraceSecretWhenParseFails() + { + MockTracer tracer = new MockTracer(); + MockGitProcess gitProcess = new MockGitProcess(); + + // The secret is on the last line and has no terminating newline, so the parse fails. + gitProcess.SetExpectedCommandResult( + $"{AzureDevOpsUseHttpPathString} credential fill", + () => new GitProcess.Result( + "protocol=https\nhost=example.com\nhelper output " + SecretValue + "=ignored\nusername=someone\npassword=" + SecretValue, + string.Empty, + GitProcess.Result.SuccessCode)); + + gitProcess.TryGetCredential(tracer, "mock://repoUrl", out _, out _, out _) + .ShouldBeFalse("Parse of the credential output must fail for this test"); + + EventMetadata metadata = GetActivityMetadata(tracer); + AssertNoSecret(metadata); + metadata["OutputKeys"].ShouldEqual("protocol,host,,username,password"); + } + + [TestCase] + public void TryGetCertificatePasswordDoesNotTraceSecretWhenParseFails() + { + MockTracer tracer = new MockTracer(); + MockGitProcess gitProcess = new MockGitProcess(); + + // The secret is on the last line and has no terminating newline, so the parse fails. + gitProcess.SetExpectedCommandResult( + "credential fill", + () => new GitProcess.Result( + "protocol=cert\npath=mock://certificate\npassword=" + SecretValue, + string.Empty, + GitProcess.Result.SuccessCode)); + + gitProcess.TryGetCertificatePassword(tracer, "mock://certificate", out _, out _) + .ShouldBeFalse("Parse of the credential output must fail for this test"); + + EventMetadata metadata = GetActivityMetadata(tracer); + AssertNoSecret(metadata); + metadata["OutputKeys"].ShouldEqual("protocol,path,password"); + } + + [TestCase("", "")] + [TestCase("protocol=https\r\nhost=example.com\r\nusername=someone\r\npassword=" + SecretValue, "protocol,host,username,password")] + [TestCase("protocol=https\rhost=example.com\nusername=someone\npassword=" + SecretValue, "protocol,host,username,password")] + [TestCase("no separator " + SecretValue + "\n=" + SecretValue + "\nusername=someone\npassword=" + SecretValue, ",,username,password")] + [TestCase("protocol=https\nhost=example.com\nurl=https://example.com\nwwwauth[]=Bearer realm=" + SecretValue + "\ncapability[]=authtype\noauth_refresh_token=" + SecretValue + "\nusername=someone\npassword=" + SecretValue, "protocol,host,url,,,,username,password")] + public void TryGetCredentialTracesOnlyKnownKeyNamesWhenParseFails(string output, string expectedKeys) + { + MockTracer tracer = new MockTracer(); + MockGitProcess gitProcess = new MockGitProcess(); + gitProcess.SetExpectedCommandResult( + $"{AzureDevOpsUseHttpPathString} credential fill", + () => new GitProcess.Result(output, string.Empty, GitProcess.Result.SuccessCode)); + + gitProcess.TryGetCredential(tracer, "mock://repoUrl", out _, out _, out _) + .ShouldBeFalse("Parse of the credential output must fail for this test"); + + EventMetadata metadata = GetActivityMetadata(tracer); + AssertNoSecret(metadata); + metadata["OutputKeys"].ShouldEqual(expectedKeys); + } + + [TestCase] + public void TryGetCredentialDoesNotTraceOutputKeysWhenParseSucceeds() + { + MockTracer tracer = new MockTracer(); + MockGitProcess gitProcess = new MockGitProcess(); + gitProcess.SetExpectedCommandResult( + $"{AzureDevOpsUseHttpPathString} credential fill", + () => new GitProcess.Result( + "protocol=https\nhost=example.com\nusername=someone\npassword=" + SecretValue + "\n", + string.Empty, + GitProcess.Result.SuccessCode)); + + gitProcess.TryGetCredential(tracer, "mock://repoUrl", out _, out _, out _).ShouldBeTrue(); + + EventMetadata metadata = GetActivityMetadata(tracer); + metadata.ContainsKey("OutputKeys").ShouldBeFalse("Output keys are only traced when the parse fails"); + AssertNoSecret(metadata); + } + + 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(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); + } + } + } +} From 9dbaa4316dab530231cbba7bf31b8aa945310bf8 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Wed, 7 Oct 2026 15:45:37 -0700 Subject: [PATCH 17/35] Reject credential output that lacks the requested key ParseValue added the prefix length to IndexOf without checking for -1. When the key was absent, the start index landed a few characters into the output and the method returned an unrelated substring. For example, output with no "username=" line gave a username of "=https". The caller treated this as success, so malformed credential output could be cached and used for authentication. Return null when the key is absent. A helper that returns only a token does not send a username, so TryGetCredential now treats an absent username as empty and requires only the password. GitAuthentication accepts an empty username and builds the basic-auth string as ":". Output without a password is rejected. Add tests for output without a username, without a password, and the certificate-password path, plus authentication tests for both cases. Assisted-by: Claude Opus 5 Signed-off-by: Tyrie Vella --- GVFS/GVFS.Common/Git/GitAuthentication.cs | 2 +- GVFS/GVFS.Common/Git/GitProcess.cs | 16 ++++-- .../Common/Git/GitProcessCredentialTests.cs | 55 +++++++++++++++++++ .../Git/GitAuthenticationTests.cs | 32 +++++++++++ 4 files changed, 100 insertions(+), 5 deletions(-) diff --git a/GVFS/GVFS.Common/Git/GitAuthentication.cs b/GVFS/GVFS.Common/Git/GitAuthentication.cs index 37c3563b51..9df8acf2cf 100644 --- a/GVFS/GVFS.Common/Git/GitAuthentication.cs +++ b/GVFS/GVFS.Common/Git/GitAuthentication.cs @@ -438,7 +438,7 @@ 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.isCachedCredentialStringApproved = false; diff --git a/GVFS/GVFS.Common/Git/GitProcess.cs b/GVFS/GVFS.Common/Git/GitProcess.cs index e27caee649..2decdb95e0 100644 --- a/GVFS/GVFS.Common/Git/GitProcess.cs +++ b/GVFS/GVFS.Common/Git/GitProcess.cs @@ -398,10 +398,12 @@ 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 = password != null; EventMetadata metadata = new EventMetadata(); metadata.Add("Success", success); @@ -1182,8 +1184,14 @@ private static string GetCredentialOutputKeys(string credentialOutput) private static string ParseValue(string contents, string prefix) { - int startIndex = contents.IndexOf(prefix) + prefix.Length; - if (startIndex >= 0 && startIndex < contents.Length) + int prefixIndex = contents.IndexOf(prefix); + if (prefixIndex < 0) + { + return null; + } + + int startIndex = prefixIndex + prefix.Length; + if (startIndex < contents.Length) { int endIndex = contents.IndexOf('\n', startIndex); if (endIndex >= 0 && endIndex < contents.Length) diff --git a/GVFS/GVFS.UnitTests/Common/Git/GitProcessCredentialTests.cs b/GVFS/GVFS.UnitTests/Common/Git/GitProcessCredentialTests.cs index c6ec86c673..87544a9e2f 100644 --- a/GVFS/GVFS.UnitTests/Common/Git/GitProcessCredentialTests.cs +++ b/GVFS/GVFS.UnitTests/Common/Git/GitProcessCredentialTests.cs @@ -98,6 +98,61 @@ public void TryGetCredentialDoesNotTraceOutputKeysWhenParseSucceeds() AssertNoSecret(metadata); } + [TestCase] + public void TryGetCredentialAcceptsMissingUsername() + { + MockTracer tracer = new MockTracer(); + MockGitProcess gitProcess = new MockGitProcess(); + gitProcess.SetExpectedCommandResult( + $"{AzureDevOpsUseHttpPathString} credential fill", + () => new GitProcess.Result( + "protocol=https\nhost=example.com\npassword=" + SecretValue + "\n", + string.Empty, + GitProcess.Result.SuccessCode)); + + gitProcess.TryGetCredential(tracer, "mock://repoUrl", out string username, out string password, out _).ShouldBeTrue(); + + username.ShouldEqual(string.Empty); + password.ShouldEqual(SecretValue); + GetActivityMetadata(tracer).ContainsKey("OutputKeys").ShouldBeFalse("Output keys are only traced when the parse fails"); + } + + [TestCase] + public void TryGetCredentialFailsWhenPasswordKeyIsAbsent() + { + MockTracer tracer = new MockTracer(); + MockGitProcess gitProcess = new MockGitProcess(); + gitProcess.SetExpectedCommandResult( + $"{AzureDevOpsUseHttpPathString} credential fill", + () => new GitProcess.Result( + "protocol=https\nhost=example.com\nusername=someone\n", + string.Empty, + GitProcess.Result.SuccessCode)); + + gitProcess.TryGetCredential(tracer, "mock://repoUrl", out _, out string password, out _).ShouldBeFalse(); + + password.ShouldBeNull(); + GetActivityMetadata(tracer)["OutputKeys"].ShouldEqual("protocol,host,username"); + } + + [TestCase] + public void TryGetCertificatePasswordFailsWhenPasswordKeyIsAbsent() + { + MockTracer tracer = new MockTracer(); + MockGitProcess gitProcess = new MockGitProcess(); + gitProcess.SetExpectedCommandResult( + "credential fill", + () => new GitProcess.Result( + "protocol=cert\npath=mock://certificate\n", + string.Empty, + GitProcess.Result.SuccessCode)); + + gitProcess.TryGetCertificatePassword(tracer, "mock://certificate", out string password, out _).ShouldBeFalse(); + + password.ShouldBeNull(); + GetActivityMetadata(tracer)["OutputKeys"].ShouldEqual("protocol,path"); + } + private static EventMetadata GetActivityMetadata(MockTracer tracer) { MockTracer activityTracer = tracer.StartActivityTracer; diff --git a/GVFS/GVFS.UnitTests/Git/GitAuthenticationTests.cs b/GVFS/GVFS.UnitTests/Git/GitAuthenticationTests.cs index 682a80537f..c60c344a52 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,37 @@ public void BackoffIsNotInEffectAfterSuccess() } } + [TestCase] + public void CredentialWithoutUsernameIsAcceptedAsEmptyUsername() + { + 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.ASCII.GetString(Convert.FromBase64String(authString)).ShouldEqual(":token"); + } + + [TestCase] + public void CredentialWithoutPasswordIsRejected() + { + 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 _); + + dut.TryGetCredentials(tracer, out _, out _).ShouldEqual(false); + } + [TestCase] public void ContinuesToBackoffIfTryGetCredentialsFails() { From a4d73af18ae7dfc7225e2e38c91cd3a6ce1510e5 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Wed, 7 Oct 2026 16:25:05 -0700 Subject: [PATCH 18/35] Keep credential stderr out of telemetry and bound the traced output Git can echo an unparseable credential helper line to stderr, including a secret. The failure warning for "git credential fill" carried that stderr with the telemetry keyword. Report the failure in the telemetry event without stderr, and write the stderr text to the local log only. The console-visibility diagnostic stays in the telemetry event. Bound the traced key summary. It lists at most 32 lines and reports "" when the captured output was truncated, so a broken helper cannot make the summary larger than the output. Recognize the other key names in the git credential protocol, so a helper that returns a bearer token without a password is easy to diagnose. Match "username=" and "password=" only at the start of a line, so a longer key such as "oauth_password=" or a value that contains the text cannot supply the value. Return a reason when a credential has no password. Split a stored credential at the first ":" so a password that contains ":" can be approved. Add tests for each case, including stderr, which now cover every traced event instead of only the activity metadata. Fix a test that waited for the 120 second initialization timeout. Assisted-by: Claude Opus 5 Signed-off-by: Tyrie Vella --- GVFS/GVFS.Common/Git/GitAuthentication.cs | 2 +- GVFS/GVFS.Common/Git/GitProcess.cs | 149 ++++++++++---- .../Common/Git/GitProcessCredentialTests.cs | 181 +++++++++++++++++- .../Git/GitAuthenticationTests.cs | 35 +++- GVFS/GVFS.UnitTests/Mock/Common/MockTracer.cs | 8 + 5 files changed, 329 insertions(+), 46 deletions(-) diff --git a/GVFS/GVFS.Common/Git/GitAuthentication.cs b/GVFS/GVFS.Common/Git/GitAuthentication.cs index 9df8acf2cf..769aaeb0ff 100644 --- a/GVFS/GVFS.Common/Git/GitAuthentication.cs +++ b/GVFS/GVFS.Common/Git/GitAuthentication.cs @@ -379,7 +379,7 @@ private static bool TryParseCredentialString(string credentialString, out string byte[] data = Convert.FromBase64String(credentialString); string rawCredString = Encoding.ASCII.GetString(data); - string[] usernamePassword = rawCredString.Split(':'); + string[] usernamePassword = rawCredString.Split(new[] { ':' }, 2); if (usernamePassword.Length == 2) { username = usernamePassword[0]; diff --git a/GVFS/GVFS.Common/Git/GitProcess.cs b/GVFS/GVFS.Common/Git/GitProcess.cs index 2decdb95e0..0ee374fa8e 100644 --- a/GVFS/GVFS.Common/Git/GitProcess.cs +++ b/GVFS/GVFS.Common/Git/GitProcess.cs @@ -37,15 +37,33 @@ 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); + private static readonly char[] LineSeparators = { '\n', '\r' }; + + // 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" + "username", + "wwwauth[]" }; private static bool failedToSetEncoding = false; @@ -304,10 +322,7 @@ 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); + TraceCredentialFillFailure(tracer, errorData, gitCredentialOutput.Errors); errorMessage = gitCredentialOutput.Errors; return false; @@ -326,7 +341,8 @@ public virtual bool TryGetCertificatePassword( if (!success) { - metadata.Add("OutputKeys", GetCredentialOutputKeys(gitCredentialOutput.Output)); + errorMessage = MissingPasswordErrorMessage; + metadata.Add("OutputKeys", GetCredentialOutputKeys(gitCredentialOutput.Output, gitCredentialOutput.OutputTruncated)); } activity.Stop(metadata); @@ -381,10 +397,7 @@ public virtual bool TryGetCredential( else { errorMessage = gitCredentialOutput.Errors; - tracer.RelatedWarning( - errorData, - "Git could not get credentials: " + gitCredentialOutput.Errors, - Keywords.Network | Keywords.Telemetry); + TraceCredentialFillFailure(tracer, errorData, gitCredentialOutput.Errors); } return false; @@ -409,7 +422,8 @@ public virtual bool TryGetCredential( metadata.Add("Success", success); if (!success) { - metadata.Add("OutputKeys", GetCredentialOutputKeys(gitCredentialOutput.Output)); + errorMessage = MissingPasswordErrorMessage; + metadata.Add("OutputKeys", GetCredentialOutputKeys(gitCredentialOutput.Output, gitCredentialOutput.OutputTruncated)); } activity.Stop(metadata); @@ -1150,57 +1164,112 @@ private static bool CurrentProcessHasVisibleConsoleWindowSafe() } } + private static void TraceCredentialFillFailure(ITracer tracer, EventMetadata errorData, string errors) + { + // Git can echo unparseable helper output, including secrets, to stderr. The telemetry event + // carries no stderr; the text stays in the local log only. + tracer.RelatedWarning(errorData, "Git could not get credentials", Keywords.Network | Keywords.Telemetry); + tracer.RelatedWarning("Git credential fill error output: " + errors); + } + /// /// 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 values never reach telemetry or the log. + /// plaintext secret, so the summary never includes a value. /// Each line maps to a known key name, "<other>" for an unknown key, - /// or "<malformed>" for a line without a key. + /// or "<malformed>" for a line without a key. The summary lists at + /// most lines. /// - private static string GetCredentialOutputKeys(string credentialOutput) + private static string GetCredentialOutputKeys(string credentialOutput, bool outputTruncated) { if (string.IsNullOrEmpty(credentialOutput)) { return string.Empty; } - IEnumerable keys = credentialOutput - .Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries) - .Where(line => !string.IsNullOrWhiteSpace(line)) - .Select(line => + if (outputTruncated) + { + return ""; + } + + StringBuilder keys = new StringBuilder(); + int keyCount = 0; + int position = 0; + while (position < credentialOutput.Length) + { + int lineEnd = credentialOutput.IndexOfAny(LineSeparators, position); + if (lineEnd < 0) { - int separatorIndex = line.IndexOf('='); - if (separatorIndex <= 0) - { - return ""; - } + lineEnd = credentialOutput.Length; + } - string key = line.Substring(0, separatorIndex); - return CredentialOutputKeyAllowlist.Contains(key) ? key : ""; - }); + ReadOnlySpan line = credentialOutput.AsSpan(position, lineEnd - position); + 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 string.Join(",", keys); + return keys.ToString(); } + /// + /// Returns the value of the "key=value" line that starts with , + /// or null if no line has that prefix or its value is not newline-terminated. + /// private static string ParseValue(string contents, string prefix) { - int prefixIndex = contents.IndexOf(prefix); - if (prefixIndex < 0) + int searchStart = 0; + while (searchStart < contents.Length) { - return null; - } + int prefixIndex = contents.IndexOf(prefix, searchStart, StringComparison.Ordinal); + if (prefixIndex < 0) + { + return null; + } - int startIndex = prefixIndex + prefix.Length; - if (startIndex < contents.Length) - { - int endIndex = contents.IndexOf('\n', startIndex); - if (endIndex >= 0 && endIndex < contents.Length) + if (prefixIndex == 0 || contents[prefixIndex - 1] == '\n') { - return - contents - .Substring(startIndex, endIndex - startIndex) - .Trim('\r'); + int startIndex = prefixIndex + prefix.Length; + int endIndex = contents.IndexOf('\n', startIndex); + if (endIndex < 0) + { + return null; + } + + return contents.Substring(startIndex, endIndex - startIndex).Trim('\r'); } + + searchStart = prefixIndex + 1; } return null; diff --git a/GVFS/GVFS.UnitTests/Common/Git/GitProcessCredentialTests.cs b/GVFS/GVFS.UnitTests/Common/Git/GitProcessCredentialTests.cs index 87544a9e2f..cf61c65de6 100644 --- a/GVFS/GVFS.UnitTests/Common/Git/GitProcessCredentialTests.cs +++ b/GVFS/GVFS.UnitTests/Common/Git/GitProcessCredentialTests.cs @@ -34,6 +34,7 @@ public void TryGetCredentialDoesNotTraceSecretWhenParseFails() EventMetadata metadata = GetActivityMetadata(tracer); AssertNoSecret(metadata); metadata["OutputKeys"].ShouldEqual("protocol,host,,username,password"); + AssertNoSecret(tracer); } [TestCase] @@ -56,13 +57,16 @@ public void TryGetCertificatePasswordDoesNotTraceSecretWhenParseFails() EventMetadata metadata = GetActivityMetadata(tracer); AssertNoSecret(metadata); metadata["OutputKeys"].ShouldEqual("protocol,path,password"); + AssertNoSecret(tracer); } [TestCase("", "")] [TestCase("protocol=https\r\nhost=example.com\r\nusername=someone\r\npassword=" + SecretValue, "protocol,host,username,password")] [TestCase("protocol=https\rhost=example.com\nusername=someone\npassword=" + SecretValue, "protocol,host,username,password")] [TestCase("no separator " + SecretValue + "\n=" + SecretValue + "\nusername=someone\npassword=" + SecretValue, ",,username,password")] - [TestCase("protocol=https\nhost=example.com\nurl=https://example.com\nwwwauth[]=Bearer realm=" + SecretValue + "\ncapability[]=authtype\noauth_refresh_token=" + SecretValue + "\nusername=someone\npassword=" + SecretValue, "protocol,host,url,,,,username,password")] + [TestCase("protocol=https\nhost=example.com\nurl=https://example.com\nwwwauth[]=Bearer realm=" + SecretValue + "\ncapability[]=authtype\noauth_refresh_token=" + SecretValue + "\nusername=someone\npassword=" + SecretValue, "protocol,host,url,wwwauth[],capability[],oauth_refresh_token,username,password")] + [TestCase("protocol=https\n password=" + SecretValue + "\nPassword=" + SecretValue + "\npassword =" + SecretValue + "\nusername=someone\npassword=" + SecretValue, "protocol,,,,username,password")] + [TestCase("custom_" + SecretValue + "=ignored\nusername=someone\npassword=" + SecretValue, ",username,password")] public void TryGetCredentialTracesOnlyKnownKeyNamesWhenParseFails(string output, string expectedKeys) { MockTracer tracer = new MockTracer(); @@ -77,6 +81,7 @@ public void TryGetCredentialTracesOnlyKnownKeyNamesWhenParseFails(string output, EventMetadata metadata = GetActivityMetadata(tracer); AssertNoSecret(metadata); metadata["OutputKeys"].ShouldEqual(expectedKeys); + AssertNoSecret(tracer); } [TestCase] @@ -153,6 +158,161 @@ public void TryGetCertificatePasswordFailsWhenPasswordKeyIsAbsent() GetActivityMetadata(tracer)["OutputKeys"].ShouldEqual("protocol,path"); } + [TestCase] + public void TryGetCredentialLimitsTheNumberOfTracedKeys() + { + MockTracer tracer = new MockTracer(); + MockGitProcess gitProcess = new MockGitProcess(); + gitProcess.SetExpectedCommandResult( + $"{AzureDevOpsUseHttpPathString} credential fill", + () => new GitProcess.Result( + string.Concat(Enumerable.Repeat("key=value\n", 100)) + "password=" + SecretValue, + string.Empty, + GitProcess.Result.SuccessCode)); + + gitProcess.TryGetCredential(tracer, "mock://repoUrl", out _, out _, out _).ShouldBeFalse(); + + string expectedKeys = string.Join(",", Enumerable.Repeat("", 32)) + ",..."; + GetActivityMetadata(tracer)["OutputKeys"].ShouldEqual(expectedKeys); + } + + [TestCase] + public void TryGetCredentialDoesNotTraceKeysWhenOutputIsTruncated() + { + MockTracer tracer = new MockTracer(); + MockGitProcess gitProcess = new MockGitProcess(); + gitProcess.SetExpectedCommandResult( + $"{AzureDevOpsUseHttpPathString} credential fill", + () => new GitProcess.Result("protocol=https\nhost=example.com\n", string.Empty, GitProcess.Result.SuccessCode, outputTruncated: true, errorsTruncated: false)); + + gitProcess.TryGetCredential(tracer, "mock://repoUrl", out _, out _, out _).ShouldBeFalse(); + + GetActivityMetadata(tracer)["OutputKeys"].ShouldEqual(""); + } + + [TestCase] + public void TryGetCredentialReportsWhyItFailedWhenThePasswordIsMissing() + { + MockTracer tracer = new MockTracer(); + MockGitProcess gitProcess = new MockGitProcess(); + gitProcess.SetExpectedCommandResult( + $"{AzureDevOpsUseHttpPathString} credential fill", + () => new GitProcess.Result("protocol=https\nusername=someone\n", string.Empty, GitProcess.Result.SuccessCode)); + + gitProcess.TryGetCredential(tracer, "mock://repoUrl", out _, out _, out string errorMessage).ShouldBeFalse(); + + errorMessage.ShouldEqual("Git credential fill returned no password"); + } + + [TestCase] + public void TryGetCertificatePasswordReportsWhyItFailedWhenThePasswordIsMissing() + { + MockTracer tracer = new MockTracer(); + MockGitProcess gitProcess = new MockGitProcess(); + gitProcess.SetExpectedCommandResult( + "credential fill", + () => new GitProcess.Result("protocol=cert\npath=mock://certificate\n", string.Empty, GitProcess.Result.SuccessCode)); + + gitProcess.TryGetCertificatePassword(tracer, "mock://certificate", out _, out string errorMessage).ShouldBeFalse(); + + errorMessage.ShouldEqual("Git credential fill returned no password"); + } + + [TestCase] + public void TryGetCertificatePasswordDoesNotTraceOutputKeysWhenParseSucceeds() + { + MockTracer tracer = new MockTracer(); + MockGitProcess gitProcess = new MockGitProcess(); + gitProcess.SetExpectedCommandResult( + "credential fill", + () => new GitProcess.Result( + "protocol=cert\npath=mock://certificate\npassword=" + SecretValue + "\n", + string.Empty, + GitProcess.Result.SuccessCode)); + + gitProcess.TryGetCertificatePassword(tracer, "mock://certificate", out string password, out _).ShouldBeTrue(); + + password.ShouldEqual(SecretValue); + EventMetadata metadata = GetActivityMetadata(tracer); + metadata.ContainsKey("OutputKeys").ShouldBeFalse("Output keys are only traced when the parse fails"); + } + + [TestCase] + public void TryGetCredentialDoesNotSendStderrToTelemetryWhenGitFails() + { + MockTracer tracer = new MockTracer(); + MockGitProcess gitProcess = new MockGitProcess(); + gitProcess.SetExpectedCommandResult( + $"{AzureDevOpsUseHttpPathString} credential fill", + () => new GitProcess.Result( + string.Empty, + "warning: invalid credential line: " + SecretValue, + GitProcess.Result.GenericFailureCode)); + + gitProcess.TryGetCredential(tracer, "mock://repoUrl", out _, out _, out string errorMessage).ShouldBeFalse(); + + AssertStderrIsOnlyLocal(tracer); + errorMessage.Contains(SecretValue).ShouldBeTrue("The caller still receives the error text"); + } + + [TestCase] + public void TryGetCertificatePasswordDoesNotSendStderrToTelemetryWhenGitFails() + { + MockTracer tracer = new MockTracer(); + MockGitProcess gitProcess = new MockGitProcess(); + gitProcess.SetExpectedCommandResult( + "credential fill", + () => new GitProcess.Result( + string.Empty, + "warning: invalid credential line: " + SecretValue, + GitProcess.Result.GenericFailureCode)); + + gitProcess.TryGetCertificatePassword(tracer, "mock://certificate", out _, out _).ShouldBeFalse(); + + AssertStderrIsOnlyLocal(tracer); + } + + [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")] + public void TryGetCredentialReadsOnlyLinesThatStartWithTheKey(string output, string expectedUsername, string expectedPassword) + { + MockTracer tracer = new MockTracer(); + MockGitProcess gitProcess = new MockGitProcess(); + gitProcess.SetExpectedCommandResult( + $"{AzureDevOpsUseHttpPathString} credential fill", + () => new GitProcess.Result(output, string.Empty, GitProcess.Result.SuccessCode)); + + 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")] + public void TryGetCredentialFailsWhenOnlyAnotherKeyContainsThePasswordKey(string output) + { + MockTracer tracer = new MockTracer(); + MockGitProcess gitProcess = new MockGitProcess(); + gitProcess.SetExpectedCommandResult( + $"{AzureDevOpsUseHttpPathString} credential fill", + () => new GitProcess.Result(output, string.Empty, GitProcess.Result.SuccessCode)); + + gitProcess.TryGetCredential(tracer, "mock://repoUrl", out _, out string password, out _).ShouldBeFalse(); + + password.ShouldBeNull(); + } + + private static void AssertStderrIsOnlyLocal(MockTracer tracer) + { + tracer.TelemetryWarningEvents.Count.ShouldEqual(1); + tracer.TelemetryWarningEvents.Single().Contains(SecretValue).ShouldBeFalse("Telemetry events must not carry stderr"); + tracer.TelemetryWarningEvents.Single().Contains("hasVisibleConsoleWindow").ShouldBeTrue("The console visibility diagnostic stays in telemetry"); + tracer.RelatedWarningEvents.Any(e => e.Contains(SecretValue)).ShouldBeTrue("The local log keeps stderr for diagnosis"); + } + private static EventMetadata GetActivityMetadata(MockTracer tracer) { MockTracer activityTracer = tracer.StartActivityTracer; @@ -162,6 +322,25 @@ private static EventMetadata GetActivityMetadata(MockTracer tracer) 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) + .Concat(source.TelemetryWarningEvents)) + { + traced.Contains(SecretValue).ShouldBeFalse("Credential output must not be traced: " + traced); + } + + foreach (EventMetadata related in source.RelatedEventMetadata.Where(m => m != null)) + { + AssertNoSecret(related); + } + } + } + private static void AssertNoSecret(EventMetadata metadata) { foreach (object value in metadata.Values) diff --git a/GVFS/GVFS.UnitTests/Git/GitAuthenticationTests.cs b/GVFS/GVFS.UnitTests/Git/GitAuthenticationTests.cs index c60c344a52..c107c33a48 100644 --- a/GVFS/GVFS.UnitTests/Git/GitAuthenticationTests.cs +++ b/GVFS/GVFS.UnitTests/Git/GitAuthenticationTests.cs @@ -79,7 +79,7 @@ public void BackoffIsNotInEffectAfterSuccess() } [TestCase] - public void CredentialWithoutUsernameIsAcceptedAsEmptyUsername() + public void CredentialWithoutUsernameIsAcceptedAndApprovedWithEmptyUsername() { MockTracer tracer = new MockTracer(); MockGitProcess gitProcess = this.GetGitProcess(); @@ -92,21 +92,48 @@ public void CredentialWithoutUsernameIsAcceptedAsEmptyUsername() dut.TryGetCredentials(tracer, out string authString, out string error).ShouldEqual(true, "Failed to get credential: " + error); Encoding.ASCII.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 CredentialWithoutPasswordIsRejected() + public void CredentialWithColonInPasswordCanBeApproved() { 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)); + () => 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 _, out _).ShouldEqual(false); + 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 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] diff --git a/GVFS/GVFS.UnitTests/Mock/Common/MockTracer.cs b/GVFS/GVFS.UnitTests/Mock/Common/MockTracer.cs index be94a0715e..dc3fbf2e95 100644 --- a/GVFS/GVFS.UnitTests/Mock/Common/MockTracer.cs +++ b/GVFS/GVFS.UnitTests/Mock/Common/MockTracer.cs @@ -20,6 +20,7 @@ public MockTracer() this.RelatedEventKeywords = new List(); this.RelatedEventMetadata = new List(); this.StopMetadata = new List(); + this.TelemetryWarningEvents = new List(); } public MockTracer StartActivityTracer { get; private set; } @@ -39,6 +40,9 @@ public MockTracer() // 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 TelemetryWarningEvents { get; } + public void WaitForRelatedEvent() { this.waitEvent.WaitOne(); @@ -98,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.TelemetryWarningEvents.Add(GVFSJsonOptions.Serialize(metadata)); + } } public void RelatedWarning(string message) From 81a6994edcde3a713db02514a28671f15fb49464 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Tue, 13 Oct 2026 12:57:23 -0700 Subject: [PATCH 19/35] Report a rejected credential in telemetry and read output like git The activity stop event for a credential fill uses no telemetry keyword, so the output keys that explain a rejected credential never left the machine. A helper whose output shape the parser rejects was a silent failure. Report a rejected credential in a warning that carries the telemetry keyword, with the allowed key names, the exit code, and the length and truncation of stderr. Add the exit code and the stderr length and truncation to the failure warning as well, so the failures of a helper can be told apart without sending stderr. Make the parser and the key summary agree on what a line is, and match how git reads credential output. A line ends at a newline, the last line need not end with one, a carriage return is not a separator, and a repeated key takes its last value. Git writes each key once and ends every line, so this does not change what a real helper returns. It makes the parser no stricter than git. Test failures with an absent password instead of a missing final newline, which git never produces. Use the stop metadata that the mock tracer already records. Assisted-by: Claude Opus 5 Signed-off-by: Tyrie Vella --- GVFS/GVFS.Common/Git/GitProcess.cs | 68 ++-- .../Common/Git/GitProcessCredentialTests.cs | 322 +++++++----------- GVFS/GVFS.UnitTests/Mock/Common/MockTracer.cs | 6 +- 3 files changed, 174 insertions(+), 222 deletions(-) diff --git a/GVFS/GVFS.Common/Git/GitProcess.cs b/GVFS/GVFS.Common/Git/GitProcess.cs index 0ee374fa8e..224b42edce 100644 --- a/GVFS/GVFS.Common/Git/GitProcess.cs +++ b/GVFS/GVFS.Common/Git/GitProcess.cs @@ -42,7 +42,6 @@ public class GitProcess : ICredentialStore private const int MaxCredentialOutputKeyLength = 32; private static readonly Encoding UTF8NoBOM = new UTF8Encoding(false); - private static readonly char[] LineSeparators = { '\n', '\r' }; // 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 "". @@ -322,6 +321,7 @@ public virtual bool TryGetCertificatePassword( EventMetadata errorData = new EventMetadata(); errorData.Add("CertificatePath", certificatePath); errorData.Add("hasVisibleConsoleWindow", hasVisibleConsoleWindow); + AddCredentialFillResult(errorData, gitCredentialOutput); TraceCredentialFillFailure(tracer, errorData, gitCredentialOutput.Errors); errorMessage = gitCredentialOutput.Errors; @@ -342,7 +342,10 @@ public virtual bool TryGetCertificatePassword( if (!success) { errorMessage = MissingPasswordErrorMessage; - metadata.Add("OutputKeys", GetCredentialOutputKeys(gitCredentialOutput.Output, gitCredentialOutput.OutputTruncated)); + EventMetadata rejectedData = new EventMetadata(); + rejectedData.Add("CertificatePath", certificatePath); + rejectedData.Add("hasVisibleConsoleWindow", hasVisibleConsoleWindow); + TraceCredentialFillMissingPassword(tracer, rejectedData, gitCredentialOutput); } activity.Stop(metadata); @@ -385,6 +388,7 @@ public virtual bool TryGetCredential( { EventMetadata errorData = new EventMetadata(); errorData.Add("hasVisibleConsoleWindow", hasVisibleConsoleWindow); + AddCredentialFillResult(errorData, gitCredentialOutput); if (gitCredentialOutput.Errors.StartsWith("Operation timed out")) { @@ -423,7 +427,9 @@ public virtual bool TryGetCredential( if (!success) { errorMessage = MissingPasswordErrorMessage; - metadata.Add("OutputKeys", GetCredentialOutputKeys(gitCredentialOutput.Output, gitCredentialOutput.OutputTruncated)); + EventMetadata rejectedData = new EventMetadata(); + rejectedData.Add("hasVisibleConsoleWindow", hasVisibleConsoleWindow); + TraceCredentialFillMissingPassword(tracer, rejectedData, gitCredentialOutput); } activity.Stop(metadata); @@ -1164,6 +1170,14 @@ 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) { // Git can echo unparseable helper output, including secrets, to stderr. The telemetry event @@ -1172,13 +1186,21 @@ private static void TraceCredentialFillFailure(ITracer tracer, EventMetadata err tracer.RelatedWarning("Git credential fill error output: " + errors); } + 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. - /// 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. + /// 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) { @@ -1197,13 +1219,13 @@ private static string GetCredentialOutputKeys(string credentialOutput, bool outp int position = 0; while (position < credentialOutput.Length) { - int lineEnd = credentialOutput.IndexOfAny(LineSeparators, position); + int lineEnd = credentialOutput.IndexOf('\n', position); if (lineEnd < 0) { lineEnd = credentialOutput.Length; } - ReadOnlySpan line = credentialOutput.AsSpan(position, lineEnd - position); + ReadOnlySpan line = credentialOutput.AsSpan(position, lineEnd - position).TrimEnd('\r'); position = lineEnd + 1; if (line.IsWhiteSpace()) { @@ -1244,35 +1266,31 @@ private static string GetCredentialOutputKeys(string credentialOutput, bool outp /// /// Returns the value of the "key=value" line that starts with , - /// or null if no line has that prefix or its value is not newline-terminated. + /// 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. /// private static string ParseValue(string contents, string prefix) { - int searchStart = 0; - while (searchStart < contents.Length) + string value = null; + int position = 0; + while (position < contents.Length) { - int prefixIndex = contents.IndexOf(prefix, searchStart, StringComparison.Ordinal); - if (prefixIndex < 0) + int lineEnd = contents.IndexOf('\n', position); + if (lineEnd < 0) { - return null; + lineEnd = contents.Length; } - if (prefixIndex == 0 || contents[prefixIndex - 1] == '\n') + ReadOnlySpan line = contents.AsSpan(position, lineEnd - position).TrimEnd('\r'); + if (line.StartsWith(prefix, StringComparison.Ordinal)) { - int startIndex = prefixIndex + prefix.Length; - int endIndex = contents.IndexOf('\n', startIndex); - if (endIndex < 0) - { - return null; - } - - return contents.Substring(startIndex, endIndex - startIndex).Trim('\r'); + value = line.Slice(prefix.Length).ToString(); } - searchStart = prefixIndex + 1; + position = lineEnd + 1; } - return null; + return value; } /// diff --git a/GVFS/GVFS.UnitTests/Common/Git/GitProcessCredentialTests.cs b/GVFS/GVFS.UnitTests/Common/Git/GitProcessCredentialTests.cs index cf61c65de6..627fa26045 100644 --- a/GVFS/GVFS.UnitTests/Common/Git/GitProcessCredentialTests.cs +++ b/GVFS/GVFS.UnitTests/Common/Git/GitProcessCredentialTests.cs @@ -13,276 +13,187 @@ 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 TryGetCredentialDoesNotTraceSecretWhenParseFails() + public void TryGetCredentialDoesNotTraceSecretWhenThePasswordIsMissing() { MockTracer tracer = new MockTracer(); - MockGitProcess gitProcess = new MockGitProcess(); + MockGitProcess gitProcess = CreateGitProcess( + CredentialFillCommand, + "protocol=https\nhost=example.com\nhelper output " + SecretValue + "=ignored\nusername=someone\ncredential=" + SecretValue + "\noauth_refresh_token=" + SecretValue + "\n"); - // The secret is on the last line and has no terminating newline, so the parse fails. - gitProcess.SetExpectedCommandResult( - $"{AzureDevOpsUseHttpPathString} credential fill", - () => new GitProcess.Result( - "protocol=https\nhost=example.com\nhelper output " + SecretValue + "=ignored\nusername=someone\npassword=" + SecretValue, - string.Empty, - GitProcess.Result.SuccessCode)); - - gitProcess.TryGetCredential(tracer, "mock://repoUrl", out _, out _, out _) - .ShouldBeFalse("Parse of the credential output must fail for this test"); - - EventMetadata metadata = GetActivityMetadata(tracer); - AssertNoSecret(metadata); - metadata["OutputKeys"].ShouldEqual("protocol,host,,username,password"); + 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 TryGetCertificatePasswordDoesNotTraceSecretWhenParseFails() + public void TryGetCertificatePasswordDoesNotTraceSecretWhenThePasswordIsMissing() { MockTracer tracer = new MockTracer(); - MockGitProcess gitProcess = new MockGitProcess(); + MockGitProcess gitProcess = CreateGitProcess( + CertificateFillCommand, + "protocol=cert\npath=mock://certificate\ncredential=" + SecretValue + "\n"); - // The secret is on the last line and has no terminating newline, so the parse fails. - gitProcess.SetExpectedCommandResult( - "credential fill", - () => new GitProcess.Result( - "protocol=cert\npath=mock://certificate\npassword=" + SecretValue, - string.Empty, - GitProcess.Result.SuccessCode)); - - gitProcess.TryGetCertificatePassword(tracer, "mock://certificate", out _, out _) - .ShouldBeFalse("Parse of the credential output must fail for this test"); - - EventMetadata metadata = GetActivityMetadata(tracer); - AssertNoSecret(metadata); - metadata["OutputKeys"].ShouldEqual("protocol,path,password"); + 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\npassword=" + SecretValue, "protocol,host,username,password")] - [TestCase("protocol=https\rhost=example.com\nusername=someone\npassword=" + SecretValue, "protocol,host,username,password")] - [TestCase("no separator " + SecretValue + "\n=" + SecretValue + "\nusername=someone\npassword=" + SecretValue, ",,username,password")] - [TestCase("protocol=https\nhost=example.com\nurl=https://example.com\nwwwauth[]=Bearer realm=" + SecretValue + "\ncapability[]=authtype\noauth_refresh_token=" + SecretValue + "\nusername=someone\npassword=" + SecretValue, "protocol,host,url,wwwauth[],capability[],oauth_refresh_token,username,password")] - [TestCase("protocol=https\n password=" + SecretValue + "\nPassword=" + SecretValue + "\npassword =" + SecretValue + "\nusername=someone\npassword=" + SecretValue, "protocol,,,,username,password")] - [TestCase("custom_" + SecretValue + "=ignored\nusername=someone\npassword=" + SecretValue, ",username,password")] - public void TryGetCredentialTracesOnlyKnownKeyNamesWhenParseFails(string output, string expectedKeys) + [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 = new MockGitProcess(); - gitProcess.SetExpectedCommandResult( - $"{AzureDevOpsUseHttpPathString} credential fill", - () => new GitProcess.Result(output, string.Empty, GitProcess.Result.SuccessCode)); + MockGitProcess gitProcess = CreateGitProcess(CredentialFillCommand, output); - gitProcess.TryGetCredential(tracer, "mock://repoUrl", out _, out _, out _) - .ShouldBeFalse("Parse of the credential output must fail for this test"); + gitProcess.TryGetCredential(tracer, "mock://repoUrl", out _, out _, out _).ShouldBeFalse(); - EventMetadata metadata = GetActivityMetadata(tracer); - AssertNoSecret(metadata); - metadata["OutputKeys"].ShouldEqual(expectedKeys); + GetTelemetryWarning(tracer)["OutputKeys"].ShouldEqual(expectedKeys); AssertNoSecret(tracer); } [TestCase] - public void TryGetCredentialDoesNotTraceOutputKeysWhenParseSucceeds() - { - MockTracer tracer = new MockTracer(); - MockGitProcess gitProcess = new MockGitProcess(); - gitProcess.SetExpectedCommandResult( - $"{AzureDevOpsUseHttpPathString} credential fill", - () => new GitProcess.Result( - "protocol=https\nhost=example.com\nusername=someone\npassword=" + SecretValue + "\n", - string.Empty, - GitProcess.Result.SuccessCode)); - - gitProcess.TryGetCredential(tracer, "mock://repoUrl", out _, out _, out _).ShouldBeTrue(); - - EventMetadata metadata = GetActivityMetadata(tracer); - metadata.ContainsKey("OutputKeys").ShouldBeFalse("Output keys are only traced when the parse fails"); - AssertNoSecret(metadata); - } - - [TestCase] - public void TryGetCredentialAcceptsMissingUsername() - { - MockTracer tracer = new MockTracer(); - MockGitProcess gitProcess = new MockGitProcess(); - gitProcess.SetExpectedCommandResult( - $"{AzureDevOpsUseHttpPathString} credential fill", - () => new GitProcess.Result( - "protocol=https\nhost=example.com\npassword=" + SecretValue + "\n", - string.Empty, - GitProcess.Result.SuccessCode)); - - gitProcess.TryGetCredential(tracer, "mock://repoUrl", out string username, out string password, out _).ShouldBeTrue(); - - username.ShouldEqual(string.Empty); - password.ShouldEqual(SecretValue); - GetActivityMetadata(tracer).ContainsKey("OutputKeys").ShouldBeFalse("Output keys are only traced when the parse fails"); - } - - [TestCase] - public void TryGetCredentialFailsWhenPasswordKeyIsAbsent() + public void TryGetCredentialLimitsTheNumberOfTracedKeys() { MockTracer tracer = new MockTracer(); - MockGitProcess gitProcess = new MockGitProcess(); - gitProcess.SetExpectedCommandResult( - $"{AzureDevOpsUseHttpPathString} credential fill", - () => new GitProcess.Result( - "protocol=https\nhost=example.com\nusername=someone\n", - string.Empty, - GitProcess.Result.SuccessCode)); - - gitProcess.TryGetCredential(tracer, "mock://repoUrl", out _, out string password, out _).ShouldBeFalse(); - - password.ShouldBeNull(); - GetActivityMetadata(tracer)["OutputKeys"].ShouldEqual("protocol,host,username"); - } + MockGitProcess gitProcess = CreateGitProcess( + CredentialFillCommand, + string.Concat(Enumerable.Repeat("key=value\n", 100))); - [TestCase] - public void TryGetCertificatePasswordFailsWhenPasswordKeyIsAbsent() - { - MockTracer tracer = new MockTracer(); - MockGitProcess gitProcess = new MockGitProcess(); - gitProcess.SetExpectedCommandResult( - "credential fill", - () => new GitProcess.Result( - "protocol=cert\npath=mock://certificate\n", - string.Empty, - GitProcess.Result.SuccessCode)); - - gitProcess.TryGetCertificatePassword(tracer, "mock://certificate", out string password, out _).ShouldBeFalse(); + gitProcess.TryGetCredential(tracer, "mock://repoUrl", out _, out _, out _).ShouldBeFalse(); - password.ShouldBeNull(); - GetActivityMetadata(tracer)["OutputKeys"].ShouldEqual("protocol,path"); + string expectedKeys = string.Join(",", Enumerable.Repeat("", 32)) + ",..."; + GetTelemetryWarning(tracer)["OutputKeys"].ShouldEqual(expectedKeys); } [TestCase] - public void TryGetCredentialLimitsTheNumberOfTracedKeys() + public void TryGetCredentialDoesNotTraceKeysWhenOutputIsTruncated() { MockTracer tracer = new MockTracer(); - MockGitProcess gitProcess = new MockGitProcess(); - gitProcess.SetExpectedCommandResult( - $"{AzureDevOpsUseHttpPathString} credential fill", - () => new GitProcess.Result( - string.Concat(Enumerable.Repeat("key=value\n", 100)) + "password=" + SecretValue, - string.Empty, - GitProcess.Result.SuccessCode)); + MockGitProcess gitProcess = CreateGitProcess( + CredentialFillCommand, + "protocol=https\nhost=example.com\n", + outputTruncated: true); gitProcess.TryGetCredential(tracer, "mock://repoUrl", out _, out _, out _).ShouldBeFalse(); - string expectedKeys = string.Join(",", Enumerable.Repeat("", 32)) + ",..."; - GetActivityMetadata(tracer)["OutputKeys"].ShouldEqual(expectedKeys); + GetTelemetryWarning(tracer)["OutputKeys"].ShouldEqual(""); } [TestCase] - public void TryGetCredentialDoesNotTraceKeysWhenOutputIsTruncated() + public void TryGetCredentialReportsTheFailureInTelemetryWhenThePasswordIsMissing() { MockTracer tracer = new MockTracer(); - MockGitProcess gitProcess = new MockGitProcess(); - gitProcess.SetExpectedCommandResult( - $"{AzureDevOpsUseHttpPathString} credential fill", - () => new GitProcess.Result("protocol=https\nhost=example.com\n", string.Empty, GitProcess.Result.SuccessCode, outputTruncated: true, errorsTruncated: false)); + MockGitProcess gitProcess = CreateGitProcess(CredentialFillCommand, "protocol=https\nhost=example.com\nusername=someone\n"); gitProcess.TryGetCredential(tracer, "mock://repoUrl", out _, out _, out _).ShouldBeFalse(); - GetActivityMetadata(tracer)["OutputKeys"].ShouldEqual(""); + 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 TryGetCredentialReportsWhyItFailedWhenThePasswordIsMissing() + public void TryGetCredentialDoesNotReportAFailureWhenTheParseSucceeds() { MockTracer tracer = new MockTracer(); - MockGitProcess gitProcess = new MockGitProcess(); - gitProcess.SetExpectedCommandResult( - $"{AzureDevOpsUseHttpPathString} credential fill", - () => new GitProcess.Result("protocol=https\nusername=someone\n", string.Empty, GitProcess.Result.SuccessCode)); + MockGitProcess gitProcess = CreateGitProcess( + CredentialFillCommand, + "protocol=https\nhost=example.com\nusername=someone\npassword=" + SecretValue + "\n"); - gitProcess.TryGetCredential(tracer, "mock://repoUrl", out _, out _, out string errorMessage).ShouldBeFalse(); + gitProcess.TryGetCredential(tracer, "mock://repoUrl", out _, out string password, out _).ShouldBeTrue(); - errorMessage.ShouldEqual("Git credential fill returned no password"); + password.ShouldEqual(SecretValue); + tracer.TelemetryWarnings.ShouldBeEmpty(); + GetActivityMetadata(tracer).ContainsKey("OutputKeys").ShouldBeFalse("Output keys are only traced when the parse fails"); } [TestCase] - public void TryGetCertificatePasswordReportsWhyItFailedWhenThePasswordIsMissing() + public void TryGetCertificatePasswordDoesNotReportAFailureWhenTheParseSucceeds() { MockTracer tracer = new MockTracer(); - MockGitProcess gitProcess = new MockGitProcess(); - gitProcess.SetExpectedCommandResult( - "credential fill", - () => new GitProcess.Result("protocol=cert\npath=mock://certificate\n", string.Empty, GitProcess.Result.SuccessCode)); + MockGitProcess gitProcess = CreateGitProcess( + CertificateFillCommand, + "protocol=cert\npath=mock://certificate\npassword=" + SecretValue + "\n"); - gitProcess.TryGetCertificatePassword(tracer, "mock://certificate", out _, out string errorMessage).ShouldBeFalse(); + gitProcess.TryGetCertificatePassword(tracer, "mock://certificate", out string password, out _).ShouldBeTrue(); - errorMessage.ShouldEqual("Git credential fill returned no password"); + password.ShouldEqual(SecretValue); + tracer.TelemetryWarnings.ShouldBeEmpty(); + GetActivityMetadata(tracer).ContainsKey("OutputKeys").ShouldBeFalse("Output keys are only traced when the parse fails"); } [TestCase] - public void TryGetCertificatePasswordDoesNotTraceOutputKeysWhenParseSucceeds() + public void TryGetCredentialAcceptsMissingUsername() { MockTracer tracer = new MockTracer(); - MockGitProcess gitProcess = new MockGitProcess(); - gitProcess.SetExpectedCommandResult( - "credential fill", - () => new GitProcess.Result( - "protocol=cert\npath=mock://certificate\npassword=" + SecretValue + "\n", - string.Empty, - GitProcess.Result.SuccessCode)); + MockGitProcess gitProcess = CreateGitProcess( + CredentialFillCommand, + "protocol=https\nhost=example.com\npassword=" + SecretValue + "\n"); - gitProcess.TryGetCertificatePassword(tracer, "mock://certificate", out string password, out _).ShouldBeTrue(); + gitProcess.TryGetCredential(tracer, "mock://repoUrl", out string username, out string password, out _).ShouldBeTrue(); + username.ShouldEqual(string.Empty); password.ShouldEqual(SecretValue); - EventMetadata metadata = GetActivityMetadata(tracer); - metadata.ContainsKey("OutputKeys").ShouldBeFalse("Output keys are only traced when the parse fails"); } [TestCase] - public void TryGetCredentialDoesNotSendStderrToTelemetryWhenGitFails() + public void TryGetCredentialDescribesAFailedFillWithoutSendingStderrToTelemetry() { MockTracer tracer = new MockTracer(); - MockGitProcess gitProcess = new MockGitProcess(); - gitProcess.SetExpectedCommandResult( - $"{AzureDevOpsUseHttpPathString} credential fill", - () => new GitProcess.Result( - string.Empty, - "warning: invalid credential line: " + SecretValue, - GitProcess.Result.GenericFailureCode)); + 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); - errorMessage.Contains(SecretValue).ShouldBeTrue("The caller still receives the error text"); + AssertStderrIsOnlyLocal(tracer, stderr); + errorMessage.ShouldEqual(stderr); } [TestCase] - public void TryGetCertificatePasswordDoesNotSendStderrToTelemetryWhenGitFails() + public void TryGetCertificatePasswordDescribesAFailedFillWithoutSendingStderrToTelemetry() { MockTracer tracer = new MockTracer(); - MockGitProcess gitProcess = new MockGitProcess(); - gitProcess.SetExpectedCommandResult( - "credential fill", - () => new GitProcess.Result( - string.Empty, - "warning: invalid credential line: " + SecretValue, - GitProcess.Result.GenericFailureCode)); + string stderr = "warning: invalid credential line: " + SecretValue; + MockGitProcess gitProcess = CreateGitProcess(CertificateFillCommand, string.Empty, stderr, GitFatalExitCode); - gitProcess.TryGetCertificatePassword(tracer, "mock://certificate", out _, out _).ShouldBeFalse(); + gitProcess.TryGetCertificatePassword(tracer, "mock://certificate", out _, out string errorMessage).ShouldBeFalse(); - AssertStderrIsOnlyLocal(tracer); + AssertStderrIsOnlyLocal(tracer, stderr); + GetTelemetryWarning(tracer)["CertificatePath"].ShouldEqual("mock://certificate"); + errorMessage.ShouldEqual(stderr); } [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")] - public void TryGetCredentialReadsOnlyLinesThatStartWithTheKey(string output, string expectedUsername, string expectedPassword) + [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 = new MockGitProcess(); - gitProcess.SetExpectedCommandResult( - $"{AzureDevOpsUseHttpPathString} credential fill", - () => new GitProcess.Result(output, string.Empty, GitProcess.Result.SuccessCode)); + MockGitProcess gitProcess = CreateGitProcess(CredentialFillCommand, output); gitProcess.TryGetCredential(tracer, "mock://repoUrl", out string username, out string password, out _).ShouldBeTrue(); @@ -292,27 +203,48 @@ public void TryGetCredentialReadsOnlyLinesThatStartWithTheKey(string output, str [TestCase("oauth_password=WRONG\nusername=someone\n")] [TestCase("path=contains-password=WRONG\nusername=someone\n")] - public void TryGetCredentialFailsWhenOnlyAnotherKeyContainsThePasswordKey(string output) + [TestCase("username=someone\rpassword=WRONG\n")] + public void TryGetCredentialFailsWhenNoLineStartsWithThePasswordKey(string output) { MockTracer tracer = new MockTracer(); - MockGitProcess gitProcess = new MockGitProcess(); - gitProcess.SetExpectedCommandResult( - $"{AzureDevOpsUseHttpPathString} credential fill", - () => new GitProcess.Result(output, string.Empty, GitProcess.Result.SuccessCode)); + MockGitProcess gitProcess = CreateGitProcess(CredentialFillCommand, output); gitProcess.TryGetCredential(tracer, "mock://repoUrl", out _, out string password, out _).ShouldBeFalse(); password.ShouldBeNull(); } - private static void AssertStderrIsOnlyLocal(MockTracer tracer) + private static MockGitProcess CreateGitProcess( + string command, + string output, + string errors = "", + int exitCode = GitProcess.Result.SuccessCode, + bool outputTruncated = false) { - tracer.TelemetryWarningEvents.Count.ShouldEqual(1); - tracer.TelemetryWarningEvents.Single().Contains(SecretValue).ShouldBeFalse("Telemetry events must not carry stderr"); - tracer.TelemetryWarningEvents.Single().Contains("hasVisibleConsoleWindow").ShouldBeTrue("The console visibility diagnostic stays in telemetry"); + MockGitProcess gitProcess = new MockGitProcess(); + gitProcess.SetExpectedCommandResult( + command, + () => new GitProcess.Result(output, errors, exitCode, outputTruncated, errorsTruncated: false)); + return gitProcess; + } + + 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; @@ -328,15 +260,17 @@ private static void AssertNoSecret(MockTracer tracer) { foreach (string traced in source.RelatedInfoEvents .Concat(source.RelatedWarningEvents) - .Concat(source.RelatedErrorEvents) - .Concat(source.TelemetryWarningEvents)) + .Concat(source.RelatedErrorEvents)) { traced.Contains(SecretValue).ShouldBeFalse("Credential output must not be traced: " + traced); } - foreach (EventMetadata related in source.RelatedEventMetadata.Where(m => m != null)) + foreach (EventMetadata metadata in source.TelemetryWarnings + .Concat(source.RelatedEventMetadata) + .Concat(source.StopMetadata) + .Where(m => m != null)) { - AssertNoSecret(related); + AssertNoSecret(metadata); } } } diff --git a/GVFS/GVFS.UnitTests/Mock/Common/MockTracer.cs b/GVFS/GVFS.UnitTests/Mock/Common/MockTracer.cs index dc3fbf2e95..5dba402b91 100644 --- a/GVFS/GVFS.UnitTests/Mock/Common/MockTracer.cs +++ b/GVFS/GVFS.UnitTests/Mock/Common/MockTracer.cs @@ -20,7 +20,7 @@ public MockTracer() this.RelatedEventKeywords = new List(); this.RelatedEventMetadata = new List(); this.StopMetadata = new List(); - this.TelemetryWarningEvents = new List(); + this.TelemetryWarnings = new List(); } public MockTracer StartActivityTracer { get; private set; } @@ -41,7 +41,7 @@ public MockTracer() public List StopMetadata { get; } // Warnings reported with Keywords.Telemetry, which a telemetry listener would ship off the machine. - public List TelemetryWarningEvents { get; } + public List TelemetryWarnings { get; } public void WaitForRelatedEvent() { @@ -104,7 +104,7 @@ public void RelatedWarning(EventMetadata metadata, string message, Keywords keyw this.RelatedWarning(metadata, message); if (metadata != null && (keyword & Keywords.Telemetry) != 0) { - this.TelemetryWarningEvents.Add(GVFSJsonOptions.Serialize(metadata)); + this.TelemetryWarnings.Add(metadata); } } From 0a3f274ba93d28cac26c1cb15823eca9940f6e8a Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Thu, 15 Oct 2026 09:54:25 -0700 Subject: [PATCH 20/35] Keep stderr out of the returned credential error message A failed "git credential fill" returned its stderr as the error message. The caller puts that message in an exception, and the retry handler reports the final failure of a request with the telemetry keyword, so a secret that git echoed to stderr could still leave the machine. Return a message that names the exit code, and keep the stderr text in the local log only. Treat an empty password like a missing one, so the failure has the same reason and the same telemetry warning on both layers. Write the captured stderr of a credential manager timeout to the local log as well, which is the case where it is most likely to explain the hang. Encode and decode the stored credential as UTF-8 instead of ASCII. ASCII replaced every other character with "?", which sent a corrupted credential to the server and to "git credential approve". Document that the parser reads all of the output because the last value wins, and that it skips empty lines where git stops reading at the first one. Add tests at the authentication boundary for the returned message and for non-ASCII credentials, and tests for an empty password and a timeout. Assisted-by: Claude Opus 5 Signed-off-by: Tyrie Vella --- GVFS/GVFS.Common/Git/GitAuthentication.cs | 6 +-- GVFS/GVFS.Common/Git/GitProcess.cs | 30 ++++++++--- .../Common/Git/GitProcessCredentialTests.cs | 54 +++++++++++++++++-- .../Git/GitAuthenticationTests.cs | 43 ++++++++++++++- 4 files changed, 119 insertions(+), 14 deletions(-) diff --git a/GVFS/GVFS.Common/Git/GitAuthentication.cs b/GVFS/GVFS.Common/Git/GitAuthentication.cs index 769aaeb0ff..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,7 +377,7 @@ 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(new[] { ':' }, 2); if (usernamePassword.Length == 2) @@ -440,7 +440,7 @@ private bool TryCallGitCredential(ITracer tracer, out string errorMessage, int t 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/GitProcess.cs b/GVFS/GVFS.Common/Git/GitProcess.cs index 224b42edce..cf582b4835 100644 --- a/GVFS/GVFS.Common/Git/GitProcess.cs +++ b/GVFS/GVFS.Common/Git/GitProcess.cs @@ -323,14 +323,14 @@ public virtual bool TryGetCertificatePassword( errorData.Add("hasVisibleConsoleWindow", hasVisibleConsoleWindow); AddCredentialFillResult(errorData, gitCredentialOutput); TraceCredentialFillFailure(tracer, errorData, gitCredentialOutput.Errors); - errorMessage = 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 { @@ -397,10 +397,11 @@ 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; + errorMessage = GetCredentialFillFailureMessage(gitCredentialOutput); TraceCredentialFillFailure(tracer, errorData, gitCredentialOutput.Errors); } @@ -420,8 +421,7 @@ public virtual bool TryGetCredential( username = ParseValue(gitCredentialOutput.Output, "username=") ?? string.Empty; password = ParseValue(gitCredentialOutput.Output, "password="); - bool success = password != null; - + bool success = !string.IsNullOrEmpty(password); EventMetadata metadata = new EventMetadata(); metadata.Add("Success", success); if (!success) @@ -1180,12 +1180,22 @@ private static void AddCredentialFillResult(EventMetadata data, Result result) private static void TraceCredentialFillFailure(ITracer tracer, EventMetadata errorData, string errors) { - // Git can echo unparseable helper output, including secrets, to stderr. The telemetry event - // carries no stderr; the text stays in the local log only. 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); @@ -1269,6 +1279,12 @@ private static string GetCredentialOutputKeys(string credentialOutput, bool outp /// 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) { string value = null; diff --git a/GVFS/GVFS.UnitTests/Common/Git/GitProcessCredentialTests.cs b/GVFS/GVFS.UnitTests/Common/Git/GitProcessCredentialTests.cs index 627fa26045..64e30f3097 100644 --- a/GVFS/GVFS.UnitTests/Common/Git/GitProcessCredentialTests.cs +++ b/GVFS/GVFS.UnitTests/Common/Git/GitProcessCredentialTests.cs @@ -1,4 +1,4 @@ -using GVFS.Common.Git; +using GVFS.Common.Git; using GVFS.Common.Tracing; using GVFS.Tests.Should; using GVFS.UnitTests.Mock.Common; @@ -167,7 +167,7 @@ public void TryGetCredentialDescribesAFailedFillWithoutSendingStderrToTelemetry( gitProcess.TryGetCredential(tracer, "mock://repoUrl", out _, out _, out string errorMessage).ShouldBeFalse(); AssertStderrIsOnlyLocal(tracer, stderr); - errorMessage.ShouldEqual(stderr); + AssertErrorMessageHasNoStderr(errorMessage); } [TestCase] @@ -181,7 +181,47 @@ public void TryGetCertificatePasswordDescribesAFailedFillWithoutSendingStderrToT AssertStderrIsOnlyLocal(tracer, stderr); GetTelemetryWarning(tracer)["CertificatePath"].ShouldEqual("mock://certificate"); - errorMessage.ShouldEqual(stderr); + 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")] @@ -228,6 +268,14 @@ private static MockGitProcess CreateGitProcess( 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); diff --git a/GVFS/GVFS.UnitTests/Git/GitAuthenticationTests.cs b/GVFS/GVFS.UnitTests/Git/GitAuthenticationTests.cs index c107c33a48..6ebc4cb9de 100644 --- a/GVFS/GVFS.UnitTests/Git/GitAuthenticationTests.cs +++ b/GVFS/GVFS.UnitTests/Git/GitAuthenticationTests.cs @@ -91,7 +91,7 @@ public void CredentialWithoutUsernameIsAcceptedAndApprovedWithEmptyUsername() dut.TryInitializeAndRequireAuth(tracer, out _); dut.TryGetCredentials(tracer, out string authString, out string error).ShouldEqual(true, "Failed to get credential: " + error); - Encoding.ASCII.GetString(Convert.FromBase64String(authString)).ShouldEqual(":token"); + Encoding.UTF8.GetString(Convert.FromBase64String(authString)).ShouldEqual(":token"); dut.ApproveCredentials(tracer, authString); @@ -121,6 +121,47 @@ public void CredentialWithColonInPasswordCanBeApproved() 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() { From df237df566d0293ffccf98126d7c5678885c47b2 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Thu, 15 Oct 2026 10:16:18 -0700 Subject: [PATCH 21/35] Correct the git-dir comment and restore an EOF line ending Git resolves files in the per-worktree git directory by default and redirects only an explicit shared set through commondir. The GitProcess comment listed a few per-worktree files and described everything else as shared. That is wrong: MERGE_HEAD, CHERRY_PICK_HEAD, BISECT_START, FETCH_HEAD, rebase-merge/ and refs/rewritten are also per-worktree. The two earlier commit messages carry the same wording. Document that a GitProcess built from an Enlistment uses WorkingDirectoryBackingRoot, the root that Enlistment uses to derive DotGitRoot. Document that subclasses set DotGitRoot during construction, since it is a non-virtual property with a protected setter and cannot be overridden. Restore the final CRLF line ending of WorktreeEnlistmentTests.cs, which an earlier edit changed to LF. Assisted-by: Claude Opus 4.5 Signed-off-by: Tyrie Vella --- GVFS/GVFS.Common/Enlistment.cs | 4 ++-- GVFS/GVFS.Common/Git/GitProcess.cs | 12 ++++++++---- .../GVFS.UnitTests/Common/WorktreeEnlistmentTests.cs | 2 +- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/GVFS/GVFS.Common/Enlistment.cs b/GVFS/GVFS.Common/Enlistment.cs index 7bc826b1f9..1faf2f59d7 100644 --- a/GVFS/GVFS.Common/Enlistment.cs +++ b/GVFS/GVFS.Common/Enlistment.cs @@ -65,8 +65,8 @@ protected Enlistment( /// /// 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 override it with - /// the shared (common) git directory. + /// 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; } diff --git a/GVFS/GVFS.Common/Git/GitProcess.cs b/GVFS/GVFS.Common/Git/GitProcess.cs index bc7e224436..ab9e44067c 100644 --- a/GVFS/GVFS.Common/Git/GitProcess.cs +++ b/GVFS/GVFS.Common/Git/GitProcess.cs @@ -103,11 +103,15 @@ public GitProcess(string gitBinPath, string workingDirectoryRoot) { // 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 the - // per-worktree files (HEAD, index, ORIG_HEAD, logs/HEAD, refs/bisect, refs/worktree) - // there, and everything else (config, objects, packed-refs, refs/heads) through - // commondir in the shared git directory. + // 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); } } diff --git a/GVFS/GVFS.UnitTests/Common/WorktreeEnlistmentTests.cs b/GVFS/GVFS.UnitTests/Common/WorktreeEnlistmentTests.cs index 9ad8ab241a..b1a3460caf 100644 --- a/GVFS/GVFS.UnitTests/Common/WorktreeEnlistmentTests.cs +++ b/GVFS/GVFS.UnitTests/Common/WorktreeEnlistmentTests.cs @@ -177,4 +177,4 @@ public void RepoUrlIsReadFromSharedConfig() enlistment.RepoUrl.ShouldEqual("https://mock/repo"); } } -} +} From 73d64a5d0e071d2c02d331d0ef401f936718a57d Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Thu, 15 Oct 2026 13:48:11 -0700 Subject: [PATCH 22/35] fix: address worktree index review feedback Block full dehydrate when worktree registration reads fail. List the registered paths and explain stale-registration cleanup. Preserve per-file backup errors and trace the resolved index destination. Add a real-worktree dehydrate test and unit coverage for guard failures, trace metadata, and sub-second backup paths. Assisted-by: GPT-6.1 Sol Signed-off-by: Tyrie Vella --- GVFS/GVFS.Common/GVFSEnlistment.Shared.cs | 58 ++++++ GVFS/GVFS.Common/Git/GitIndexGenerator.cs | 7 +- .../EnlistmentPerFixture/WorktreeTests.cs | 57 +++++ .../CommandLine/GitIndexPathCallerTests.cs | 195 +++++++++++++++++- GVFS/GVFS/CommandLine/DehydrateVerb.cs | 89 ++++---- 5 files changed, 353 insertions(+), 53 deletions(-) diff --git a/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs b/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs index 26e2de306f..d8035a300c 100644 --- a/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs +++ b/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs @@ -212,6 +212,64 @@ public static string[] GetKnownWorktreePaths(string gitDir) return paths.ToArray(); } + public static bool TryGetKnownWorktreePaths(string gitDir, out string[] paths, out string error) + { + ArgumentNullException.ThrowIfNull(gitDir); + paths = null; + error = 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 = File.ReadAllText(pathToRead).Trim().Replace('/', Path.DirectorySeparatorChar); + if (!Path.IsPathFullyQualified(gitdirContent) || + !string.Equals(Path.GetFileName(gitdirContent), GVFSConstants.DotGit.Root, StringComparison.Ordinal)) + { + error = $"Invalid worktree registration at '{pathToRead}': expected an absolute path to a .git file."; + return false; + } + + worktreePaths.Add(Path.GetDirectoryName(Path.GetFullPath(gitdirContent))); + } + + 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}"; + return false; + } + } + public class WorktreeInfo { public const string EnlistmentRootFileName = "gvfs-enlistment-root"; diff --git a/GVFS/GVFS.Common/Git/GitIndexGenerator.cs b/GVFS/GVFS.Common/Git/GitIndexGenerator.cs index 07b441716b..5c27e32619 100644 --- a/GVFS/GVFS.Common/Git/GitIndexGenerator.cs +++ b/GVFS/GVFS.Common/Git/GitIndexGenerator.cs @@ -84,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(); diff --git a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs index 7de8bb680a..54b1006cb2 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,62 @@ public void WorktreeUsesPerWorktreePlaceholderDatabase() } } + [TestCase] + public void WorktreeDehydratePreservesPrimaryIndex() + { + 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); + + GVFSEnlistment.WorktreeInfo worktreeInfo = GVFSEnlistment.TryGetWorktreeInfo(worktreePath); + worktreeInfo.ShouldNotBeNull(); + string worktreeIndex = Path.Combine(worktreeInfo.WorktreeGitDir, "index"); + string primaryIndex = Path.Combine(this.Enlistment.RepoBackingRoot, ".git", "index"); + 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."); + + ProcessResult dehydrate = ProcessHelper.Run( + GVFSTestConfig.PathToGVFS, + $"dehydrate \"{worktreePath}\" --confirm --folders GVFS {TestConstants.InternalUseOnlyFlag} " + + GVFSHelpers.GetInternalParameter(serviceName: this.Enlistment.ServiceName), + workingDirectory: this.Enlistment.EnlistmentRoot, + 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); + } + } + } + private void InitWorktreeArrays(int count, out string[] paths, out string[] branches) { paths = new string[count]; diff --git a/GVFS/GVFS.UnitTests/CommandLine/GitIndexPathCallerTests.cs b/GVFS/GVFS.UnitTests/CommandLine/GitIndexPathCallerTests.cs index 065754fe71..1731405175 100644 --- a/GVFS/GVFS.UnitTests/CommandLine/GitIndexPathCallerTests.cs +++ b/GVFS/GVFS.UnitTests/CommandLine/GitIndexPathCallerTests.cs @@ -1,12 +1,14 @@ 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 { @@ -184,6 +186,73 @@ public void FullDehydrateRejectsPrimaryWithLinkedWorktree() () => 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"); + } + + [TestCase(null)] + [TestCase("")] + [TestCase("not-an-absolute-path")] + [TestCase(@"C:\worktrees\bad\HEAD")] + public void FullDehydrateRejectsMissingOrMalformedRegistration(string registration) + { + GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree: false); + string worktreeGitDir = Path.Combine(enlistment.DotGitRoot, "worktrees", "bad"); + Directory.CreateDirectory(worktreeGitDir); + if (registration != null) + { + 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")); + 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); + } + } + + [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] @@ -200,6 +269,52 @@ public void FullDehydrateAllowsPrimaryWithoutLinkedWorktrees() () => 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 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)] [TestCase(true)] public void BackupRootIsUniqueAndWorktreeLocal(bool isWorktree) @@ -220,6 +335,7 @@ public void BackupRootIsUniqueAndWorktreeLocal(bool isWorktree) expectedParent, expectedSubfolder, timestamp.ToString("yyyyMMdd_HHmmss_fffffff"))); + DehydrateVerb.GetBackupRoot(enlistment, timestamp.AddTicks(1)).ShouldNotEqual(backupRoot); } [TestCase] @@ -261,9 +377,62 @@ public void MissingLockDirectoryReturnsBackupError() errorMessage.ShouldNotBeNull(); } - private GVFSEnlistment CreateEnlistment(bool isWorktree) + [TestCase(false)] + [TestCase(true)] + public void LockBackupReturnsPreciseFileError(bool move) { - string gitBinPath = Path.Combine(this.testRoot, "git.exe"); + 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 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) + { + 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); @@ -271,7 +440,7 @@ private GVFSEnlistment CreateEnlistment(bool isWorktree) if (!isWorktree) { - return new GVFSEnlistment(primaryRoot, "https://mock/repo", gitBinPath, authentication: null); + return new GVFSEnlistment(primaryRoot, "mock://repo", gitBinPath, authentication: null); } string worktreePath = Path.Combine(this.testRoot, "linked-worktree"); @@ -293,7 +462,25 @@ private GVFSEnlistment CreateEnlistment(bool isWorktree) gitBinPath, authentication: null, worktreeInfo, - repoUrl: "https://mock/repo"); + 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/CommandLine/DehydrateVerb.cs b/GVFS/GVFS/CommandLine/DehydrateVerb.cs index 7adf9bd19b..9cd8b7e8b3 100644 --- a/GVFS/GVFS/CommandLine/DehydrateVerb.cs +++ b/GVFS/GVFS/CommandLine/DehydrateVerb.cs @@ -631,7 +631,7 @@ 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; } @@ -711,7 +711,7 @@ 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; } @@ -775,11 +775,21 @@ internal void ValidateFullDehydrate(ITracer tracer, GVFSEnlistment enlistment) this.ReportErrorAndExit(tracer, "Dehydrate --full is not supported for git worktrees."); } - if (GVFSEnlistment.GetKnownWorktreePaths(enlistment.DotGitRoot).Length > 0) + if (!GVFSEnlistment.TryGetKnownWorktreePaths(enlistment.DotGitRoot, out string[] worktreePaths, out string error)) { this.ReportErrorAndExit( tracer, - "Dehydrate --full is not supported while linked worktrees exist. Remove all linked worktrees first."); + "Cannot verify linked worktree registrations. Full dehydrate is blocked. " + error); + } + + 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 + + "Use 'git worktree remove ' to remove a worktree. " + + "If its directory no longer exists, use 'git worktree prune' to remove stale registrations."); } } @@ -809,70 +819,52 @@ internal bool TryBackupGitLocks( out string errorMessage) { string gitIndexDirectory = Path.GetDirectoryName(enlistment.GitIndexPath); - bool backupSucceeded = false; - if (!this.TryIO( - tracer, - () => - { - backupSucceeded = move - ? this.TryBackupFilesInFolder(tracer, gitIndexDirectory, backupGit, searchPattern: "*.lock") - : this.TryCopyFilesInFolder(tracer, gitIndexDirectory, backupGit, searchPattern: "*.lock"); - }, - "Backup git lock files", - out errorMessage)) - { - return false; - } + return move + ? this.TryBackupFilesInFolder(tracer, gitIndexDirectory, backupGit, "*.lock", out errorMessage) + : this.TryCopyFilesInFolder(tracer, gitIndexDirectory, backupGit, "*.lock", out errorMessage); + } - if (!backupSucceeded) - { - errorMessage = "Failed to backup .git lock files."; - } + 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); + } - return backupSucceeded; + 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 TryBackupFilesInFolder(ITracer tracer, string folderPath, string backupPath, string searchPattern, params string[] filenamesToSkip) + private bool TryTransferFilesInFolder(ITracer tracer, string folderPath, string backupPath, string searchPattern, bool move, out string errorMessage, params string[] filenamesToSkip) { - string errorMessage; - foreach (string file in Directory.GetFiles(folderPath, searchPattern)) + string[] files = null; + if (!this.TryIO(tracer, () => files = Directory.GetFiles(folderPath, searchPattern), $"Enumerate files in {folderPath}", out errorMessage)) { - string fileName = Path.GetFileName(file); - if (!filenamesToSkip.Any(x => x.Equals(fileName, GVFSPlatform.Instance.Constants.PathComparison))) - { - if (!this.TryIO( - tracer, - () => File.Move(file, Path.Combine(backupPath, fileName)), - $"Backing up {Path.GetFileName(file)}", - out errorMessage)) - { - return false; - } - } + errorMessage = $"Failed to enumerate '{folderPath}': {errorMessage}"; + return false; } - return true; - } - - private bool TryCopyFilesInFolder(ITracer tracer, string folderPath, string backupPath, string searchPattern, params string[] filenamesToSkip) - { - string errorMessage; - foreach (string file in Directory.GetFiles(folderPath, searchPattern)) + 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, Path.Combine(backupPath, fileName)), - $"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; } @@ -980,7 +972,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"); } From 2c1c6300be5c3f2cd1998dffa899e878170bdc6f Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Thu, 15 Oct 2026 14:26:35 -0700 Subject: [PATCH 23/35] fix: handle worktree path and backup failure boundaries Keep backups outside worktrees when paths include a trailing separator. Resolve relative worktree registrations and retain full exceptions in diagnostic logs. Report both index backup paths and return a failed dehydrate exit code after backup errors. Add CLI tests for full-dehydrate guards, backup failures, and trailing separators. Assisted-by: GPT-6.1 Sol Signed-off-by: Tyrie Vella --- GVFS/GVFS.Common/GVFSEnlistment.Shared.cs | 14 +- .../EnlistmentPerFixture/WorktreeTests.cs | 125 ++++++++++++++++-- .../CommandLine/GitIndexPathCallerTests.cs | 89 +++++++++++-- GVFS/GVFS/CommandLine/DehydrateVerb.cs | 45 +++++-- 4 files changed, 238 insertions(+), 35 deletions(-) diff --git a/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs b/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs index d8035a300c..b895b452d9 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,11 +213,15 @@ public static string[] GetKnownWorktreePaths(string gitDir) return paths.ToArray(); } - public static bool TryGetKnownWorktreePaths(string gitDir, out string[] paths, out string error) + /// + /// Reads every registration and fails if any registration cannot be read or parsed. + /// + 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 { @@ -250,14 +255,14 @@ public static bool TryGetKnownWorktreePaths(string gitDir, out string[] paths, o { pathToRead = Path.Combine(entry, "gitdir"); string gitdirContent = File.ReadAllText(pathToRead).Trim().Replace('/', Path.DirectorySeparatorChar); - if (!Path.IsPathFullyQualified(gitdirContent) || + if (string.IsNullOrEmpty(gitdirContent) || !string.Equals(Path.GetFileName(gitdirContent), GVFSConstants.DotGit.Root, StringComparison.Ordinal)) { - error = $"Invalid worktree registration at '{pathToRead}': expected an absolute path to a .git file."; + error = $"Invalid worktree registration at '{pathToRead}': expected a path to a .git file."; return false; } - worktreePaths.Add(Path.GetDirectoryName(Path.GetFullPath(gitdirContent))); + worktreePaths.Add(Path.GetDirectoryName(Path.GetFullPath(gitdirContent, entry))); } paths = worktreePaths.ToArray(); @@ -266,6 +271,7 @@ public static bool TryGetKnownWorktreePaths(string gitDir, out string[] paths, o 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; } } diff --git a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs index 54b1006cb2..cdc26ea0a8 100644 --- a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs +++ b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs @@ -364,8 +364,9 @@ public void WorktreeUsesPerWorktreePlaceholderDatabase() } } - [TestCase] - public void WorktreeDehydratePreservesPrimaryIndex() + [TestCase(false)] + [TestCase(true)] + public void WorktreeDehydratePreservesPrimaryIndex(bool trailingSeparator) { string suffix = Guid.NewGuid().ToString("N").Substring(0, 8); string worktreeName = $"index-dehydrate-{suffix}"; @@ -385,20 +386,27 @@ public void WorktreeDehydratePreservesPrimaryIndex() GitHelpers.InvokeGitAgainstGVFSRepo(worktreePath, "add worktree-index-only.txt").ExitCode.ShouldEqual(0); GitHelpers.InvokeGitAgainstGVFSRepo(worktreePath, "commit -m \"Add worktree-only index entry\"").ExitCode.ShouldEqual(0); - GVFSEnlistment.WorktreeInfo worktreeInfo = GVFSEnlistment.TryGetWorktreeInfo(worktreePath); - worktreeInfo.ShouldNotBeNull(); - string worktreeIndex = Path.Combine(worktreeInfo.WorktreeGitDir, "index"); - string primaryIndex = Path.Combine(this.Enlistment.RepoBackingRoot, ".git", "index"); + 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."); - ProcessResult dehydrate = ProcessHelper.Run( - GVFSTestConfig.PathToGVFS, - $"dehydrate \"{worktreePath}\" --confirm --folders GVFS {TestConstants.InternalUseOnlyFlag} " + - GVFSHelpers.GetInternalParameter(serviceName: this.Enlistment.ServiceName), - workingDirectory: this.Enlistment.EnlistmentRoot, - timeoutMs: 120000); + 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."); @@ -420,6 +428,99 @@ public void WorktreeDehydratePreservesPrimaryIndex() } } + [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] + 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.UnitTests/CommandLine/GitIndexPathCallerTests.cs b/GVFS/GVFS.UnitTests/CommandLine/GitIndexPathCallerTests.cs index 1731405175..1fe58b5fa4 100644 --- a/GVFS/GVFS.UnitTests/CommandLine/GitIndexPathCallerTests.cs +++ b/GVFS/GVFS.UnitTests/CommandLine/GitIndexPathCallerTests.cs @@ -43,6 +43,11 @@ public void TearDown() 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"); @@ -189,6 +194,30 @@ public void FullDehydrateRejectsPrimaryWithLinkedWorktree() 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"); + } + + [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(null)] @@ -213,6 +242,8 @@ public void FullDehydrateRejectsMissingOrMalformedRegistration(string registrati () => 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(); } @@ -237,6 +268,13 @@ public void FullDehydrateRejectsUnreadableRegistration() output.ToString().ShouldContain("Full dehydrate is blocked."); output.ToString().ShouldContain(gitdirFile); tracer.RelatedErrorEvents.Count.ShouldEqual(1); + 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(); } } @@ -315,17 +353,19 @@ public void FullDehydrateRejectsGitDirectoryReplacedByFile() } } - [TestCase(false)] - [TestCase(true)] - public void BackupRootIsUniqueAndWorktreeLocal(bool isWorktree) + [TestCase(false, false)] + [TestCase(false, true)] + [TestCase(true, false)] + [TestCase(true, true)] + public void BackupRootIsUniqueAndWorktreeLocal(bool isWorktree, bool trailingSeparator) { - GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree); + 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 - ? Path.GetDirectoryName(enlistment.WorkingDirectoryBackingRoot) + ? this.testRoot : enlistment.PrimaryEnlistmentRoot; string expectedSubfolder = isWorktree ? Path.Combine("dehydrate_backup", enlistment.Worktree.Name) @@ -334,7 +374,7 @@ public void BackupRootIsUniqueAndWorktreeLocal(bool isWorktree) Path.Combine( expectedParent, expectedSubfolder, - timestamp.ToString("yyyyMMdd_HHmmss_fffffff"))); + "20260923_130000_1230000")); DehydrateVerb.GetBackupRoot(enlistment, timestamp.AddTicks(1)).ShouldNotEqual(backupRoot); } @@ -354,7 +394,8 @@ public void MissingIndexReturnsBackupError() out string errorMessage); result.ShouldBeFalse(); - errorMessage.ShouldNotBeNull(); + errorMessage.ShouldContain(enlistment.GitIndexPath); + errorMessage.ShouldContain(Path.Combine(backupGit, "index")); } [TestCase] @@ -406,6 +447,33 @@ public void LockBackupReturnsPreciseFileError(bool move) } } + [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) @@ -430,7 +498,7 @@ public void IndexGenerationTracesResolvedDestination(bool isWorktree) } } - private GVFSEnlistment CreateEnlistment(bool isWorktree, string gitBinPath = null) + 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"); @@ -444,6 +512,11 @@ private GVFSEnlistment CreateEnlistment(bool isWorktree, string gitBinPath = nul } 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); diff --git a/GVFS/GVFS/CommandLine/DehydrateVerb.cs b/GVFS/GVFS/CommandLine/DehydrateVerb.cs index 9cd8b7e8b3..e826211e92 100644 --- a/GVFS/GVFS/CommandLine/DehydrateVerb.cs +++ b/GVFS/GVFS/CommandLine/DehydrateVerb.cs @@ -283,7 +283,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(); @@ -485,7 +485,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"); } } @@ -760,7 +760,13 @@ internal bool TryBackupGitIndex( ? () => File.Move(enlistment.GitIndexPath, backupIndexPath) : () => File.Copy(enlistment.GitIndexPath, backupIndexPath); - return this.TryIO(tracer, backupIndex, "Backup the git index", out errorMessage); + if (!this.TryIO(tracer, backupIndex, $"Back up {enlistment.GitIndexPath} to {backupIndexPath}", out errorMessage)) + { + errorMessage = $"Failed to back up '{enlistment.GitIndexPath}' to '{backupIndexPath}': {errorMessage}"; + return false; + } + + return true; } internal void ValidateFullDehydrate(ITracer tracer, GVFSEnlistment enlistment) @@ -772,14 +778,33 @@ internal void ValidateFullDehydrate(ITracer tracer, GVFSEnlistment enlistment) if (enlistment.IsWorktree) { - this.ReportErrorAndExit(tracer, "Dehydrate --full is not supported for git worktrees."); + this.ReportErrorAndExit( + tracer, + "Dehydrate --full is not supported for git worktrees. " + + "Use 'gvfs dehydrate --confirm --folders ' for folder dehydrate."); } - if (!GVFSEnlistment.TryGetKnownWorktreePaths(enlistment.DotGitRoot, out string[] worktreePaths, out string error)) + 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."; + if (!GVFSEnlistment.TryGetKnownWorktreePaths(enlistment.DotGitRoot, out string[] worktreePaths, out string error, out Exception exception)) { + EventMetadata metadata = new EventMetadata + { + { "Error", error }, + { "DotGitRoot", enlistment.DotGitRoot }, + }; + if (exception != null) + { + metadata.Add("Exception", exception.ToString()); + } + + tracer.RelatedError(metadata, "Cannot verify linked worktree registrations."); this.ReportErrorAndExit( - tracer, - "Cannot verify linked worktree registrations. Full dehydrate is blocked. " + error); + "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); } if (worktreePaths.Length > 0) @@ -787,9 +812,7 @@ internal void ValidateFullDehydrate(ITracer tracer, GVFSEnlistment enlistment) this.ReportErrorAndExit( tracer, "Dehydrate --full is not supported while linked worktrees exist." + Environment.NewLine + - string.Join(Environment.NewLine, worktreePaths) + Environment.NewLine + - "Use 'git worktree remove ' to remove a worktree. " + - "If its directory no longer exists, use 'git worktree prune' to remove stale registrations."); + string.Join(Environment.NewLine, worktreePaths) + Environment.NewLine + cleanupAdvice); } } @@ -799,7 +822,7 @@ internal static string GetBackupRoot(GVFSEnlistment enlistment, DateTime timesta string backupSubfolder = string.Empty; if (enlistment.IsWorktree) { - backupParent = Path.GetDirectoryName(enlistment.WorkingDirectoryBackingRoot); + backupParent = Path.GetDirectoryName(Path.TrimEndingDirectorySeparator(enlistment.WorkingDirectoryBackingRoot)); backupSubfolder = enlistment.Worktree.Name; } From 439c98adc170562d51237f73ff4be4a9aa85445f Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Thu, 15 Oct 2026 14:40:56 -0700 Subject: [PATCH 24/35] Address disk layout follow-up review Surface disk layout upgrade error details on non-missing-layout failures instead of discarding them. Extend the functional tests for missing disk layout metadata to assert that the mount output includes the gvfs log pointer. Assisted-by: GitHub Copilot Signed-off-by: Tyrie Vella --- .../Tests/EnlistmentPerFixture/MountTests.cs | 14 +++++++++++--- GVFS/GVFS/CommandLine/MountVerb.cs | 8 +++++++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/MountTests.cs b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/MountTests.cs index b47a00b774..259a4dc98b 100644 --- a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/MountTests.cs +++ b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/MountTests.cs @@ -21,6 +21,7 @@ public class MountTests : TestsWithEnlistmentPerFixture // 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; @@ -119,7 +120,8 @@ public void MountFailsWithUniqueExitCodeWhenDiskLayoutVersionIsMissing() this.MountShouldFail( GVFSMissingDiskLayoutVersion, - MissingDiskLayoutVersionMessage); + MissingDiskLayoutVersionMessage, + additionalExpectedErrorMessage: GVFSLogMessage); } finally { @@ -158,7 +160,8 @@ public void MountFailsWhenNoOnDiskVersion() this.MountShouldFail( GVFSMissingDiskLayoutVersion, - MissingDiskLayoutVersionMessage); + MissingDiskLayoutVersionMessage, + additionalExpectedErrorMessage: GVFSLogMessage); } finally { @@ -391,7 +394,7 @@ public void ProjFS_CMDHangNoneActiveInstance() this.Enlistment.MountGVFS(); } - private void MountShouldFail(int expectedExitCode, string expectedErrorMessage, string mountWorkingDirectory = null) + private void MountShouldFail(int expectedExitCode, string expectedErrorMessage, string mountWorkingDirectory = null, string additionalExpectedErrorMessage = null) { string enlistmentRoot = this.Enlistment.EnlistmentRoot; @@ -411,6 +414,11 @@ private void MountShouldFail(int expectedExitCode, string expectedErrorMessage, { string combinedOutput = result.Output + "\n" + result.Errors; combinedOutput.ShouldContain(expectedErrorMessage); + + if (additionalExpectedErrorMessage != null) + { + combinedOutput.ShouldContain(additionalExpectedErrorMessage); + } } } diff --git a/GVFS/GVFS/CommandLine/MountVerb.cs b/GVFS/GVFS/CommandLine/MountVerb.cs index 700cb85289..2c4ab26cbb 100644 --- a/GVFS/GVFS/CommandLine/MountVerb.cs +++ b/GVFS/GVFS/CommandLine/MountVerb.cs @@ -140,7 +140,13 @@ protected override void PreCreateEnlistment() } else { - this.ReportErrorAndExit("Failed to upgrade repo disk layout. " + ConsoleHelper.GetGVFSLogMessage(enlistmentRoot)); + string upgradeErrorMessage = "Failed to upgrade repo disk layout."; + if (!string.IsNullOrWhiteSpace(diskLayoutError)) + { + upgradeErrorMessage += " " + diskLayoutError; + } + + this.ReportErrorAndExit(upgradeErrorMessage + " " + ConsoleHelper.GetGVFSLogMessage(enlistmentRoot)); } } From bc2b8158d2e0d3fe6cc3fa104a5fb9e0e6272733 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Thu, 15 Oct 2026 15:04:21 -0700 Subject: [PATCH 25/35] Ignore worktree metadata directories without gitdir files Match Git by ignoring directories that contain no worktree registration. Continue to block full dehydrate when a present gitdir file is unreadable or malformed. Cover empty metadata entries alone and alongside a valid worktree registration. Assisted-by: GPT-6.1 Sol Signed-off-by: Tyrie Vella --- GVFS/GVFS.Common/GVFSEnlistment.Shared.cs | 13 +++++++++-- .../CommandLine/GitIndexPathCallerTests.cs | 23 ++++++++++++++----- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs b/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs index b895b452d9..8e0dd0c179 100644 --- a/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs +++ b/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs @@ -214,7 +214,7 @@ public static string[] GetKnownWorktreePaths(string gitDir) } /// - /// Reads every registration and fails if any registration cannot be read or parsed. + /// Reads registrations with a gitdir file and fails if a present file cannot be read or parsed. /// public static bool TryGetKnownWorktreePaths(string gitDir, out string[] paths, out string error, out Exception exception) { @@ -254,7 +254,16 @@ public static bool TryGetKnownWorktreePaths(string gitDir, out string[] paths, o foreach (string entry in Directory.GetDirectories(worktreesDir)) { pathToRead = Path.Combine(entry, "gitdir"); - string gitdirContent = File.ReadAllText(pathToRead).Trim().Replace('/', Path.DirectorySeparatorChar); + string gitdirContent; + try + { + gitdirContent = File.ReadAllText(pathToRead).Trim().Replace('/', Path.DirectorySeparatorChar); + } + catch (FileNotFoundException) + { + continue; + } + if (string.IsNullOrEmpty(gitdirContent) || !string.Equals(Path.GetFileName(gitdirContent), GVFSConstants.DotGit.Root, StringComparison.Ordinal)) { diff --git a/GVFS/GVFS.UnitTests/CommandLine/GitIndexPathCallerTests.cs b/GVFS/GVFS.UnitTests/CommandLine/GitIndexPathCallerTests.cs index 1fe58b5fa4..b91b9270b1 100644 --- a/GVFS/GVFS.UnitTests/CommandLine/GitIndexPathCallerTests.cs +++ b/GVFS/GVFS.UnitTests/CommandLine/GitIndexPathCallerTests.cs @@ -179,6 +179,7 @@ public void FullDehydrateRejectsPrimaryWithLinkedWorktree() 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 @@ -196,6 +197,7 @@ public void FullDehydrateRejectsPrimaryWithLinkedWorktree() 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] @@ -220,19 +222,15 @@ public void FullDehydrateResolvesRelativeRegistration() } } - [TestCase(null)] [TestCase("")] [TestCase("not-an-absolute-path")] [TestCase(@"C:\worktrees\bad\HEAD")] - public void FullDehydrateRejectsMissingOrMalformedRegistration(string registration) + public void FullDehydrateRejectsMalformedRegistration(string registration) { GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree: false); string worktreeGitDir = Path.Combine(enlistment.DotGitRoot, "worktrees", "bad"); Directory.CreateDirectory(worktreeGitDir); - if (registration != null) - { - File.WriteAllText(Path.Combine(worktreeGitDir, "gitdir"), registration); - } + File.WriteAllText(Path.Combine(worktreeGitDir, "gitdir"), registration); using (StringWriter output = new StringWriter()) using (MockTracer tracer = new MockTracer()) @@ -320,6 +318,19 @@ public void FullDehydrateAllowsEmptyRegistrationsDirectory() } } + [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 FullDehydrateRejectsMissingGitDirectory() { From bdd7c31f37460a13b72682ba9e5695f30248e499 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Thu, 15 Oct 2026 15:30:55 -0700 Subject: [PATCH 26/35] Match Git when worktree registration entries are omitted Skip missing, zero-byte, and unreadable gitdir files. Record skipped read failures in diagnostic logs instead of blocking full dehydrate on entries Git does not list. Keep nonempty malformed registrations and enumeration errors blocking. Test whitespace-only content and mixed readable and unreadable registrations. Assisted-by: GPT-6.1 Sol Signed-off-by: Tyrie Vella --- GVFS/GVFS.Common/GVFSEnlistment.Shared.cs | 29 ++++++++- .../CommandLine/GitIndexPathCallerTests.cs | 63 ++++++++++++++++--- GVFS/GVFS/CommandLine/DehydrateVerb.cs | 15 ++++- 3 files changed, 93 insertions(+), 14 deletions(-) diff --git a/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs b/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs index 8e0dd0c179..f4510303bc 100644 --- a/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs +++ b/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs @@ -214,14 +214,21 @@ public static string[] GetKnownWorktreePaths(string gitDir) } /// - /// Reads registrations with a gitdir file and fails if a present file cannot be read or parsed. + /// Reads registrations, skipping missing, empty, or unreadable files as Git does. + /// Nonempty malformed files and directory enumeration failures are reported to the caller. /// - public static bool TryGetKnownWorktreePaths(string gitDir, out string[] paths, out string error, out Exception exception) + public static bool TryGetKnownWorktreePaths( + string gitDir, + out string[] paths, + out string error, + out Exception exception, + out List skippedRegistrationErrors) { ArgumentNullException.ThrowIfNull(gitDir); paths = null; error = null; exception = null; + skippedRegistrationErrors = new List(); string pathToRead = gitDir; try { @@ -257,12 +264,28 @@ public static bool TryGetKnownWorktreePaths(string gitDir, out string[] paths, o string gitdirContent; try { - gitdirContent = File.ReadAllText(pathToRead).Trim().Replace('/', Path.DirectorySeparatorChar); + using (FileStream stream = File.OpenRead(pathToRead)) + { + if (stream.Length == 0) + { + continue; + } + + using (StreamReader reader = new StreamReader(stream)) + { + gitdirContent = reader.ReadToEnd().Trim().Replace('/', Path.DirectorySeparatorChar); + } + } } catch (FileNotFoundException) { continue; } + catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) + { + skippedRegistrationErrors.Add($"Failed to read '{pathToRead}': {ex}"); + continue; + } if (string.IsNullOrEmpty(gitdirContent) || !string.Equals(Path.GetFileName(gitdirContent), GVFSConstants.DotGit.Root, StringComparison.Ordinal)) diff --git a/GVFS/GVFS.UnitTests/CommandLine/GitIndexPathCallerTests.cs b/GVFS/GVFS.UnitTests/CommandLine/GitIndexPathCallerTests.cs index b91b9270b1..721aeefd85 100644 --- a/GVFS/GVFS.UnitTests/CommandLine/GitIndexPathCallerTests.cs +++ b/GVFS/GVFS.UnitTests/CommandLine/GitIndexPathCallerTests.cs @@ -222,7 +222,9 @@ public void FullDehydrateResolvesRelativeRegistration() } } - [TestCase("")] + [TestCase("\n")] + [TestCase(" ")] + [TestCase(" \n")] [TestCase("not-an-absolute-path")] [TestCase(@"C:\worktrees\bad\HEAD")] public void FullDehydrateRejectsMalformedRegistration(string registration) @@ -248,7 +250,7 @@ public void FullDehydrateRejectsMalformedRegistration(string registration) } [TestCase] - public void FullDehydrateRejectsUnreadableRegistration() + public void FullDehydrateIgnoresUnreadableRegistration() { GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree: false); string worktreeGitDir = Path.Combine(enlistment.DotGitRoot, "worktrees", "locked"); @@ -261,15 +263,15 @@ public void FullDehydrateRejectsUnreadableRegistration() 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); - using (JsonDocument metadata = JsonDocument.Parse(tracer.RelatedErrorEvents[0])) + Assert.DoesNotThrow(() => verb.ValidateFullDehydrate(tracer, enlistment)); + output.ToString().Length.ShouldEqual(0); + tracer.RelatedErrorEvents.Count.ShouldEqual(0); + tracer.RelatedWarningEvents.Count.ShouldEqual(1); + using (JsonDocument metadata = JsonDocument.Parse(tracer.RelatedWarningEvents[0])) { - metadata.RootElement.GetProperty("Exception").GetString().ShouldContain("System.IO.IOException"); - metadata.RootElement.GetProperty("Exception").GetString().ShouldContain("TryGetKnownWorktreePaths"); + metadata.RootElement.GetProperty("Error").GetString().ShouldContain(gitdirFile); + metadata.RootElement.GetProperty("Error").GetString().ShouldContain("System.IO.IOException"); + metadata.RootElement.GetProperty("Error").GetString().ShouldContain("TryGetKnownWorktreePaths"); } output.ToString().Contains("System.IO.IOException").ShouldBeFalse(); @@ -331,6 +333,47 @@ public void FullDehydrateIgnoresDirectoryWithoutGitdir() } } + [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 FullDehydrateStillRejectsVisibleWorktreeWhenAnotherEntryIsUnreadable() + { + 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("not supported while linked worktrees exist."); + output.ToString().ShouldContain(worktreePath); + tracer.RelatedWarningEvents.Count.ShouldEqual(1); + tracer.RelatedErrorEvents.Count.ShouldEqual(1); + } + } + [TestCase] public void FullDehydrateRejectsMissingGitDirectory() { diff --git a/GVFS/GVFS/CommandLine/DehydrateVerb.cs b/GVFS/GVFS/CommandLine/DehydrateVerb.cs index e826211e92..2005c469c3 100644 --- a/GVFS/GVFS/CommandLine/DehydrateVerb.cs +++ b/GVFS/GVFS/CommandLine/DehydrateVerb.cs @@ -789,7 +789,20 @@ internal void ValidateFullDehydrate(ITracer tracer, GVFSEnlistment enlistment) "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."; - if (!GVFSEnlistment.TryGetKnownWorktreePaths(enlistment.DotGitRoot, out string[] worktreePaths, out string error, out Exception exception)) + bool readSucceeded = GVFSEnlistment.TryGetKnownWorktreePaths( + enlistment.DotGitRoot, + out string[] worktreePaths, + out string error, + out Exception exception, + out List skippedRegistrationErrors); + foreach (string skippedRegistrationError in skippedRegistrationErrors) + { + tracer.RelatedWarning( + new EventMetadata { { "Error", skippedRegistrationError } }, + "Skipped unreadable worktree registration file."); + } + + if (!readSucceeded) { EventMetadata metadata = new EventMetadata { From ed86feb9537f350143247ab99b6bb05a2fcc9944 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Tue, 20 Oct 2026 11:25:26 +0200 Subject: [PATCH 27/35] Update default Microsoft Git version to v2.56.0.vfs.0.2 --- .github/workflows/build.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index c0f4ec649b..1289c084b2 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.2' }} jobs: validate: From 64f6ad8297f4411c86266667bed7f820a565c9a9 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Mon, 19 Oct 2026 10:56:46 -0700 Subject: [PATCH 28/35] Avoid GVFS command locks for read-only Git operations Exempt worktree list without relaxing locks for other worktree subcommands. Also exempt audited history, ref-query, object-statistics, and verification operations. Default annotate follows the existing blame policy for working-tree reads. Neither command provides snapshot isolation. Share the hook policy with unit coverage through a partial source file. Add deterministic first-open coverage during a paused worktree-list command. Cover read-only commands under contention and preserve normal status waits. Keep test lock holders alive only for the synchronous test action. Require lock readiness before the action and cover retained dispatcher waits. Use producer-shaped pre-command and post-command metadata in the unit cases. Assisted-by: GPT-6.1 Sol Signed-off-by: Tyrie Vella --- .../Program.cs | 27 +++- .../GitReadAndGitLockTests.cs | 81 +++++++++- .../GitCommands/CreatePlaceholderTests.cs | 76 ++++++++++ GVFS/GVFS.FunctionalTests/Tools/GitHelpers.cs | 42 +++++- GVFS/GVFS.Hooks/Program.CommandLocking.cs | 119 +++++++++++++++ GVFS/GVFS.Hooks/Program.cs | 92 ------------ GVFS/GVFS.UnitTests/GVFS.UnitTests.csproj | 7 +- .../Hooks/CommandLockingTests.cs | 140 ++++++++++++++++++ 8 files changed, 483 insertions(+), 101 deletions(-) create mode 100644 GVFS/GVFS.Hooks/Program.CommandLocking.cs create mode 100644 GVFS/GVFS.UnitTests/Hooks/CommandLockingTests.cs 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/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/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.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/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)); + } + } + } +} From da31d9879cedacdfa562f9bf3a790d77dbdb6ca0 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Wed, 21 Oct 2026 16:13:35 -0700 Subject: [PATCH 29/35] Preserve hydration summary diagnostics across failures and cache fallback Record typed count availability, partial observations, validation failures, and phase outcomes. Correlate health commands with cached and fallback calculations through explicit payload identifiers and a compatible IPC extension. Keep count semantics and wait behavior unchanged. Add unit and functional tests for the diagnostic contract, serialization, and compatibility. Assisted-by: GPT-6.1 Sol Signed-off-by: Tyrie Vella --- GVFS/GVFS.Common/GitStatusCache.cs | 141 ++++++--- .../EnlistmentHydrationSummary.cs | 242 ++++++++-------- .../HealthCalculator/EnlistmentPathData.cs | 109 ++++++- .../HydrationSummaryDiagnostics.cs | 259 +++++++++++++++++ .../HydrationStatusNamedPipeMessages.cs | 86 +++++- .../GVFS.Common/NamedPipes/NamedPipeClient.cs | 12 +- .../Tracing/TelemetryDaemonEventListener.cs | 2 +- .../Tests/EnlistmentPerFixture/HealthTests.cs | 35 +++ .../Tools/GVFSFunctionalTestEnlistment.cs | 4 +- .../GVFS.FunctionalTests/Tools/GVFSProcess.cs | 7 +- GVFS/GVFS.Mount/InProcessMount.cs | 7 +- .../Common/EnlistmentHydrationSummaryTests.cs | 198 +++++++++++++ .../Common/GitStatusCacheTests.cs | 26 ++ .../Common/HydrationStatusDiagnosticsTests.cs | 163 +++++++++++ .../TelemetryDaemonEventListenerTests.cs | 53 ++++ .../FileSystemCallbacks.cs | 4 +- GVFS/GVFS/CommandLine/HealthVerb.cs | 273 ++++++++++++++---- docs/hydration-status-diagnostics.md | 93 ++++++ docs/troubleshooting.md | 5 + 19 files changed, 1487 insertions(+), 232 deletions(-) create mode 100644 GVFS/GVFS.Common/HealthCalculator/HydrationSummaryDiagnostics.cs create mode 100644 GVFS/GVFS.UnitTests/Common/HydrationStatusDiagnosticsTests.cs create mode 100644 docs/hydration-status-diagnostics.md diff --git a/GVFS/GVFS.Common/GitStatusCache.cs b/GVFS/GVFS.Common/GitStatusCache.cs index 870dce1c7c..d28544df4d 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.CacheState"] = this.hydrationCache.State; + wroteTelemetry = true; + } if (statusStatistics.BackgroundStatusScanCount > 0) { @@ -413,44 +454,57 @@ private void RebuildStatusCacheIfNeeded(bool ignoreBackoff) } } - private void UpdateHydrationSummary() + internal void UpdateHydrationSummary() { - if (this.projectedFolderCountProvider == null) - { - return; - } - - bool enabled = TEST_EnableHydrationSummaryOverride - ?? this.context.Repository.LibGit2RepoInvoker.GetConfigBoolOrDefault(GVFSConstants.GitConfig.ShowHydrationStatus, GVFSConstants.GitConfig.ShowHydrationStatusDefault); - if (!enabled) - { - return; - } - HydrationStatusCircuitBreaker circuitBreaker = new HydrationStatusCircuitBreaker( this.context.Enlistment.DotGVFSRoot, this.context.Tracer); - - if (circuitBreaker.IsDisabled()) - { - return; - } - try { + 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) + { + this.RecordHydrationSkip(NamedPipeMessages.HydrationStatus.CacheState.FeatureDisabled); + return; + } + + if (circuitBreaker.IsDisabled()) + { + this.RecordHydrationSkip(NamedPipeMessages.HydrationStatus.CacheState.CircuitBreakerDisabled); + return; + } + /* While not strictly part of git status, enlistment hydration summary is used * in "git status" pre-command hook, and can take several seconds to compute on very large repos. * 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); + EnlistmentHydrationSummary.CreateSummary(this.context.Enlistment, this.context.FileSystem, this.context.Tracer, this.projectedFolderCountProvider, this.shutdownTokenSource.Token, 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 +518,57 @@ 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); } } + 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..bfacee4885 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,172 @@ 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 = 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) when (cancellationToken.IsCancellationRequested) { - 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.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..a87d571413 100644 --- a/GVFS/GVFS.Common/HealthCalculator/EnlistmentPathData.cs +++ b/GVFS/GVFS.Common/HealthCalculator/EnlistmentPathData.cs @@ -49,7 +49,7 @@ 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(); @@ -61,7 +61,32 @@ public void LoadPlaceholdersFromDatabase(GVFSEnlistment enlistment) } this.PlaceholderFilePaths.AddRange(filePlaceholders.Select(placeholderData => placeholderData.Path)); - this.PlaceholderFolderPaths.AddRange(folderPlaceholders.Select(placeholderData => placeholderData.Path)); + if (diagnostics != null) + { + diagnostics.PartialFolderCount = 0; + diagnostics.ExpandedFolderCount = 0; + diagnostics.PossibleTombstoneFolderCount = 0; + } + + 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 +120,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; } @@ -109,16 +139,30 @@ public void LoadModifiedPaths(GVFSEnlistment enlistment, ITracer tracer) using (FileStream file = File.Open(filePath, FileMode.OpenOrCreate, FileAccess.Read, FileShare.Read)) using (StreamReader reader = new StreamReader(file)) { - AddModifiedPaths(ReadModifiedPathDatabaseLines(reader)); + AddModifiedPaths(ReadModifiedPathDatabaseLines(reader, diagnostics)); + if (diagnostics != null) + { + 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}"); } } - private IEnumerable ReadModifiedPathDatabaseLines(StreamReader r) + internal IEnumerable ReadModifiedPathDatabaseLines(StreamReader r, HydrationSummaryDiagnostics diagnostics = null) { while (!r.EndOfStream) { @@ -131,8 +175,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 +204,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)) { 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 +231,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 +246,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/HydrationSummaryDiagnostics.cs b/GVFS/GVFS.Common/HealthCalculator/HydrationSummaryDiagnostics.cs new file mode 100644 index 0000000000..cc2bde307c --- /dev/null +++ b/GVFS/GVFS.Common/HealthCalculator/HydrationSummaryDiagnostics.cs @@ -0,0 +1,259 @@ +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 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.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..d1cd16fb0a 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,6 +11,77 @@ public static class HydrationStatus public const string SuccessResult = "S"; public const string NotAvailableResult = "NA"; + public enum CacheState + { + NotComputed, + Available, + ProviderUnavailable, + FeatureDisabled, + CircuitBreakerDisabled, + InvalidCounts, + IncompleteData, + Cancelled, + Failed, + } + + 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 /// @@ -45,20 +117,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 +146,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 +157,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 +171,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/NamedPipeClient.cs b/GVFS/GVFS.Common/NamedPipes/NamedPipeClient.cs index 18745788fa..caa4cc137e 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 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; } 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/Tests/EnlistmentPerFixture/HealthTests.cs b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/HealthTests.cs index e469991750..52254cfebe 100644 --- a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/HealthTests.cs +++ b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/HealthTests.cs @@ -8,6 +8,8 @@ using System.Globalization; using System.IO; using System.Text.RegularExpressions; +using System.Linq; +using System.Text.Json; namespace GVFS.FunctionalTests.Tests.EnlistmentPerFixture { @@ -137,6 +139,39 @@ public void FilterIntoDirectory() enlistmentHealthStatus: "Highly Hydrated"); } + [TestCase, Order(5)] + public void StatusRecordsTheDisplayedResultAndFallbackDiagnostics() + { + HashSet previousLogs = new HashSet(Directory.GetFiles(this.Enlistment.GVFSLogsRoot, "gvfs_health_*.log")); + string output = this.Enlistment.Health(statusOnly: true); + string log = Directory.GetFiles(this.Enlistment.GVFSLogsRoot, "gvfs_health_*.log").Single(path => !previousLogs.Contains(path)); + string[] lines = File.ReadAllLines(log); + string completion = lines.Single(line => line.Contains("health (Stop)")); + using (JsonDocument document = JsonDocument.Parse(completion.Substring(completion.IndexOf('{')))) + { + JsonElement root = document.RootElement; + 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().ShouldNotBeNull(); + string displayOutcome = root.GetProperty("DisplayOutcome").GetString(); + output.ShouldContain(displayOutcome == "Percentage" ? "% of files and " : "Error calculating hydration summary."); + if (root.GetProperty("ResultSource").GetString() == "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); + } + } + } + } + private void HydratePlaceholder(string filePath) { File.ReadAllText(filePath); 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/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.Mount/InProcessMount.cs b/GVFS/GVFS.Mount/InProcessMount.cs index 0a5c930b5f..957a705347 100644 --- a/GVFS/GVFS.Mount/InProcessMount.cs +++ b/GVFS/GVFS.Mount/InProcessMount.cs @@ -768,7 +768,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 +777,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 +792,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) diff --git a/GVFS/GVFS.UnitTests/Common/EnlistmentHydrationSummaryTests.cs b/GVFS/GVFS.UnitTests/Common/EnlistmentHydrationSummaryTests.cs index 3920ab1354..772799c3d3 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,199 @@ 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"); + 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(2, summary.Diagnostics.PlaceholderFileCount); + Assert.IsNull(summary.Diagnostics.TotalFolderCount); + if (phase != HydrationSummaryPhase.Placeholders) + { + Assert.AreEqual(3, summary.Diagnostics.ModifiedFileCount); + } + + string payload = telemetry.Events.Single(e => e.EventName == "HydrationSummaryDuration").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.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.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 UnrelatedCancellationExceptionIsFailure() + { + this.AddIndex(100); + EnlistmentHydrationSummary summary = EnlistmentHydrationSummary.CreateSummary( + this.context.Enlistment, this.fileSystem, this.tracer, () => throw new OperationCanceledException(), new TestPathData()); + Assert.AreEqual(HydrationSummaryOutcome.Failed, summary.Diagnostics.Outcome); + Assert.IsNotNull(summary.Error); + } + + private void AddIndex(int count) + { + byte[] bytes = new byte[12]; + Encoding.ASCII.GetBytes("DIRC").CopyTo(bytes, 0); + 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 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; + 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/GitStatusCacheTests.cs b/GVFS/GVFS.UnitTests/Common/GitStatusCacheTests.cs index 59737a83f8..26f0567bc1 100644 --- a/GVFS/GVFS.UnitTests/Common/GitStatusCacheTests.cs +++ b/GVFS/GVFS.UnitTests/Common/GitStatusCacheTests.cs @@ -88,6 +88,32 @@ 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 CanInvalidateCleanCache() { diff --git a/GVFS/GVFS.UnitTests/Common/HydrationStatusDiagnosticsTests.cs b/GVFS/GVFS.UnitTests/Common/HydrationStatusDiagnosticsTests.cs new file mode 100644 index 0000000000..26c1cca53c --- /dev/null +++ b/GVFS/GVFS.UnitTests/Common/HydrationStatusDiagnosticsTests.cs @@ -0,0 +1,163 @@ +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; + +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.That(body, Does.StartWith("1,2,3,4,100,50,D1,")); + 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("LegacyCache", metadata["Outcome"]); + 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] + 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/Tracing/TelemetryDaemonEventListenerTests.cs b/GVFS/GVFS.UnitTests/Tracing/TelemetryDaemonEventListenerTests.cs index f1deffe98f..8d3030fd4b 100644 --- a/GVFS/GVFS.UnitTests/Tracing/TelemetryDaemonEventListenerTests.cs +++ b/GVFS/GVFS.UnitTests/Tracing/TelemetryDaemonEventListenerTests.cs @@ -4,12 +4,65 @@ 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; namespace GVFS.UnitTests.Tracing { [TestFixture] public class TelemetryDaemonEventListenerTests { + [TestCase] + public async Task HydrationMetadataReachesTheCollectorWithNullsAndNestedCounts() + { + 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); + HydrationSummaryDiagnostics diagnostics = new HydrationSummaryDiagnostics + { + TotalFileCount = 100, + ModifiedFileCount = 0, + Outcome = HydrationSummaryOutcome.Failed, + ExceptionType = typeof(IOException).FullName, + }; + using (JsonTracer tracer = new JsonTracer("test", "test", disableTelemetry: true)) + { + tracer.AddEventListener(new TelemetryDaemonEventListener("test", "test-enlistment", "test-mount", pipeName, eventSink: null)); + tracer.RelatedEvent(EventLevel.Warning, "HydrationSummaryDuration", + new EventMetadata { ["Summary"] = diagnostics.GetMetadata() }, Keywords.Telemetry); + } + + string line = (await received).Split('\n')[0]; + TelemetryDaemonEventListener.PipeMessage envelope = TelemetryDaemonEventListener.PipeMessage.FromJson(line); + Assert.AreEqual("HydrationSummaryDuration", envelope.EventName); + Assert.AreEqual("test-enlistment", envelope.Payload.EnlistmentId); + using (JsonDocument json = JsonDocument.Parse(envelope.Payload.Json)) + { + JsonElement summary = json.RootElement.GetProperty("Summary"); + Assert.AreEqual(100, summary.GetProperty("TotalFileCount").GetInt32()); + Assert.AreEqual(0, summary.GetProperty("ModifiedFileCount").GetInt32()); + Assert.AreEqual(JsonValueKind.Null, summary.GetProperty("TotalFolderCount").ValueKind); + Assert.AreEqual("Failed", summary.GetProperty("Outcome").GetString()); + Assert.AreEqual(diagnostics.CalculationId.ToString("N"), summary.GetProperty("CalculationId").GetString()); + } + } + } + + 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.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/CommandLine/HealthVerb.cs b/GVFS/GVFS/CommandLine/HealthVerb.cs index 8bb453ac9c..17b595c904 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,257 @@ protected override void Execute(GVFSEnlistment enlistment) EventLevel.Informational, Keywords.Any); - if (this.StatusOnly) + Guid commandId = Guid.NewGuid(); + EventMetadata metadata = new EventMetadata { - this.OutputHydrationPercent(enlistment, tracer); - return; + ["SchemaVersion"] = 1, + ["CommandId"] = commandId.ToString("N"), + ["Mode"] = this.StatusOnly ? "Status" : "Detailed", + ["Outcome"] = "Failed", + }; + try + { + this.ExecuteWithTracer(enlistment, tracer, metadata, commandId); + } + finally + { + tracer.Stop(metadata); } + } + } - // Now default to the current working directory when running the verb without a specified path - if (string.IsNullOrEmpty(this.Directory) || this.Directory.Equals(".")) + private 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)) { - 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; - } + 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; } + } - 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"] = "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"] = cache.Info == null ? "LegacyCache" + : cache.Info.CountsComplete ? "Success" : "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"] = 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)) { - 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 + { + // 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 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++) { - return null; + 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/docs/hydration-status-diagnostics.md b/docs/hydration-status-diagnostics.md new file mode 100644 index 0000000000..b88e61d3ca --- /dev/null +++ b/docs/hydration-status-diagnostics.md @@ -0,0 +1,93 @@ +# 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. + +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 phase. +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. +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. +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 is an integer from `CacheState`. +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. + +## 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. + +This contract does not change count sets, disk-log replay, percentages, cache acceptance, or 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 --------------------------- From 75f1fed22afcc821da669880fa71d6f0f29ff197 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Fri, 23 Oct 2026 11:20:39 -0700 Subject: [PATCH 30/35] Correct hydration diagnostic policy boundaries and strengthen contract tests Preserve cached summaries and circuit-breaker counts when configuration reads fail. Preserve cancellation policy while recording cancellation origin. Separate command outcome from calculation outcome and name the heartbeat cache-state point sample explicitly. Pin IPC state integers and add regression tests for source-loader orchestration, failed phases, worker timeouts, cache snapshots, real command paths, and collector payloads. Assisted-by: GPT-6.1 Sol Signed-off-by: Tyrie Vella --- GVFS/GVFS.Common/GitStatusCache.cs | 59 +++-- .../EnlistmentHydrationSummary.cs | 8 +- .../HealthCalculator/EnlistmentPathData.cs | 48 ++-- .../HydrationStatusCircuitBreaker.cs | 4 +- .../HydrationSummaryDiagnostics.cs | 2 + .../HydrationStatusNamedPipeMessages.cs | 19 +- .../GVFS.Common/NamedPipes/NamedPipeClient.cs | 8 +- .../Tests/EnlistmentPerFixture/HealthTests.cs | 89 +++++++- .../Common/EnlistmentHydrationSummaryTests.cs | 122 +++++++++- .../Common/EnlistmentPathDiagnosticsTests.cs | 166 ++++++++++++++ .../Common/GitStatusCacheTests.cs | 214 ++++++++++++++++++ .../Common/HydrationStatusDiagnosticsTests.cs | 208 ++++++++++++++++- .../Mock/Common/MockNamedPipeClient.cs | 50 ++++ .../TelemetryDaemonEventListenerTests.cs | 100 ++++++-- GVFS/GVFS/CommandLine/HealthVerb.cs | 68 ++++-- docs/hydration-status-diagnostics.md | 28 ++- 16 files changed, 1074 insertions(+), 119 deletions(-) create mode 100644 GVFS/GVFS.UnitTests/Common/EnlistmentPathDiagnosticsTests.cs create mode 100644 GVFS/GVFS.UnitTests/Mock/Common/MockNamedPipeClient.cs diff --git a/GVFS/GVFS.Common/GitStatusCache.cs b/GVFS/GVFS.Common/GitStatusCache.cs index d28544df4d..369adda87c 100644 --- a/GVFS/GVFS.Common/GitStatusCache.cs +++ b/GVFS/GVFS.Common/GitStatusCache.cs @@ -291,7 +291,7 @@ public virtual bool WriteTelemetryandReset(EventMetadata metadata) { metadata["HydrationStatus.RequestCount"] = hydrationRequests; metadata["HydrationStatus.UnavailableCount"] = hydrationUnavailable; - metadata["HydrationStatus.CacheState"] = this.hydrationCache.State; + metadata["HydrationStatus.CurrentCacheState"] = this.hydrationCache.State; wroteTelemetry = true; } @@ -456,31 +456,27 @@ private void RebuildStatusCacheIfNeeded(bool ignoreBackoff) internal void UpdateHydrationSummary() { - HydrationStatusCircuitBreaker circuitBreaker = new HydrationStatusCircuitBreaker( - this.context.Enlistment.DotGVFSRoot, - this.context.Tracer); - try + if (this.projectedFolderCountProvider == null) { - if (this.projectedFolderCountProvider == null) - { - this.RecordHydrationSkip(NamedPipeMessages.HydrationStatus.CacheState.ProviderUnavailable); - return; - } + this.RecordHydrationSkip(NamedPipeMessages.HydrationStatus.CacheState.ProviderUnavailable); + return; + } - bool enabled = TEST_EnableHydrationSummaryOverride - ?? this.context.Repository.LibGit2RepoInvoker.GetConfigBoolOrDefault(GVFSConstants.GitConfig.ShowHydrationStatus, GVFSConstants.GitConfig.ShowHydrationStatusDefault); - if (!enabled) - { - this.RecordHydrationSkip(NamedPipeMessages.HydrationStatus.CacheState.FeatureDisabled); - return; - } + if (!this.GetHydrationSummaryEnabled()) + { + this.RecordHydrationSkip(NamedPipeMessages.HydrationStatus.CacheState.FeatureDisabled); + return; + } - if (circuitBreaker.IsDisabled()) - { - this.RecordHydrationSkip(NamedPipeMessages.HydrationStatus.CacheState.CircuitBreakerDisabled); - return; - } + HydrationStatusCircuitBreaker circuitBreaker = this.CreateHydrationCircuitBreaker(); + if (circuitBreaker.IsDisabled()) + { + this.RecordHydrationSkip(NamedPipeMessages.HydrationStatus.CacheState.CircuitBreakerDisabled); + return; + } + try + { /* While not strictly part of git status, enlistment hydration summary is used * in "git status" pre-command hook, and can take several seconds to compute on very large repos. * Accessing it here ensures that the value is cached for when a user invokes "git status", @@ -493,7 +489,7 @@ internal void UpdateHydrationSummary() FolderCountSource = HydrationFolderCountSource.Projection, }; EnlistmentHydrationSummary hydrationSummary = - EnlistmentHydrationSummary.CreateSummary(this.context.Enlistment, this.context.FileSystem, this.context.Tracer, this.projectedFolderCountProvider, this.shutdownTokenSource.Token, diagnostics); + 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 @@ -547,6 +543,23 @@ internal void UpdateHydrationSummary() } } + 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; diff --git a/GVFS/GVFS.Common/HealthCalculator/EnlistmentHydrationSummary.cs b/GVFS/GVFS.Common/HealthCalculator/EnlistmentHydrationSummary.cs index bfacee4885..9c605af1d4 100644 --- a/GVFS/GVFS.Common/HealthCalculator/EnlistmentHydrationSummary.cs +++ b/GVFS/GVFS.Common/HealthCalculator/EnlistmentHydrationSummary.cs @@ -102,7 +102,8 @@ internal static EnlistmentHydrationSummary CreateSummary( { summary.TotalFolderCount = 0; diagnostics.EarlyExit = true; - diagnostics.Outcome = HydrationSummaryOutcome.InvalidCounts; + diagnostics.Outcome = diagnostics.IndexState == HydrationDataState.Failed + ? HydrationSummaryOutcome.IncompleteData : HydrationSummaryOutcome.InvalidCounts; return summary; } @@ -115,9 +116,12 @@ internal static EnlistmentHydrationSummary CreateSummary( : diagnostics.CountsComplete ? HydrationSummaryOutcome.Success : HydrationSummaryOutcome.IncompleteData; return summary; } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + catch (OperationCanceledException e) { 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(); diff --git a/GVFS/GVFS.Common/HealthCalculator/EnlistmentPathData.cs b/GVFS/GVFS.Common/HealthCalculator/EnlistmentPathData.cs index a87d571413..ed76e811d1 100644 --- a/GVFS/GVFS.Common/HealthCalculator/EnlistmentPathData.cs +++ b/GVFS/GVFS.Common/HealthCalculator/EnlistmentPathData.cs @@ -51,14 +51,7 @@ public void NormalizeAllPaths() /// The current GVFS enlistment being operated on public virtual void LoadPlaceholdersFromDatabase(GVFSEnlistment enlistment, HydrationSummaryDiagnostics diagnostics = null) { - List filePlaceholders = new List(); - List folderPlaceholders = new List(); - - using (GVFSDatabase database = new GVFSDatabase(new PhysicalFileSystem(), enlistment.DotGVFSRoot, new SqliteDatabase())) - { - PlaceholderTable placeholderTable = new PlaceholderTable(database); - placeholderTable.GetAllEntries(out filePlaceholders, out folderPlaceholders); - } + this.ReadPlaceholderEntries(enlistment, out List filePlaceholders, out List folderPlaceholders); this.PlaceholderFilePaths.AddRange(filePlaceholders.Select(placeholderData => placeholderData.Path)); if (diagnostics != null) @@ -136,15 +129,11 @@ public virtual 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)); - if (diagnostics != null) - { - diagnostics.ModifiedPathsDiskOutcome = HydrationPathSourceOutcome.Success; - diagnostics.ModifiedPathsState = HydrationDataState.Complete; - } + diagnostics.ModifiedPathsDiskOutcome = HydrationPathSourceOutcome.Success; + diagnostics.ModifiedPathsState = HydrationDataState.Complete; } } catch (Exception ex) @@ -162,6 +151,31 @@ public virtual void LoadModifiedPaths(GVFSEnlistment enlistment, ITracer tracer, } } + 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); + } + } + + 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) @@ -206,7 +220,7 @@ internal IEnumerable ReadModifiedPathDatabaseLines(StreamReader r, Hydra /// An array containing all of the modified paths in string format 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(); 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 index cc2bde307c..eecbb7c0c3 100644 --- a/GVFS/GVFS.Common/HealthCalculator/HydrationSummaryDiagnostics.cs +++ b/GVFS/GVFS.Common/HealthCalculator/HydrationSummaryDiagnostics.cs @@ -113,6 +113,7 @@ public sealed class HydrationSummaryDiagnostics 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; } @@ -213,6 +214,7 @@ public EventMetadata GetMetadata() [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, }; diff --git a/GVFS/GVFS.Common/NamedPipes/HydrationStatusNamedPipeMessages.cs b/GVFS/GVFS.Common/NamedPipes/HydrationStatusNamedPipeMessages.cs index d1cd16fb0a..53259fc2cf 100644 --- a/GVFS/GVFS.Common/NamedPipes/HydrationStatusNamedPipeMessages.cs +++ b/GVFS/GVFS.Common/NamedPipes/HydrationStatusNamedPipeMessages.cs @@ -13,15 +13,15 @@ public static class HydrationStatus public enum CacheState { - NotComputed, - Available, - ProviderUnavailable, - FeatureDisabled, - CircuitBreakerDisabled, - InvalidCounts, - IncompleteData, - Cancelled, - Failed, + NotComputed = 0, + Available = 1, + ProviderUnavailable = 2, + FeatureDisabled = 3, + CircuitBreakerDisabled = 4, + InvalidCounts = 5, + IncompleteData = 6, + Cancelled = 7, + Failed = 8, } public enum ParseOutcome @@ -84,6 +84,7 @@ public static bool TryParse(string body, out CacheInfo info, bool hasCounts = fa /// /// Wire format: PlaceholderFileCount,PlaceholderFolderCount,ModifiedFileCount,ModifiedFolderCount,TotalFileCount,TotalFolderCount + /// An optional D1 extension follows the six counts. CacheInfo defines the extension fields. /// public class Response { diff --git a/GVFS/GVFS.Common/NamedPipes/NamedPipeClient.cs b/GVFS/GVFS.Common/NamedPipes/NamedPipeClient.cs index caa4cc137e..54c282ae49 100644 --- a/GVFS/GVFS.Common/NamedPipes/NamedPipeClient.cs +++ b/GVFS/GVFS.Common/NamedPipes/NamedPipeClient.cs @@ -21,7 +21,7 @@ public bool Connect(int timeoutMilliseconds = 3000) return this.Connect(timeoutMilliseconds, out _); } - public bool Connect(int timeoutMilliseconds, out Exception error) + public virtual bool Connect(int timeoutMilliseconds, out Exception error) { error = null; if (this.clientStream != null) @@ -65,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()); } @@ -102,7 +102,7 @@ public string ReadRawResponse() } } - public NamedPipeMessages.Message ReadResponse() + public virtual NamedPipeMessages.Message ReadResponse() { return NamedPipeMessages.Message.FromString(this.ReadRawResponse()); } @@ -121,7 +121,7 @@ public bool TryReadResponse(out NamedPipeMessages.Message message) } } - public void Dispose() + public virtual void Dispose() { this.ValidateConnection(); diff --git a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/HealthTests.cs b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/HealthTests.cs index 52254cfebe..56d96ca454 100644 --- a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/HealthTests.cs +++ b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/HealthTests.cs @@ -10,6 +10,7 @@ using System.Text.RegularExpressions; using System.Linq; using System.Text.Json; +using System.Threading; namespace GVFS.FunctionalTests.Tests.EnlistmentPerFixture { @@ -142,6 +143,7 @@ public void FilterIntoDirectory() [TestCase, Order(5)] public void StatusRecordsTheDisplayedResultAndFallbackDiagnostics() { + this.RemountWithHydrationStatus(false); HashSet previousLogs = new HashSet(Directory.GetFiles(this.Enlistment.GVFSLogsRoot, "gvfs_health_*.log")); string output = this.Enlistment.Health(statusOnly: true); string log = Directory.GetFiles(this.Enlistment.GVFSLogsRoot, "gvfs_health_*.log").Single(path => !previousLogs.Contains(path)); @@ -153,23 +155,90 @@ public void StatusRecordsTheDisplayedResultAndFallbackDiagnostics() 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().ShouldNotBeNull(); + 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."); - if (root.GetProperty("ResultSource").GetString() == "Fallback") + root.GetProperty("ResultSource").GetString().ShouldEqual("Fallback"); + string calculation = lines.Single(line => line.Contains("HydrationSummaryDuration ")); + using (JsonDocument calculationDocument = JsonDocument.Parse(calculation.Substring(calculation.IndexOf('{')))) { - 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)) + { + 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); + Stopwatch deadline = Stopwatch.StartNew(); + string lastCompletion = string.Empty; + while (deadline.Elapsed < TimeSpan.FromSeconds(30)) + { + HashSet previous = new HashSet(Directory.GetFiles(this.Enlistment.GVFSLogsRoot, "gvfs_health_*.log")); + string output = this.Enlistment.Health(statusOnly: true); + using (JsonDocument completion = this.ReadHealthCompletion(previous)) + { + JsonElement root = completion.RootElement; + lastCompletion = root.GetRawText(); + if (root.GetProperty("ResultSource").GetString() == "Cache") { - 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); + 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 "); + return; } } + + Thread.Sleep(100); } + + Assert.Fail("The mount did not publish a valid hydration summary. Last completion: " + lastCompletion); + } + + private void RemountWithHydrationStatus(bool enabled) + { + this.Enlistment.UnmountGVFS(); + GitProcess.InvokeProcess(this.Enlistment.RepoRoot, "config --local gvfs.show-hydration-status " + (enabled ? "true" : "false")).ExitCode.ShouldEqual(0); + this.Enlistment.MountGVFS(); + GitProcess.InvokeProcess(this.Enlistment.RepoRoot, "status --porcelain").ExitCode.ShouldEqual(0); + this.Enlistment.WaitForBackgroundOperations(); + } + + private JsonDocument ReadHealthCompletion(HashSet previous) + { + string log = Directory.GetFiles(this.Enlistment.GVFSLogsRoot, "gvfs_health_*.log").Single(path => !previous.Contains(path)); + string line = File.ReadAllLines(log).Single(value => value.Contains("health (Stop)")); + return JsonDocument.Parse(line.Substring(line.IndexOf('{'))); } private void HydratePlaceholder(string filePath) diff --git a/GVFS/GVFS.UnitTests/Common/EnlistmentHydrationSummaryTests.cs b/GVFS/GVFS.UnitTests/Common/EnlistmentHydrationSummaryTests.cs index 772799c3d3..99ead1c816 100644 --- a/GVFS/GVFS.UnitTests/Common/EnlistmentHydrationSummaryTests.cs +++ b/GVFS/GVFS.UnitTests/Common/EnlistmentHydrationSummaryTests.cs @@ -339,6 +339,7 @@ public void CalculationEmitsCountsAndFinalValidity(int folderCount, string outco 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; @@ -376,6 +377,13 @@ public void FailureRetainsEarlierAndPartialCounts(HydrationSummaryPhase phase) 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) @@ -383,7 +391,9 @@ public void FailureRetainsEarlierAndPartialCounts(HydrationSummaryPhase phase) Assert.AreEqual(3, summary.Diagnostics.ModifiedFileCount); } - string payload = telemetry.Events.Single(e => e.EventName == "HydrationSummaryDuration").Payload; + 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")); @@ -425,6 +435,8 @@ public void EarlyExitLeavesFolderCountAndTimingUnavailable() 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); @@ -442,6 +454,9 @@ public void CancellationRetainsSuccessfulObservations() 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); @@ -452,19 +467,116 @@ public void CancellationRetainsSuccessfulObservations() [TestCase] [Category(CategoryConstants.ExceptionExpected)] - public void UnrelatedCancellationExceptionIsFailure() + 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.IsNotNull(summary.Error); + 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()); } - private void AddIndex(int count) + [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); @@ -477,6 +589,7 @@ private sealed class TestPathData : EnlistmentPathData 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) { @@ -499,6 +612,7 @@ public override void LoadModifiedPaths(GVFSEnlistment enlistment, ITracer tracer } diagnostics.ModifiedPathsState = this.ModifiedState; + this.DuringModifiedPaths?.Invoke(); if (this.FailurePhase == HydrationSummaryPhase.ModifiedPaths) { throw new IOException("mock:\\private\\modified {secret}"); 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/GitStatusCacheTests.cs b/GVFS/GVFS.UnitTests/Common/GitStatusCacheTests.cs index 26f0567bc1..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 { @@ -114,6 +116,218 @@ public void DisabledHydrationFeatureExplainsTheCacheMiss() } } + [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/HydrationStatusDiagnosticsTests.cs b/GVFS/GVFS.UnitTests/Common/HydrationStatusDiagnosticsTests.cs index 26c1cca53c..3416b97ca1 100644 --- a/GVFS/GVFS.UnitTests/Common/HydrationStatusDiagnosticsTests.cs +++ b/GVFS/GVFS.UnitTests/Common/HydrationStatusDiagnosticsTests.cs @@ -7,6 +7,9 @@ using System; using System.IO; using System.Linq; +using System.Collections.Generic; +using System.Threading; +using GVFS.UnitTests.Category; namespace GVFS.UnitTests.Common { @@ -35,7 +38,7 @@ public void CacheExtensionPreservesLegacyCountPrefix() CountsComplete = true, }; string body = response.ToBody(info); - Assert.That(body, Does.StartWith("1,2,3,4,100,50,D1,")); + 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)); @@ -104,7 +107,8 @@ public void CachedDisplayRecordsCountsWithoutFallback() verb.OutputHydrationPercent(null, tracer, metadata, Guid.NewGuid()); Assert.AreEqual("Cache", metadata["ResultSource"]); Assert.AreEqual("Percentage", metadata["DisplayOutcome"]); - Assert.AreEqual("LegacyCache", metadata["Outcome"]); + 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"]); @@ -112,6 +116,206 @@ public void CachedDisplayRecordsCountsWithoutFallback() } } + [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() { 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/Tracing/TelemetryDaemonEventListenerTests.cs b/GVFS/GVFS.UnitTests/Tracing/TelemetryDaemonEventListenerTests.cs index 8d3030fd4b..3c9ced2ac8 100644 --- a/GVFS/GVFS.UnitTests/Tracing/TelemetryDaemonEventListenerTests.cs +++ b/GVFS/GVFS.UnitTests/Tracing/TelemetryDaemonEventListenerTests.cs @@ -10,47 +10,101 @@ 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] - public async Task HydrationMetadataReachesTheCollectorWithNullsAndNestedCounts() + [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); - HydrationSummaryDiagnostics diagnostics = new HydrationSummaryDiagnostics - { - TotalFileCount = 100, - ModifiedFileCount = 0, - Outcome = HydrationSummaryOutcome.Failed, - ExceptionType = typeof(IOException).FullName, - }; - using (JsonTracer tracer = new JsonTracer("test", "test", disableTelemetry: true)) + 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)); - tracer.RelatedEvent(EventLevel.Warning, "HydrationSummaryDuration", - new EventMetadata { ["Summary"] = diagnostics.GetMetadata() }, Keywords.Telemetry); + CollectorHealthVerb verb = new CollectorHealthVerb { StatusOnly = true, CacheHit = cacheHit, Output = output }; + verb.ExecuteWithCompletion(null, tracer, Guid.NewGuid()); } - string line = (await received).Split('\n')[0]; - TelemetryDaemonEventListener.PipeMessage envelope = TelemetryDaemonEventListener.PipeMessage.FromJson(line); - Assert.AreEqual("HydrationSummaryDuration", envelope.EventName); - Assert.AreEqual("test-enlistment", envelope.Payload.EnlistmentId); - using (JsonDocument json = JsonDocument.Parse(envelope.Payload.Json)) + 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 summary = json.RootElement.GetProperty("Summary"); - Assert.AreEqual(100, summary.GetProperty("TotalFileCount").GetInt32()); - Assert.AreEqual(0, summary.GetProperty("ModifiedFileCount").GetInt32()); - Assert.AreEqual(JsonValueKind.Null, summary.GetProperty("TotalFolderCount").ValueKind); - Assert.AreEqual("Failed", summary.GetProperty("Outcome").GetString()); - Assert.AreEqual(diagnostics.CalculationId.ToString("N"), summary.GetProperty("CalculationId").GetString()); + 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"); } } diff --git a/GVFS/GVFS/CommandLine/HealthVerb.cs b/GVFS/GVFS/CommandLine/HealthVerb.cs index 17b595c904..a6128dd8e3 100644 --- a/GVFS/GVFS/CommandLine/HealthVerb.cs +++ b/GVFS/GVFS/CommandLine/HealthVerb.cs @@ -71,25 +71,38 @@ protected override void Execute(GVFSEnlistment enlistment) Keywords.Any); Guid commandId = Guid.NewGuid(); - EventMetadata metadata = new EventMetadata - { - ["SchemaVersion"] = 1, - ["CommandId"] = commandId.ToString("N"), - ["Mode"] = this.StatusOnly ? "Status" : "Detailed", - ["Outcome"] = "Failed", - }; - try - { - this.ExecuteWithTracer(enlistment, tracer, metadata, commandId); - } - finally - { - tracer.Stop(metadata); - } + 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); } } - private void ExecuteWithTracer(GVFSEnlistment enlistment, ITracer tracer, EventMetadata metadata, Guid commandId) + internal virtual void ExecuteWithTracer(GVFSEnlistment enlistment, ITracer tracer, EventMetadata metadata, Guid commandId) { if (this.StatusOnly) { @@ -127,7 +140,7 @@ private void ExecuteWithTracer(GVFSEnlistment enlistment, ITracer tracer, EventM EnlistmentHealthData enlistmentHealthData = enlistmentHealthCalculator.CalculateStatistics(this.Directory); this.PrintOutput(enlistmentHealthData); - metadata["Outcome"] = "Success"; + metadata["Outcome"] = HealthCommandOutcome.Success; } internal void OutputHydrationPercent(GVFSEnlistment enlistment, ITracer tracer, EventMetadata metadata, Guid commandId) @@ -143,8 +156,9 @@ internal void OutputHydrationPercent(GVFSEnlistment enlistment, ITracer tracer, { metadata["ResultSource"] = "Cache"; metadata["DisplayOutcome"] = "Percentage"; - metadata["Outcome"] = cache.Info == null ? "LegacyCache" - : cache.Info.CountsComplete ? "Success" : "IncompleteData"; + 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; @@ -160,7 +174,8 @@ internal void OutputHydrationPercent(GVFSEnlistment enlistment, ITracer tracer, EnlistmentHydrationSummary summary = this.CreateHydrationSummary(enlistment, tracer, diagnostics); metadata["Summary"] = diagnostics.GetMetadata(); metadata["CalculationId"] = diagnostics.CalculationId.ToString("N"); - metadata["Outcome"] = diagnostics.Outcome; + metadata["Outcome"] = summary.IsValid ? HealthCommandOutcome.Success : HealthCommandOutcome.Failed; + metadata["CalculationOutcome"] = diagnostics.Outcome; metadata["DisplayOutcome"] = summary.IsValid ? "Percentage" : "Error"; this.Output.WriteLine(summary.ToMessage()); } @@ -182,7 +197,7 @@ internal virtual CacheLookupResult TryGetCachedHydrationStatus(GVFSEnlistment en { try { - using (NamedPipeClient pipeClient = new NamedPipeClient(enlistment.NamedPipeName)) + using (NamedPipeClient pipeClient = this.CreateHydrationPipeClient(enlistment)) { if (!pipeClient.Connect(ConnectTimeoutMs, out Exception error)) { @@ -223,6 +238,17 @@ internal virtual CacheLookupResult TryGetCachedHydrationStatus(GVFSEnlistment en 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 }; diff --git a/docs/hydration-status-diagnostics.md b/docs/hydration-status-diagnostics.md index b88e61d3ca..b37195a924 100644 --- a/docs/hydration-status-diagnostics.md +++ b/docs/hydration-status-diagnostics.md @@ -9,6 +9,9 @@ The diagnostic fields describe both attempts without a repository path or a raw `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. +This terminal record replaces the generic exception telemetry and the early-exit warning. The six count fields contain numbers or JSON null. A null value means that the calculation did not obtain that count. @@ -21,13 +24,16 @@ Partial counts remain available, but their source state is not `Complete`. `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 phase. +`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. @@ -40,6 +46,9 @@ They do not change its replay semantics or the recursive meaning of modified-fol 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. @@ -58,7 +67,20 @@ D1,state,calculationId,ageMs,invalidated,countsComplete ``` An `NA` response can contain this extension without counts. -The state is an integer from `CacheState`. +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`. @@ -71,6 +93,7 @@ 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 @@ -82,6 +105,7 @@ The modified-path reader retains its existing connection and response-wait behav Telemetry contains exception types and HRESULT values, not arbitrary exception messages or stacks. Local warnings retain full exception details. +Hydration exceptions no longer produce generic Error-level telemetry or telemetry warnings with full exception text. The structured result fields are identical in local logs and telemetry. Raw details appear only in local warnings. From d800cfe84fc88fefc459424aed7eb892ac85d46d Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Fri, 23 Oct 2026 12:36:50 -0700 Subject: [PATCH 31/35] Wait for hydration cache state in health functional tests Poll the structured cache state before checking disabled and available results. Filesystem background readiness does not guarantee that the hydration refresh passed its backoff. Keep the deadline bounded and retain the final completion record on failure. Add functional test cases with zero-second and five-second cache backoff. Keep product behavior unchanged. Assisted-by: GPT-6.1 Sol Signed-off-by: Tyrie Vella --- .../Tests/EnlistmentPerFixture/HealthTests.cs | 103 +++++++++--------- 1 file changed, 53 insertions(+), 50 deletions(-) diff --git a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/HealthTests.cs b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/HealthTests.cs index 56d96ca454..f8c122861b 100644 --- a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/HealthTests.cs +++ b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/HealthTests.cs @@ -140,38 +140,31 @@ public void FilterIntoDirectory() enlistmentHealthStatus: "Highly Hydrated"); } - [TestCase, Order(5)] - public void StatusRecordsTheDisplayedResultAndFallbackDiagnostics() + [TestCase(0), Order(5)] + [TestCase(5)] + public void StatusRecordsTheDisplayedResultAndFallbackDiagnostics(int backoffSeconds) { - this.RemountWithHydrationStatus(false); - HashSet previousLogs = new HashSet(Directory.GetFiles(this.Enlistment.GVFSLogsRoot, "gvfs_health_*.log")); - string output = this.Enlistment.Health(statusOnly: true); - string log = Directory.GetFiles(this.Enlistment.GVFSLogsRoot, "gvfs_health_*.log").Single(path => !previousLogs.Contains(path)); - string[] lines = File.ReadAllLines(log); - string completion = lines.Single(line => line.Contains("health (Stop)")); - using (JsonDocument document = JsonDocument.Parse(completion.Substring(completion.IndexOf('{')))) + 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 root = document.RootElement; - 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); - } + 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); } } @@ -180,7 +173,7 @@ public void DetailedHealthRecordsItsCommandMode() { HashSet previous = new HashSet(Directory.GetFiles(this.Enlistment.GVFSLogsRoot, "gvfs_health_*.log")); this.Enlistment.Health(); - using (JsonDocument completion = this.ReadHealthCompletion(previous)) + using (JsonDocument completion = this.ReadHealthCompletion(previous, out _)) { completion.RootElement.GetProperty("Mode").GetString().ShouldEqual("Detailed"); completion.RootElement.GetProperty("Outcome").GetString().ShouldEqual("Success"); @@ -192,52 +185,62 @@ public void DetailedHealthRecordsItsCommandMode() 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")); - string output = this.Enlistment.Health(statusOnly: true); - using (JsonDocument completion = this.ReadHealthCompletion(previous)) + output = this.Enlistment.Health(statusOnly: true); + using (JsonDocument completion = this.ReadHealthCompletion(previous, out lines)) { JsonElement root = completion.RootElement; lastCompletion = root.GetRawText(); - if (root.GetProperty("ResultSource").GetString() == "Cache") + if (root.GetProperty("Cache").GetProperty("State").GetString() == expectedState) { - 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 "); - return; + return root.Clone(); } } Thread.Sleep(100); } - Assert.Fail("The mount did not publish a valid hydration summary. Last completion: " + lastCompletion); + throw new AssertionException("The hydration cache did not reach " + expectedState + ". Last completion: " + lastCompletion); } - private void RemountWithHydrationStatus(bool enabled) + 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) + private JsonDocument ReadHealthCompletion(HashSet previous, out string[] lines) { string log = Directory.GetFiles(this.Enlistment.GVFSLogsRoot, "gvfs_health_*.log").Single(path => !previous.Contains(path)); - string line = File.ReadAllLines(log).Single(value => value.Contains("health (Stop)")); + lines = File.ReadAllLines(log); + string line = lines.Single(value => value.Contains("health (Stop)")); return JsonDocument.Parse(line.Substring(line.IndexOf('{'))); } From 1324889d7429209f36af1938638aa651266cf20a Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Mon, 26 Oct 2026 10:59:06 +0100 Subject: [PATCH 32/35] Update default Microsoft Git version to v2.56.0.vfs.0.4 --- .github/workflows/build.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 1289c084b2..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.56.0.vfs.0.2' }} + GIT_VERSION: ${{ github.event.inputs.git_version || 'v2.56.0.vfs.0.4' }} jobs: validate: From f543d02e52096902e9b407e783f6ba5a0476c70c Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Wed, 28 Oct 2026 13:49:41 -0700 Subject: [PATCH 33/35] docs: state the durable hydration diagnostic contract Keep telemetry replacement history in the pull request description. State the count, cache, and circuit-breaker preservation requirement directly in the diagnostic contract. Assisted-by: GPT-6.1 Sol Signed-off-by: Tyrie Vella --- docs/hydration-status-diagnostics.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/hydration-status-diagnostics.md b/docs/hydration-status-diagnostics.md index b37195a924..4423e459b5 100644 --- a/docs/hydration-status-diagnostics.md +++ b/docs/hydration-status-diagnostics.md @@ -11,7 +11,6 @@ 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. -This terminal record replaces the generic exception telemetry and the early-exit warning. The six count fields contain numbers or JSON null. A null value means that the calculation did not obtain that count. @@ -105,7 +104,6 @@ The modified-path reader retains its existing connection and response-wait behav Telemetry contains exception types and HRESULT values, not arbitrary exception messages or stacks. Local warnings retain full exception details. -Hydration exceptions no longer produce generic Error-level telemetry or telemetry warnings with full exception text. The structured result fields are identical in local logs and telemetry. Raw details appear only in local warnings. @@ -114,4 +112,4 @@ The listener can drop events when its queue is full or its collector is unavaila Process termination can also prevent a terminal record. Mount telemetry cannot observe a hook connection failure that never reaches the mount. -This contract does not change count sets, disk-log replay, percentages, cache acceptance, or circuit-breaker policy. +Diagnostic collection must preserve count sets, disk-log replay, percentages, cache acceptance, and circuit-breaker policy. From cb754df28a9345eed69420a735276b643bae25a6 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Wed, 28 Oct 2026 14:04:54 -0700 Subject: [PATCH 34/35] fix: block full dehydrate on unverifiable worktree metadata Reject unreadable reverse gitdir pointers. Ignore missing or empty pointers only when the administrative entry contains no other metadata. Keep nondestructive discovery unchanged. Add real-worktree tests that demonstrate Git omitting usable worktrees and verify full dehydrate stops before index mutation. Assisted-by: GPT-6.1 Sol Signed-off-by: Tyrie Vella --- GVFS/GVFS.Common/GVFSEnlistment.Shared.cs | 32 ++++----- .../EnlistmentPerFixture/WorktreeTests.cs | 67 +++++++++++++++++++ .../CommandLine/GitIndexPathCallerTests.cs | 63 +++++++++++++---- GVFS/GVFS/CommandLine/DehydrateVerb.cs | 9 +-- 4 files changed, 135 insertions(+), 36 deletions(-) diff --git a/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs b/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs index f4510303bc..959bd0eafd 100644 --- a/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs +++ b/GVFS/GVFS.Common/GVFSEnlistment.Shared.cs @@ -214,21 +214,19 @@ public static string[] GetKnownWorktreePaths(string gitDir) } /// - /// Reads registrations, skipping missing, empty, or unreadable files as Git does. - /// Nonempty malformed files and directory enumeration failures are reported to the caller. + /// 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, - out List skippedRegistrationErrors) + out Exception exception) { ArgumentNullException.ThrowIfNull(gitDir); paths = null; error = null; exception = null; - skippedRegistrationErrors = new List(); string pathToRead = gitDir; try { @@ -261,32 +259,36 @@ public static bool TryGetKnownWorktreePaths( foreach (string entry in Directory.GetDirectories(worktreesDir)) { pathToRead = Path.Combine(entry, "gitdir"); - string gitdirContent; + string gitdirContent = string.Empty; try { using (FileStream stream = File.OpenRead(pathToRead)) { - if (stream.Length == 0) - { - continue; - } - using (StreamReader reader = new StreamReader(stream)) { - gitdirContent = reader.ReadToEnd().Trim().Replace('/', Path.DirectorySeparatorChar); + gitdirContent = reader.ReadToEnd(); } } } catch (FileNotFoundException) { - continue; } - catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) + + if (gitdirContent.Length == 0) { - skippedRegistrationErrors.Add($"Failed to read '{pathToRead}': {ex}"); + 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)) { diff --git a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs index cdc26ea0a8..1ab25b7080 100644 --- a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs +++ b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/WorktreeTests.cs @@ -469,6 +469,73 @@ public void FullDehydrateRejectsLinkedWorktreesBeforeMutation(bool fromWorktree) } } + [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() { diff --git a/GVFS/GVFS.UnitTests/CommandLine/GitIndexPathCallerTests.cs b/GVFS/GVFS.UnitTests/CommandLine/GitIndexPathCallerTests.cs index 721aeefd85..c481285e99 100644 --- a/GVFS/GVFS.UnitTests/CommandLine/GitIndexPathCallerTests.cs +++ b/GVFS/GVFS.UnitTests/CommandLine/GitIndexPathCallerTests.cs @@ -250,7 +250,7 @@ public void FullDehydrateRejectsMalformedRegistration(string registration) } [TestCase] - public void FullDehydrateIgnoresUnreadableRegistration() + public void FullDehydrateRejectsUnreadableRegistration() { GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree: false); string worktreeGitDir = Path.Combine(enlistment.DotGitRoot, "worktrees", "locked"); @@ -263,15 +263,15 @@ public void FullDehydrateIgnoresUnreadableRegistration() using (MockTracer tracer = new MockTracer()) { DehydrateVerb verb = new DehydrateVerb { Full = true, Output = output }; - Assert.DoesNotThrow(() => verb.ValidateFullDehydrate(tracer, enlistment)); - output.ToString().Length.ShouldEqual(0); - tracer.RelatedErrorEvents.Count.ShouldEqual(0); - tracer.RelatedWarningEvents.Count.ShouldEqual(1); - using (JsonDocument metadata = JsonDocument.Parse(tracer.RelatedWarningEvents[0])) + 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("Error").GetString().ShouldContain(gitdirFile); - metadata.RootElement.GetProperty("Error").GetString().ShouldContain("System.IO.IOException"); - metadata.RootElement.GetProperty("Error").GetString().ShouldContain("TryGetKnownWorktreePaths"); + metadata.RootElement.GetProperty("Exception").GetString().ShouldContain("System.IO.IOException"); + metadata.RootElement.GetProperty("Exception").GetString().ShouldContain("TryGetKnownWorktreePaths"); } output.ToString().Contains("System.IO.IOException").ShouldBeFalse(); @@ -350,7 +350,7 @@ public void FullDehydrateIgnoresEmptyGitdir() } [TestCase] - public void FullDehydrateStillRejectsVisibleWorktreeWhenAnotherEntryIsUnreadable() + public void FullDehydrateRejectsUnverifiableStateWhenAnotherWorktreeIsReadable() { GVFSEnlistment enlistment = this.CreateEnlistment(isWorktree: false); string validDir = Path.Combine(enlistment.DotGitRoot, "worktrees", "valid"); @@ -367,9 +367,46 @@ public void FullDehydrateStillRejectsVisibleWorktreeWhenAnotherEntryIsUnreadable { 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); - tracer.RelatedWarningEvents.Count.ShouldEqual(1); + 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); } } diff --git a/GVFS/GVFS/CommandLine/DehydrateVerb.cs b/GVFS/GVFS/CommandLine/DehydrateVerb.cs index 2005c469c3..61d5968b8b 100644 --- a/GVFS/GVFS/CommandLine/DehydrateVerb.cs +++ b/GVFS/GVFS/CommandLine/DehydrateVerb.cs @@ -793,14 +793,7 @@ internal void ValidateFullDehydrate(ITracer tracer, GVFSEnlistment enlistment) enlistment.DotGitRoot, out string[] worktreePaths, out string error, - out Exception exception, - out List skippedRegistrationErrors); - foreach (string skippedRegistrationError in skippedRegistrationErrors) - { - tracer.RelatedWarning( - new EventMetadata { { "Error", skippedRegistrationError } }, - "Skipped unreadable worktree registration file."); - } + out Exception exception); if (!readSucceeded) { From 124fbd235b1e741ff411a1fe2cb8c0b3549a8522 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Wed, 28 Oct 2026 15:06:52 -0700 Subject: [PATCH 35/35] fix: address review feedback on upgrade exception output Keep full upgrade exception details in the log and return only the exception message to the command. Add a functional test that checks console output, diagnostic logs, and recovery after metadata restoration. Assisted-by: GPT-6.1 Sol Signed-off-by: Tyrie Vella --- .../DiskLayoutUpgrades/DiskLayoutUpgrade.cs | 2 +- .../Tests/EnlistmentPerFixture/MountTests.cs | 41 ++++++++++++++++++- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/GVFS/GVFS.Common/DiskLayoutUpgrades/DiskLayoutUpgrade.cs b/GVFS/GVFS.Common/DiskLayoutUpgrades/DiskLayoutUpgrade.cs index 2dccefa75b..96d713e17c 100644 --- a/GVFS/GVFS.Common/DiskLayoutUpgrades/DiskLayoutUpgrade.cs +++ b/GVFS/GVFS.Common/DiskLayoutUpgrades/DiskLayoutUpgrade.cs @@ -59,7 +59,7 @@ public static bool TryRunAllUpgrades(string enlistmentRoot, out string error, ou { StartLogFile(enlistmentRoot, tracer); tracer.RelatedError(e.ToString()); - error = e.ToString(); + error = e.Message; returnCode = ReturnCode.GenericError; return false; } diff --git a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/MountTests.cs b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/MountTests.cs index 259a4dc98b..963bb449f3 100644 --- a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/MountTests.cs +++ b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/MountTests.cs @@ -176,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() { @@ -394,7 +431,7 @@ public void ProjFS_CMDHangNoneActiveInstance() this.Enlistment.MountGVFS(); } - private void MountShouldFail(int expectedExitCode, string expectedErrorMessage, string mountWorkingDirectory = null, string additionalExpectedErrorMessage = null) + private string MountShouldFail(int expectedExitCode, string expectedErrorMessage, string mountWorkingDirectory = null, string additionalExpectedErrorMessage = null) { string enlistmentRoot = this.Enlistment.EnlistmentRoot; @@ -420,6 +457,8 @@ private void MountShouldFail(int expectedExitCode, string expectedErrorMessage, combinedOutput.ShouldContain(additionalExpectedErrorMessage); } } + + return result.Output + "\n" + result.Errors; } private void MountShouldFail(string expectedErrorMessage, string mountWorkingDirectory = null)