From 256710fbf630eff2357459de3a3c9549688eebb8 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Thu, 4 Jun 2026 11:04:34 -0700 Subject: [PATCH 1/3] Offload prefetch --commits to mount process for warm auth When a GVFS mount is running, 'gvfs prefetch --commits' now sends a PrefetchCommits request to the mount process via named pipe IPC. The mount process executes the prefetch using its already-warm authentication, avoiding the slow cold-auth path (anonymous probe + git credential helper invocation). If the mount is not running, not ready, or is an older version that does not recognize the PrefetchCommits message, the verb falls back to the existing direct-auth path transparently. Changes: - NamedPipeMessages: Add PrefetchCommits request/response messages - PrefetchStep: Add injectable post-fetch callback to avoid re-entrant named pipe IPC when running inside the mount process - InProcessMount: Add HandlePrefetchCommitsRequest handler that runs PrefetchStep synchronously on the connection thread - PrefetchVerb: Try mount offload before falling back to direct auth - Add PrefetchCommitsOffloadTests functional tests covering mounted, unmounted, idempotent, and remount scenarios Assisted-by: Claude Opus 4.6 Signed-off-by: Tyrie Vella --- GVFS/GVFS.Common/Maintenance/PrefetchStep.cs | 16 +++ .../NamedPipes/NamedPipeMessages.cs | 23 ++++ .../PrefetchCommitsOffloadTests.cs | 128 ++++++++++++++++++ GVFS/GVFS.Mount/InProcessMount.cs | 51 +++++++ GVFS/GVFS/CommandLine/PrefetchVerb.cs | 86 ++++++++++-- 5 files changed, 295 insertions(+), 9 deletions(-) create mode 100644 GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/PrefetchCommitsOffloadTests.cs diff --git a/GVFS/GVFS.Common/Maintenance/PrefetchStep.cs b/GVFS/GVFS.Common/Maintenance/PrefetchStep.cs index 163089afb..4f7ee83d3 100644 --- a/GVFS/GVFS.Common/Maintenance/PrefetchStep.cs +++ b/GVFS/GVFS.Common/Maintenance/PrefetchStep.cs @@ -19,10 +19,18 @@ public class PrefetchStep : GitMaintenanceStep private const int NoExistingPrefetchPacks = -1; private readonly TimeSpan timeBetweenPrefetches = TimeSpan.FromMinutes(70); + private readonly Action> postFetchCallback; + public PrefetchStep(GVFSContext context, GitObjects gitObjects, bool requireCacheLock = true) + : this(context, gitObjects, requireCacheLock, postFetchCallback: null) + { + } + + public PrefetchStep(GVFSContext context, GitObjects gitObjects, bool requireCacheLock, Action> postFetchCallback) : base(context, requireCacheLock) { this.GitObjects = gitObjects; + this.postFetchCallback = postFetchCallback; } public override string Area => "PrefetchStep"; @@ -283,6 +291,14 @@ private void SchedulePostFetchJob(List packIndexes) return; } + // When running inside the mount process, use the injected callback to + // enqueue the post-fetch step directly (avoids re-entrant named pipe IPC). + if (this.postFetchCallback != null) + { + this.postFetchCallback(packIndexes); + return; + } + // We make a best-effort request to run MIDX and commit-graph writes using (NamedPipeClient pipeClient = new NamedPipeClient(this.Context.Enlistment.NamedPipeName)) { diff --git a/GVFS/GVFS.Common/NamedPipes/NamedPipeMessages.cs b/GVFS/GVFS.Common/NamedPipes/NamedPipeMessages.cs index d42c84873..119fa604a 100644 --- a/GVFS/GVFS.Common/NamedPipes/NamedPipeMessages.cs +++ b/GVFS/GVFS.Common/NamedPipes/NamedPipeMessages.cs @@ -313,6 +313,29 @@ public Message CreateMessage() } } + public static class PrefetchCommits + { + public const string Request = "PrefetchCommits"; + public const string CompleteResult = "PrefetchCommitsComplete"; + public const string MountNotReadyResult = "MountNotReady"; + + public class Response + { + public bool Success { get; set; } + public string Error { get; set; } + + public static Response FromMessage(Message message) + { + return GVFSJsonOptions.Deserialize(message.Body); + } + + public Message CreateMessage() + { + return new Message(CompleteResult, GVFSJsonOptions.Serialize(this)); + } + } + } + public static class Notification { public class Request diff --git a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/PrefetchCommitsOffloadTests.cs b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/PrefetchCommitsOffloadTests.cs new file mode 100644 index 000000000..e763761fd --- /dev/null +++ b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/PrefetchCommitsOffloadTests.cs @@ -0,0 +1,128 @@ +using GVFS.FunctionalTests.FileSystemRunners; +using GVFS.FunctionalTests.Should; +using GVFS.FunctionalTests.Tools; +using GVFS.Tests.Should; +using NUnit.Framework; +using System.IO; + +namespace GVFS.FunctionalTests.Tests.EnlistmentPerFixture +{ + [TestFixture] + public class PrefetchCommitsOffloadTests : TestsWithEnlistmentPerFixture + { + private const string PrefetchPackPrefix = "prefetch"; + + private FileSystemRunner fileSystem; + + public PrefetchCommitsOffloadTests() + : base(forcePerRepoObjectCache: true, skipPrefetchDuringClone: true) + { + this.fileSystem = new SystemIORunner(); + } + + private string PackRoot + { + get + { + return this.Enlistment.GetPackRoot(this.fileSystem); + } + } + + [TestCase, Order(1)] + public void PrefetchCommitsMountedUsesOffload() + { + // With the enlistment mounted, prefetch --commits should succeed + // by offloading to the mount process (using its warm auth). + this.Enlistment.Prefetch("--commits"); + this.PostFetchJobShouldComplete(); + + string[] prefetchPacks = this.ReadPrefetchPackFileNames(); + prefetchPacks.Length.ShouldBeAtLeast(1, "There should be at least one prefetch pack after mounted prefetch"); + this.AllPrefetchPacksShouldHaveIdx(prefetchPacks); + } + + [TestCase, Order(2)] + public void PrefetchCommitsMountedIsIdempotent() + { + // Running prefetch --commits again while mounted should succeed + // (may be a no-op if packs are already up to date). + string[] packsBefore = this.ReadPrefetchPackFileNames(); + + this.Enlistment.Prefetch("--commits"); + this.PostFetchJobShouldComplete(); + + string[] packsAfter = this.ReadPrefetchPackFileNames(); + packsAfter.Length.ShouldBeAtLeast(packsBefore.Length, "Pack count should not decrease after idempotent prefetch"); + this.AllPrefetchPacksShouldHaveIdx(packsAfter); + } + + [TestCase, Order(3)] + public void PrefetchCommitsUnmountedFallsBackToDirectAuth() + { + // Unmount, then prefetch --commits should fall back to direct auth + // and still succeed. + this.Enlistment.UnmountGVFS(); + + try + { + this.Enlistment.Prefetch("--commits"); + + string[] prefetchPacks = this.ReadPrefetchPackFileNames(); + prefetchPacks.Length.ShouldBeAtLeast(1, "There should be at least one prefetch pack after unmounted prefetch"); + this.AllPrefetchPacksShouldHaveIdx(prefetchPacks); + } + finally + { + this.Enlistment.MountGVFS(); + } + } + + [TestCase, Order(4)] + public void PrefetchCommitsMountedAfterRemount() + { + // After unmount + remount, prefetch --commits should work via + // the mount process again. + this.Enlistment.UnmountGVFS(); + this.Enlistment.MountGVFS(); + + this.Enlistment.Prefetch("--commits"); + this.PostFetchJobShouldComplete(); + + string[] prefetchPacks = this.ReadPrefetchPackFileNames(); + prefetchPacks.Length.ShouldBeAtLeast(1, "There should be at least one prefetch pack after remount prefetch"); + this.AllPrefetchPacksShouldHaveIdx(prefetchPacks); + } + + private string[] ReadPrefetchPackFileNames() + { + return Directory.GetFiles(this.PackRoot, $"{PrefetchPackPrefix}*.pack"); + } + + private void AllPrefetchPacksShouldHaveIdx(string[] prefetchPacks) + { + foreach (string prefetchPack in prefetchPacks) + { + string idxPath = Path.ChangeExtension(prefetchPack, ".idx"); + idxPath.ShouldBeAFile(this.fileSystem); + } + } + + private void PostFetchJobShouldComplete() + { + string objectDir = this.Enlistment.GetObjectRoot(this.fileSystem); + string postFetchLock = Path.Combine(objectDir, "git-maintenance-step.lock"); + + System.Diagnostics.Stopwatch timeout = System.Diagnostics.Stopwatch.StartNew(); + while (this.fileSystem.FileExists(postFetchLock)) + { + timeout.Elapsed.TotalSeconds.ShouldBeAtMost(60, "Post-fetch lock file was not released within 60 seconds"); + System.Threading.Thread.Sleep(500); + } + + ProcessResult graphResult = GitProcess.InvokeProcess( + this.Enlistment.RepoRoot, + "commit-graph verify --shallow --object-dir=\"" + objectDir + "\""); + graphResult.ExitCode.ShouldEqual(0); + } + } +} diff --git a/GVFS/GVFS.Mount/InProcessMount.cs b/GVFS/GVFS.Mount/InProcessMount.cs index 1272eb876..85857573a 100644 --- a/GVFS/GVFS.Mount/InProcessMount.cs +++ b/GVFS/GVFS.Mount/InProcessMount.cs @@ -516,6 +516,10 @@ private void HandleRequest(ITracer tracer, string request, NamedPipeServer.Conne this.HandleDehydrateFolders(message, connection); break; + case NamedPipeMessages.PrefetchCommits.Request: + this.HandlePrefetchCommitsRequest(connection); + break; + case NamedPipeMessages.HydrationStatus.Request: this.HandleGetHydrationStatusRequest(connection); break; @@ -993,6 +997,53 @@ private void HandlePostFetchJobRequest(NamedPipeMessages.Message message, NamedP connection.TrySendResponse(response.CreateMessage()); } + private void HandlePrefetchCommitsRequest(NamedPipeServer.Connection connection) + { + this.tracer.RelatedInfo("Received prefetch commits request"); + + if (this.currentState != MountState.Ready) + { + connection.TrySendResponse( + new NamedPipeMessages.Message(NamedPipeMessages.PrefetchCommits.MountNotReadyResult, null)); + return; + } + + NamedPipeMessages.PrefetchCommits.Response response; + try + { + // Use a callback to enqueue the post-fetch step directly on the + // maintenance scheduler, avoiding a re-entrant named pipe call. + PrefetchStep prefetchStep = new PrefetchStep( + this.context, + this.gitObjects, + requireCacheLock: false, + postFetchCallback: packIndexes => + { + this.maintenanceScheduler.EnqueueOneTimeStep(new PostFetchStep(this.context, packIndexes)); + }); + + string error; + bool success = prefetchStep.TryPrefetchCommitsAndTrees(out error); + + response = new NamedPipeMessages.PrefetchCommits.Response + { + Success = success, + Error = error, + }; + } + catch (Exception e) + { + this.tracer.RelatedError("HandlePrefetchCommitsRequest: Exception: {0}", e.ToString()); + response = new NamedPipeMessages.PrefetchCommits.Response + { + Success = false, + Error = e.Message, + }; + } + + connection.TrySendResponse(response.CreateMessage()); + } + private void HandleGetStatusRequest(NamedPipeServer.Connection connection) { NamedPipeMessages.GetStatus.Response response = new NamedPipeMessages.GetStatus.Response(); diff --git a/GVFS/GVFS/CommandLine/PrefetchVerb.cs b/GVFS/GVFS/CommandLine/PrefetchVerb.cs index 17483c34c..ad6aa04e1 100644 --- a/GVFS/GVFS/CommandLine/PrefetchVerb.cs +++ b/GVFS/GVFS/CommandLine/PrefetchVerb.cs @@ -3,6 +3,7 @@ using GVFS.Common.Git; using GVFS.Common.Http; using GVFS.Common.Maintenance; +using GVFS.Common.NamedPipes; using GVFS.Common.Prefetch; using GVFS.Common.Tracing; using System; @@ -172,15 +173,25 @@ protected override void Execute(GVFSEnlistment enlistment) this.ReportErrorAndExit(tracer, "You can only specify --hydrate with --files or --folders"); } - GitObjectsHttpRequestor objectRequestor; - CacheServerInfo resolvedCacheServer; - this.InitializeServerConnection( - tracer, - enlistment, - cacheServerFromConfig, - out objectRequestor, - out resolvedCacheServer); - this.PrefetchCommits(tracer, enlistment, objectRequestor, resolvedCacheServer); + // Try offload silently — if mount isn't available this returns + // false quickly and we fall through to the direct-auth path which + // has its own spinner. We don't wrap this in ShowStatusWhileRunning + // because a false return (mount unavailable) would print "Failed" + // to the console, which is misleading for an expected fallback. + bool offloadSucceeded = this.TryPrefetchCommitsViaMountProcess(tracer, enlistment); + + if (!offloadSucceeded) + { + GitObjectsHttpRequestor objectRequestor; + CacheServerInfo resolvedCacheServer; + this.InitializeServerConnection( + tracer, + enlistment, + cacheServerFromConfig, + out objectRequestor, + out resolvedCacheServer); + this.PrefetchCommits(tracer, enlistment, objectRequestor, resolvedCacheServer); + } } else { @@ -296,6 +307,63 @@ private void InitializeServerConnection( objectRequestor = new GitObjectsHttpRequestor(tracer, enlistment, resolvedCacheServer, retryConfig); } + /// + /// Attempts to offload the commit prefetch to a running mount process, + /// which already has warm authentication. Returns true if the mount + /// handled the request (success or failure); returns false if offload + /// is unavailable and the caller should fall back to direct auth. + /// + private bool TryPrefetchCommitsViaMountProcess(ITracer tracer, GVFSEnlistment enlistment) + { + using (NamedPipeClient pipeClient = new NamedPipeClient(enlistment.NamedPipeName)) + { + if (!pipeClient.Connect()) + { + tracer.RelatedInfo("TryPrefetchCommitsViaMountProcess: Mount not running, falling back to direct prefetch"); + return false; + } + + NamedPipeMessages.Message request = new NamedPipeMessages.Message(NamedPipeMessages.PrefetchCommits.Request, null); + if (!pipeClient.TrySendRequest(request)) + { + tracer.RelatedWarning("TryPrefetchCommitsViaMountProcess: Failed to send request, falling back to direct prefetch"); + return false; + } + + NamedPipeMessages.Message response; + if (!pipeClient.TryReadResponse(out response)) + { + tracer.RelatedWarning("TryPrefetchCommitsViaMountProcess: Failed to read response, falling back to direct prefetch"); + return false; + } + + switch (response.Header) + { + case NamedPipeMessages.PrefetchCommits.CompleteResult: + NamedPipeMessages.PrefetchCommits.Response prefetchResponse = + NamedPipeMessages.PrefetchCommits.Response.FromMessage(response); + + if (prefetchResponse.Success) + { + tracer.RelatedInfo("TryPrefetchCommitsViaMountProcess: Mount completed prefetch successfully"); + return true; + } + + this.ReportErrorAndExit(tracer, "Prefetching commits and trees failed (via mount): " + prefetchResponse.Error); + return true; + + case NamedPipeMessages.PrefetchCommits.MountNotReadyResult: + tracer.RelatedInfo("TryPrefetchCommitsViaMountProcess: Mount not ready, falling back to direct prefetch"); + return false; + + default: + // Older mount that doesn't recognize PrefetchCommits + tracer.RelatedInfo("TryPrefetchCommitsViaMountProcess: Unexpected response '{0}', falling back to direct prefetch", response.Header); + return false; + } + } + } + private void PrefetchCommits(ITracer tracer, GVFSEnlistment enlistment, GitObjectsHttpRequestor objectRequestor, CacheServerInfo cacheServer) { bool success; From d17356e60191c251155e910f157aeabbda854faf Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Thu, 4 Jun 2026 14:05:14 -0700 Subject: [PATCH 2/3] Offload blob prefetch to mount process for warm auth Extend the prefetch offload to handle blob prefetch (files/folders), not just commit/tree prefetch. When mounted, 'gvfs prefetch --files' and 'gvfs prefetch --folders' now offload to the mount process using its warm authentication. The noop check (IsNoopPrefetch via LastBlobPrefetch.dat) still runs client-side before attempting offload, so no IPC overhead for no-ops. Changes: - NamedPipeMessages: Add PrefetchBlobs request/response with file/folder lists, commit ID, hydrate flag, and stats in response - InProcessMount: Add HandlePrefetchBlobsRequest handler that creates a fresh GitObjectsHttpRequestor with warm auth, runs BlobPrefetcher with capped thread counts (ProcessorCount/2), validates inputs, and properly disposes HTTP resources - PrefetchVerb: Try blob offload after noop check, fall back to direct auth only when mount is unavailable - Add PrefetchBlobsOffloadTests functional tests covering mounted blobs, stats reporting, unmounted fallback, folder prefetch, and remount Assisted-by: Claude Opus 4.6 Signed-off-by: Tyrie Vella --- .../NamedPipes/NamedPipeMessages.cs | 44 ++++++ .../PrefetchBlobsOffloadTests.cs | 80 +++++++++++ GVFS/GVFS.Mount/InProcessMount.cs | 129 ++++++++++++++++++ GVFS/GVFS/CommandLine/PrefetchVerb.cs | 81 ++++++++++- 4 files changed, 333 insertions(+), 1 deletion(-) create mode 100644 GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/PrefetchBlobsOffloadTests.cs diff --git a/GVFS/GVFS.Common/NamedPipes/NamedPipeMessages.cs b/GVFS/GVFS.Common/NamedPipes/NamedPipeMessages.cs index 119fa604a..121ddf75c 100644 --- a/GVFS/GVFS.Common/NamedPipes/NamedPipeMessages.cs +++ b/GVFS/GVFS.Common/NamedPipes/NamedPipeMessages.cs @@ -336,6 +336,50 @@ public Message CreateMessage() } } + public static class PrefetchBlobs + { + public const string RequestHeader = "PrefetchBlobs"; + public const string CompleteResult = "PrefetchBlobsComplete"; + public const string MountNotReadyResult = "MountNotReady"; + + public class Request + { + public List Files { get; set; } + public List Folders { get; set; } + public string HeadCommitId { get; set; } + public bool HydrateFiles { get; set; } + + public static Request FromMessage(Message message) + { + return GVFSJsonOptions.Deserialize(message.Body); + } + + public Message CreateMessage() + { + return new Message(RequestHeader, GVFSJsonOptions.Serialize(this)); + } + } + + public class Response + { + public bool Success { get; set; } + public string Error { get; set; } + public int MatchedBlobCount { get; set; } + public int DownloadedBlobCount { get; set; } + public int HydratedFileCount { get; set; } + + public static Response FromMessage(Message message) + { + return GVFSJsonOptions.Deserialize(message.Body); + } + + public Message CreateMessage() + { + return new Message(CompleteResult, GVFSJsonOptions.Serialize(this)); + } + } + } + public static class Notification { public class Request diff --git a/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/PrefetchBlobsOffloadTests.cs b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/PrefetchBlobsOffloadTests.cs new file mode 100644 index 000000000..c3fcf977b --- /dev/null +++ b/GVFS/GVFS.FunctionalTests/Tests/EnlistmentPerFixture/PrefetchBlobsOffloadTests.cs @@ -0,0 +1,80 @@ +using GVFS.FunctionalTests.FileSystemRunners; +using GVFS.FunctionalTests.Should; +using GVFS.FunctionalTests.Tools; +using GVFS.Tests.Should; +using NUnit.Framework; +using System.IO; + +namespace GVFS.FunctionalTests.Tests.EnlistmentPerFixture +{ + [TestFixture] + public class PrefetchBlobsOffloadTests : TestsWithEnlistmentPerFixture + { + private FileSystemRunner fileSystem; + + public PrefetchBlobsOffloadTests() + { + this.fileSystem = new SystemIORunner(); + } + + [TestCase, Order(1)] + public void PrefetchBlobsMountedUsesOffload() + { + // With the enlistment mounted, blob prefetch should succeed + // by offloading to the mount process (using its warm auth). + string output = this.Enlistment.Prefetch($"--files {Path.Combine("GVFS", "GVFS", "Program.cs")}"); + output.ShouldContain("Matched blobs:"); + output.ShouldContain("Downloaded:"); + } + + [TestCase, Order(2)] + public void PrefetchBlobsMountedReportsStats() + { + // Prefetch multiple files and verify stats are reported + string output = this.Enlistment.Prefetch( + $"--files {Path.Combine("GVFS", "GVFS", "Program.cs")};{Path.Combine("GVFS", "GVFS.FunctionalTests", "GVFS.FunctionalTests.csproj")}"); + output.ShouldContain("Matched blobs:"); + output.ShouldContain("Already cached:"); + output.ShouldContain("Downloaded:"); + } + + [TestCase, Order(3)] + public void PrefetchBlobsUnmountedFallsBackToDirectAuth() + { + // Unmount, then blob prefetch should fall back to direct auth + // and still succeed. + this.Enlistment.UnmountGVFS(); + + try + { + string output = this.Enlistment.Prefetch($"--files {Path.Combine("GVFS", "GVFS", "Program.cs")}"); + output.ShouldContain("Matched blobs:"); + output.ShouldContain("Downloaded:"); + } + finally + { + this.Enlistment.MountGVFS(); + } + } + + [TestCase, Order(4)] + public void PrefetchBlobsMountedWithFolders() + { + // Prefetch a folder while mounted + string output = this.Enlistment.Prefetch("--folders GVFS/GVFS"); + output.ShouldContain("Matched blobs:"); + } + + [TestCase, Order(5)] + public void PrefetchBlobsMountedAfterRemount() + { + // After unmount + remount, blob prefetch should work via + // the mount process again. + this.Enlistment.UnmountGVFS(); + this.Enlistment.MountGVFS(); + + string output = this.Enlistment.Prefetch($"--files {Path.Combine("GVFS", "GVFS", "Program.cs")}"); + output.ShouldContain("Matched blobs:"); + } + } +} diff --git a/GVFS/GVFS.Mount/InProcessMount.cs b/GVFS/GVFS.Mount/InProcessMount.cs index 85857573a..273c31df7 100644 --- a/GVFS/GVFS.Mount/InProcessMount.cs +++ b/GVFS/GVFS.Mount/InProcessMount.cs @@ -5,6 +5,7 @@ using GVFS.Common.Http; using GVFS.Common.Maintenance; using GVFS.Common.NamedPipes; +using GVFS.Common.Prefetch; using GVFS.Common.Tracing; using GVFS.PlatformLoader; using GVFS.Virtualization; @@ -520,6 +521,10 @@ private void HandleRequest(ITracer tracer, string request, NamedPipeServer.Conne this.HandlePrefetchCommitsRequest(connection); break; + case NamedPipeMessages.PrefetchBlobs.RequestHeader: + this.HandlePrefetchBlobsRequest(message, connection); + break; + case NamedPipeMessages.HydrationStatus.Request: this.HandleGetHydrationStatusRequest(connection); break; @@ -1044,6 +1049,130 @@ private void HandlePrefetchCommitsRequest(NamedPipeServer.Connection connection) connection.TrySendResponse(response.CreateMessage()); } + private void HandlePrefetchBlobsRequest(NamedPipeMessages.Message message, NamedPipeServer.Connection connection) + { + this.tracer.RelatedInfo("Received prefetch blobs request"); + + if (this.currentState != MountState.Ready) + { + connection.TrySendResponse( + new NamedPipeMessages.Message(NamedPipeMessages.PrefetchBlobs.MountNotReadyResult, null)); + return; + } + + NamedPipeMessages.PrefetchBlobs.Request request = NamedPipeMessages.PrefetchBlobs.Request.FromMessage(message); + + // Validate inputs — do not trust IPC requests blindly + if (request.Files == null || request.Folders == null) + { + connection.TrySendResponse(new NamedPipeMessages.PrefetchBlobs.Response + { + Success = false, + Error = "Files and Folders must not be null", + }.CreateMessage()); + return; + } + + if (request.Files.Count == 0 && request.Folders.Count == 0) + { + connection.TrySendResponse(new NamedPipeMessages.PrefetchBlobs.Response + { + Success = false, + Error = "Files and Folders must not both be empty", + }.CreateMessage()); + return; + } + + if (string.IsNullOrWhiteSpace(request.HeadCommitId)) + { + connection.TrySendResponse(new NamedPipeMessages.PrefetchBlobs.Response + { + Success = false, + Error = "HeadCommitId must be specified", + }.CreateMessage()); + return; + } + + NamedPipeMessages.PrefetchBlobs.Response response; + try + { + // Create a fresh GitObjectsHttpRequestor using the mount's warm auth. + // BlobPrefetcher constructs its own PrefetchGitObjects internally. + using (GitObjectsHttpRequestor objectRequestor = new GitObjectsHttpRequestor( + this.tracer, this.enlistment, this.cacheServer, this.retryConfig)) + { + // Open LastBlobPrefetch.dat so BlobPrefetcher can update noop state + string lastPrefetchPath = Path.Combine(this.enlistment.DotGVFSRoot, "LastBlobPrefetch.dat"); + FileBasedDictionary lastPrefetchArgs; + string dictError; + if (!FileBasedDictionary.TryCreate( + this.tracer, lastPrefetchPath, new PhysicalFileSystem(), + out lastPrefetchArgs, out dictError)) + { + this.tracer.RelatedWarning("HandlePrefetchBlobsRequest: Unable to load last prefetch args: " + dictError); + lastPrefetchArgs = null; + } + + // Cap thread counts to avoid starving virtualization callbacks + int maxThreads = Math.Max(1, Environment.ProcessorCount / 2); + int downloadThreads = Math.Min(maxThreads, 16); + + BlobPrefetcher blobPrefetcher = new BlobPrefetcher( + this.tracer, + this.enlistment, + objectRequestor, + request.Files, + request.Folders, + lastPrefetchArgs, + chunkSize: 4000, + searchThreadCount: maxThreads, + downloadThreadCount: downloadThreads, + indexThreadCount: maxThreads); + + int matchedBlobCount; + int downloadedBlobCount; + int hydratedFileCount; + + blobPrefetcher.PrefetchWithStats( + request.HeadCommitId, + isBranch: false, + hydrateFilesAfterDownload: request.HydrateFiles, + matchedBlobCount: out matchedBlobCount, + downloadedBlobCount: out downloadedBlobCount, + hydratedFileCount: out hydratedFileCount); + + response = new NamedPipeMessages.PrefetchBlobs.Response + { + Success = !blobPrefetcher.HasFailures, + Error = blobPrefetcher.HasFailures ? "Blob prefetch encountered failures" : null, + MatchedBlobCount = matchedBlobCount, + DownloadedBlobCount = downloadedBlobCount, + HydratedFileCount = hydratedFileCount, + }; + } + } + catch (BlobPrefetcher.FetchException e) + { + this.tracer.RelatedError("HandlePrefetchBlobsRequest: FetchException: {0}", e.Message); + response = new NamedPipeMessages.PrefetchBlobs.Response + { + Success = false, + Error = e.Message, + }; + } + catch (Exception e) + { + this.tracer.RelatedError("HandlePrefetchBlobsRequest: Exception: {0}", e.ToString()); + response = new NamedPipeMessages.PrefetchBlobs.Response + { + Success = false, + Error = e.Message, + }; + } + + connection.TrySendResponse(response.CreateMessage()); + } + private void HandleGetStatusRequest(NamedPipeServer.Connection connection) { NamedPipeMessages.GetStatus.Response response = new NamedPipeMessages.GetStatus.Response(); diff --git a/GVFS/GVFS/CommandLine/PrefetchVerb.cs b/GVFS/GVFS/CommandLine/PrefetchVerb.cs index ad6aa04e1..6c584c182 100644 --- a/GVFS/GVFS/CommandLine/PrefetchVerb.cs +++ b/GVFS/GVFS/CommandLine/PrefetchVerb.cs @@ -206,7 +206,7 @@ protected override void Execute(GVFSEnlistment enlistment) { Console.WriteLine("All requested files are already available. Nothing new to prefetch."); } - else + else if (!this.TryPrefetchBlobsViaMountProcess(tracer, enlistment, filesList, foldersList, headCommitId)) { GitObjectsHttpRequestor objectRequestor; CacheServerInfo resolvedCacheServer; @@ -364,6 +364,85 @@ private bool TryPrefetchCommitsViaMountProcess(ITracer tracer, GVFSEnlistment en } } + /// + /// Attempts to offload the blob prefetch to a running mount process, + /// which already has warm authentication. Returns true if the mount + /// handled the request (success or failure); returns false if offload + /// is unavailable and the caller should fall back to direct auth. + /// + private bool TryPrefetchBlobsViaMountProcess( + ITracer tracer, + GVFSEnlistment enlistment, + List filesList, + List foldersList, + string headCommitId) + { + using (NamedPipeClient pipeClient = new NamedPipeClient(enlistment.NamedPipeName)) + { + if (!pipeClient.Connect()) + { + tracer.RelatedInfo("TryPrefetchBlobsViaMountProcess: Mount not running, falling back to direct prefetch"); + return false; + } + + NamedPipeMessages.PrefetchBlobs.Request request = new NamedPipeMessages.PrefetchBlobs.Request + { + Files = filesList, + Folders = foldersList, + HeadCommitId = headCommitId, + HydrateFiles = this.HydrateFiles, + }; + + if (!pipeClient.TrySendRequest(request.CreateMessage())) + { + tracer.RelatedWarning("TryPrefetchBlobsViaMountProcess: Failed to send request, falling back to direct prefetch"); + return false; + } + + NamedPipeMessages.Message response; + if (!pipeClient.TryReadResponse(out response)) + { + tracer.RelatedWarning("TryPrefetchBlobsViaMountProcess: Failed to read response, falling back to direct prefetch"); + return false; + } + + switch (response.Header) + { + case NamedPipeMessages.PrefetchBlobs.CompleteResult: + NamedPipeMessages.PrefetchBlobs.Response blobResponse = + NamedPipeMessages.PrefetchBlobs.Response.FromMessage(response); + + if (blobResponse.Success) + { + tracer.RelatedInfo("TryPrefetchBlobsViaMountProcess: Mount completed blob prefetch successfully"); + + Console.WriteLine(); + Console.WriteLine("Stats:"); + Console.WriteLine(" Matched blobs: " + blobResponse.MatchedBlobCount); + Console.WriteLine(" Already cached: " + (blobResponse.MatchedBlobCount - blobResponse.DownloadedBlobCount)); + Console.WriteLine(" Downloaded: " + blobResponse.DownloadedBlobCount); + if (this.HydrateFiles) + { + Console.WriteLine(" Hydrated files: " + blobResponse.HydratedFileCount); + } + + return true; + } + + this.ReportErrorAndExit(tracer, "Prefetching blobs failed (via mount): " + blobResponse.Error); + return true; + + case NamedPipeMessages.PrefetchBlobs.MountNotReadyResult: + tracer.RelatedInfo("TryPrefetchBlobsViaMountProcess: Mount not ready, falling back to direct prefetch"); + return false; + + default: + tracer.RelatedInfo("TryPrefetchBlobsViaMountProcess: Unexpected response '{0}', falling back to direct prefetch", response.Header); + return false; + } + } + } + private void PrefetchCommits(ITracer tracer, GVFSEnlistment enlistment, GitObjectsHttpRequestor objectRequestor, CacheServerInfo cacheServer) { bool success; From 67464d27f397a563f004d6e0c630d8ed9dc6ac60 Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Fri, 5 Jun 2026 10:30:04 -0700 Subject: [PATCH 3/3] Skip blob offload when --hydrate is set Hydration writes to the ProjFS-virtualized working directory, which can trigger callbacks back into the mount process. Skip the offload path for --hydrate and use the direct-auth path instead, where the verb process handles hydration safely from outside the mount. Assisted-by: Claude Opus 4.6 Signed-off-by: Tyrie Vella --- GVFS/GVFS/CommandLine/PrefetchVerb.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/GVFS/GVFS/CommandLine/PrefetchVerb.cs b/GVFS/GVFS/CommandLine/PrefetchVerb.cs index 6c584c182..ec1d42813 100644 --- a/GVFS/GVFS/CommandLine/PrefetchVerb.cs +++ b/GVFS/GVFS/CommandLine/PrefetchVerb.cs @@ -206,7 +206,7 @@ protected override void Execute(GVFSEnlistment enlistment) { Console.WriteLine("All requested files are already available. Nothing new to prefetch."); } - else if (!this.TryPrefetchBlobsViaMountProcess(tracer, enlistment, filesList, foldersList, headCommitId)) + else if (this.HydrateFiles || !this.TryPrefetchBlobsViaMountProcess(tracer, enlistment, filesList, foldersList, headCommitId)) { GitObjectsHttpRequestor objectRequestor; CacheServerInfo resolvedCacheServer;