From 6af85b2bd1229c7d38409224677df44e7814849d Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Thu, 4 Jun 2026 11:24:04 -0700 Subject: [PATCH] Show mount progress phases in CLI during gvfs mount Move the named pipe server start earlier in InProcessMount so MountVerb can connect and poll status during the parallel auth+validation phase. Add a MountProgress field to the GetStatus response carrying a human-readable phase description that the CLI renders as a dynamic spinner sub-status. Changes: - NamedPipeMessages: add MountProgress to GetStatus.Response - InProcessMount: volatile progress string set at each phase; pipe started after RepoMetadata init (before parallel tasks); HandleRequest guards non-GetStatus during Mounting state; HandleGetStatusRequest null-safe for early-pipe fields - ConsoleHelper: new ShowStatusWhileRunning overloads accepting Func getMessage for dynamic spinner text - GVFSEnlistment: optional Action onProgress callback on WaitUntilMounted (existing callers unaffected) - MountVerb: wires dynamic spinner to progress callback User sees: Mounting (Authenticating and validating)... Mounting (Starting virtualization)... Mounting...Succeeded Assisted-by: Claude Opus 4.6 Signed-off-by: Tyrie Vella --- GVFS/GVFS.Common/ConsoleHelper.cs | 70 ++++++++++- GVFS/GVFS.Common/GVFSEnlistment.cs | 11 +- .../NamedPipes/NamedPipeMessages.cs | 1 + GVFS/GVFS.Mount/InProcessMount.cs | 114 ++++++++++-------- GVFS/GVFS/CommandLine/GVFSVerb.cs | 16 +++ GVFS/GVFS/CommandLine/MountVerb.cs | 13 +- 6 files changed, 170 insertions(+), 55 deletions(-) diff --git a/GVFS/GVFS.Common/ConsoleHelper.cs b/GVFS/GVFS.Common/ConsoleHelper.cs index d40785334..dd1e0127f 100644 --- a/GVFS/GVFS.Common/ConsoleHelper.cs +++ b/GVFS/GVFS.Common/ConsoleHelper.cs @@ -21,6 +21,32 @@ public static bool ShowStatusWhileRunning( bool showSpinner, string gvfsLogEnlistmentRoot, int initialDelayMs = 0) + { + return ShowStatusWhileRunning( + action, + getMessage: null, + message: message, + output, + showSpinner, + gvfsLogEnlistmentRoot, + initialDelayMs); + } + + /// + /// Runs an action while displaying a dynamic status message with a spinner. + /// The delegate is called on each spinner tick + /// and may return a sub-status string (e.g. "Authenticating") that is appended + /// to in parentheses. When null or returning null, + /// only the base message is shown. + /// + public static bool ShowStatusWhileRunning( + Func action, + Func getMessage, + string message, + TextWriter output, + bool showSpinner, + string gvfsLogEnlistmentRoot, + int initialDelayMs = 0) { Func actionResultAction = () => @@ -30,6 +56,7 @@ public static bool ShowStatusWhileRunning( ActionResult result = ShowStatusWhileRunning( actionResultAction, + getMessage, message, output, showSpinner, @@ -46,6 +73,18 @@ public static ActionResult ShowStatusWhileRunning( bool showSpinner, string gvfsLogEnlistmentRoot, int initialDelayMs) + { + return ShowStatusWhileRunning(action, getMessage: null, message, output, showSpinner, gvfsLogEnlistmentRoot, initialDelayMs); + } + + public static ActionResult ShowStatusWhileRunning( + Func action, + Func getMessage, + string message, + TextWriter output, + bool showSpinner, + string gvfsLogEnlistmentRoot, + int initialDelayMs) { ActionResult result = ActionResult.Failure; bool initialMessageWritten = false; @@ -67,6 +106,7 @@ public static ActionResult ShowStatusWhileRunning( { int retries = 0; char[] waiting = { '\u2014', '\\', '|', '/' }; + string lastProgress = null; while (!isComplete) { @@ -76,7 +116,23 @@ public static ActionResult ShowStatusWhileRunning( } else { - output.Write("\r{0}...{1}", message, waiting[(retries / 2) % waiting.Length]); + string progress = getMessage?.Invoke(); + string displayMessage = !string.IsNullOrEmpty(progress) + ? $"{message} ({progress})" + : message; + + // Clear previous line content when message shrinks + string line = $"\r{displayMessage}...{waiting[(retries / 2) % waiting.Length]}"; + if (lastProgress != null && lastProgress.Length > line.Length) + { + output.Write(line + new string(' ', lastProgress.Length - line.Length)); + } + else + { + output.Write(line); + } + + lastProgress = line; initialMessageWritten = true; actionIsDone.WaitOne(100); } @@ -86,8 +142,16 @@ public static ActionResult ShowStatusWhileRunning( if (initialMessageWritten) { - // Clear out any trailing waiting character - output.Write("\r{0}...", message); + // Clear out any trailing waiting character and sub-status + string finalLine = $"\r{message}..."; + if (lastProgress != null && lastProgress.Length > finalLine.Length) + { + output.Write(finalLine + new string(' ', lastProgress.Length - finalLine.Length) + $"\r{message}..."); + } + else + { + output.Write(finalLine); + } } }); spinnerThread.Start(); diff --git a/GVFS/GVFS.Common/GVFSEnlistment.cs b/GVFS/GVFS.Common/GVFSEnlistment.cs index 286b32037..9f395f6b6 100644 --- a/GVFS/GVFS.Common/GVFSEnlistment.cs +++ b/GVFS/GVFS.Common/GVFSEnlistment.cs @@ -212,13 +212,13 @@ public static string GetNewGVFSLogFileName( fileSystem: fileSystem); } - public static bool WaitUntilMounted(ITracer tracer, string enlistmentRoot, bool unattended, out string errorMessage) + public static bool WaitUntilMounted(ITracer tracer, string enlistmentRoot, bool unattended, out string errorMessage, Action onProgress = null) { string pipeName = GVFSPlatform.Instance.GetNamedPipeName(enlistmentRoot); - return WaitUntilMounted(tracer, pipeName, enlistmentRoot, unattended, out errorMessage); + return WaitUntilMounted(tracer, pipeName, enlistmentRoot, unattended, out errorMessage, onProgress); } - public static bool WaitUntilMounted(ITracer tracer, string pipeName, string enlistmentRoot, bool unattended, out string errorMessage) + public static bool WaitUntilMounted(ITracer tracer, string pipeName, string enlistmentRoot, bool unattended, out string errorMessage, Action onProgress = null) { tracer.RelatedInfo($"{nameof(WaitUntilMounted)}: Creating NamedPipeClient for pipe '{pipeName}'"); @@ -260,6 +260,11 @@ public static bool WaitUntilMounted(ITracer tracer, string pipeName, string enli } else { + if (onProgress != null && !string.IsNullOrEmpty(getStatusResponse.MountProgress)) + { + onProgress(getStatusResponse.MountProgress); + } + tracer.RelatedInfo($"{nameof(WaitUntilMounted)}: Waiting 500ms for mount process to be ready"); Thread.Sleep(100); } diff --git a/GVFS/GVFS.Common/NamedPipes/NamedPipeMessages.cs b/GVFS/GVFS.Common/NamedPipes/NamedPipeMessages.cs index d42c84873..7b2159605 100644 --- a/GVFS/GVFS.Common/NamedPipes/NamedPipeMessages.cs +++ b/GVFS/GVFS.Common/NamedPipes/NamedPipeMessages.cs @@ -35,6 +35,7 @@ public static class GetStatus public class Response { public string MountStatus { get; set; } + public string MountProgress { 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 1272eb876..68c3f32bc 100644 --- a/GVFS/GVFS.Mount/InProcessMount.cs +++ b/GVFS/GVFS.Mount/InProcessMount.cs @@ -55,7 +55,8 @@ public class InProcessMount private GVFSContext context; private GVFSGitObjects gitObjects; - private MountState currentState; + private volatile MountState currentState; + private volatile string mountProgressMessage; private HeartbeatThread heartbeat; private ManualResetEvent unmountEvent; @@ -194,65 +195,71 @@ private void MountWithLockAcquired(EventLevel verbosity, Keywords keywords) this.enlistment.InitializeCachePaths(localCacheRoot, gitObjectsRoot, blobSizesRoot); - // Local validations and git config run while we wait for the network - var localTask = Task.Run(() => + // Start the pipe server early so MountVerb can connect and poll progress + // during the parallel validation phase. Only GetStatus requests are + // handled while currentState == Mounting (see HandleRequest guard). + this.mountProgressMessage = "Authenticating and validating"; + using (NamedPipeServer pipeServer = this.StartNamedPipe()) { - Stopwatch sw = Stopwatch.StartNew(); + this.tracer.RelatedEvent( + EventLevel.Informational, + $"{nameof(this.Mount)}_StartedNamedPipe", + new EventMetadata { { "NamedPipeName", this.enlistment.NamedPipeName } }); - this.ValidateGitVersion(); - this.tracer.RelatedInfo("ParallelMount: ValidateGitVersion completed in {0}ms", sw.ElapsedMilliseconds); + // Local validations and git config run while we wait for the network + Task localTask = Task.Run(() => + { + Stopwatch sw = Stopwatch.StartNew(); - this.ValidateHooksVersion(); - this.ValidateFileSystemSupportsRequiredFeatures(); + this.ValidateGitVersion(); + this.tracer.RelatedInfo("ParallelMount: ValidateGitVersion completed in {0}ms", sw.ElapsedMilliseconds); - GitProcess git = new GitProcess(this.enlistment); - if (!git.IsValidRepo()) - { - this.FailMountAndExit("The .git folder is missing or has invalid contents"); - } + this.ValidateHooksVersion(); + this.ValidateFileSystemSupportsRequiredFeatures(); - if (!GVFSPlatform.Instance.FileSystem.IsFileSystemSupported(this.enlistment.WorkingDirectoryRoot, out string fsError)) - { - this.FailMountAndExit("FileSystem unsupported: " + fsError); - } + GitProcess git = new GitProcess(this.enlistment); + if (!git.IsValidRepo()) + { + this.FailMountAndExit("The .git folder is missing or has invalid contents"); + } - this.tracer.RelatedInfo("ParallelMount: Local validations completed in {0}ms", sw.ElapsedMilliseconds); + if (!GVFSPlatform.Instance.FileSystem.IsFileSystemSupported(this.enlistment.WorkingDirectoryRoot, out string fsError)) + { + this.FailMountAndExit("FileSystem unsupported: " + fsError); + } - if (!this.TrySetRequiredGitConfigSettings()) - { - this.FailMountAndExit("Unable to configure git repo"); - } + this.tracer.RelatedInfo("ParallelMount: Local validations completed in {0}ms", sw.ElapsedMilliseconds); - this.LogEnlistmentInfoAndSetConfigValues(); - this.tracer.RelatedInfo("ParallelMount: Local validations + git config completed in {0}ms", sw.ElapsedMilliseconds); - }); + if (!this.TrySetRequiredGitConfigSettings()) + { + this.FailMountAndExit("Unable to configure git repo"); + } - try - { - Task.WaitAll(networkTask, localTask); - } - catch (AggregateException ae) - { - this.FailMountAndExit(ae.Flatten().InnerExceptions[0].Message); - } + this.LogEnlistmentInfoAndSetConfigValues(); + this.tracer.RelatedInfo("ParallelMount: Local validations + git config completed in {0}ms", sw.ElapsedMilliseconds); + }); - parallelTimer.Stop(); - this.tracer.RelatedInfo("ParallelMount: All parallel tasks completed in {0}ms", parallelTimer.ElapsedMilliseconds); + try + { + Task.WaitAll(networkTask, localTask); + } + catch (AggregateException ae) + { + this.FailMountAndExit(ae.Flatten().InnerExceptions[0].Message); + } - ServerGVFSConfig serverGVFSConfig = networkTask.Result; + parallelTimer.Stop(); + this.tracer.RelatedInfo("ParallelMount: All parallel tasks completed in {0}ms", parallelTimer.ElapsedMilliseconds); - CacheServerResolver cacheServerResolver = new CacheServerResolver(this.tracer, this.enlistment); - this.cacheServer = cacheServerResolver.ResolveNameFromRemote(this.cacheServer.Url, serverGVFSConfig); + ServerGVFSConfig serverGVFSConfig = networkTask.Result; - this.EnsureLocalCacheIsHealthy(serverGVFSConfig); + this.mountProgressMessage = "Resolving cache server"; + CacheServerResolver cacheServerResolver = new CacheServerResolver(this.tracer, this.enlistment); + this.cacheServer = cacheServerResolver.ResolveNameFromRemote(this.cacheServer.Url, serverGVFSConfig); - using (NamedPipeServer pipeServer = this.StartNamedPipe()) - { - this.tracer.RelatedEvent( - EventLevel.Informational, - $"{nameof(this.Mount)}_StartedNamedPipe", - new EventMetadata { { "NamedPipeName", this.enlistment.NamedPipeName } }); + this.EnsureLocalCacheIsHealthy(serverGVFSConfig); + this.mountProgressMessage = "Preparing mount"; this.context = this.CreateContext(); if (this.context.Unattended) @@ -273,6 +280,7 @@ private void MountWithLockAcquired(EventLevel verbosity, Keywords keywords) GVFSPlatform.Instance.ConfigureVisualStudio(this.enlistment.GitBinPath, this.tracer); + this.mountProgressMessage = "Starting virtualization"; this.MountAndStartWorkingDirectoryCallbacks(this.cacheServer); try @@ -295,6 +303,7 @@ private void MountWithLockAcquired(EventLevel verbosity, Keywords keywords) }, Keywords.Telemetry); + this.mountProgressMessage = null; this.currentState = MountState.Ready; this.unmountEvent.WaitOne(); @@ -474,6 +483,16 @@ private void HandleRequest(ITracer tracer, string request, NamedPipeServer.Conne { 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. + if (message.Header != NamedPipeMessages.GetStatus.Request && + this.currentState != MountState.Ready && + this.currentState != MountState.Unmounting) + { + connection.TrySendResponse(NamedPipeMessages.MountNotReadyResult); + return; + } + switch (message.Header) { case NamedPipeMessages.GetStatus.Request: @@ -999,14 +1018,15 @@ private void HandleGetStatusRequest(NamedPipeServer.Connection connection) response.EnlistmentRoot = this.enlistment.WorkingDirectoryRoot; response.LocalCacheRoot = !string.IsNullOrWhiteSpace(this.enlistment.LocalCacheRoot) ? this.enlistment.LocalCacheRoot : this.enlistment.GitObjectsRoot; response.RepoUrl = this.enlistment.RepoUrl; - response.CacheServer = this.cacheServer.ToString(); - response.LockStatus = this.context?.Repository.GVFSLock != null ? this.context.Repository.GVFSLock.GetStatus() : "Unavailable"; + response.CacheServer = this.cacheServer?.ToString() ?? string.Empty; + 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) { case MountState.Mounting: response.MountStatus = NamedPipeMessages.GetStatus.Mounting; + response.MountProgress = this.mountProgressMessage; break; case MountState.Ready: diff --git a/GVFS/GVFS/CommandLine/GVFSVerb.cs b/GVFS/GVFS/CommandLine/GVFSVerb.cs index 069ac1661..427302321 100644 --- a/GVFS/GVFS/CommandLine/GVFSVerb.cs +++ b/GVFS/GVFS/CommandLine/GVFSVerb.cs @@ -198,6 +198,22 @@ protected bool ShowStatusWhileRunning( initialDelayMs: 0); } + protected bool ShowStatusWhileRunning( + Func action, + Func getMessage, + string message, + string gvfsLogEnlistmentRoot) + { + return ConsoleHelper.ShowStatusWhileRunning( + action, + getMessage, + message, + this.Output, + showSpinner: !this.Unattended && this.Output == Console.Out && !GVFSPlatform.Instance.IsConsoleOutputRedirectedToFile(), + gvfsLogEnlistmentRoot: gvfsLogEnlistmentRoot, + initialDelayMs: 0); + } + protected bool ShowStatusWhileRunning( Func action, string message, diff --git a/GVFS/GVFS/CommandLine/MountVerb.cs b/GVFS/GVFS/CommandLine/MountVerb.cs index 37e5f1041..53077bba4 100644 --- a/GVFS/GVFS/CommandLine/MountVerb.cs +++ b/GVFS/GVFS/CommandLine/MountVerb.cs @@ -14,6 +14,7 @@ public class MountVerb : GVFSVerb.ForExistingEnlistment { private const string MountVerbName = "mount"; private Process mountProcess; + private volatile string currentMountProgress; public string Verbosity { get; set; } @@ -197,7 +198,9 @@ protected override void Execute(GVFSEnlistment enlistment) if (!this.ShowStatusWhileRunning( () => { return this.TryMount(tracer, enlistment, mountExecutableLocation, out errorMessage); }, - "Mounting")) + getMessage: () => this.currentMountProgress, + "Mounting", + enlistment.WorkingDirectoryRoot)) { ReturnCode mountExitCode = ReturnCode.GenericError; if (this.mountProcess != null) @@ -277,7 +280,13 @@ private bool TryMount(ITracer tracer, GVFSEnlistment enlistment, string mountExe tracer.RelatedInfo($"{nameof(this.TryMount)}: Waiting for repo to be mounted"); - return GVFSEnlistment.WaitUntilMounted(tracer, enlistment.NamedPipeName, enlistment.WorkingDirectoryRoot, this.Unattended, out errorMessage); + return GVFSEnlistment.WaitUntilMounted( + tracer, + enlistment.NamedPipeName, + enlistment.WorkingDirectoryRoot, + this.Unattended, + out errorMessage, + onProgress: progress => this.currentMountProgress = progress); } private bool RegisterMount(GVFSEnlistment enlistment, out string errorMessage)