From 4ef689313b02fa07078e94a14f1b0eb7f92aaa0f Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Fri, 15 May 2026 11:50:49 -0700 Subject: [PATCH] Apply staged upgrade when mount processes exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The installer replaces GVFS.Service.exe in-place even during a staged upgrade, so the running service is always the new version. Add a PendingUpgradeMonitor that registers Process.Exited callbacks on GVFS.Mount processes and applies the staged upgrade once all have exited. This is event-driven (no polling) and works regardless of which version of gvfs.exe triggered the unmount. Previously only 'gvfs unmount' triggered the upgrade check (via UnregisterRepoRequest). 'gvfs service --unmount-all' sets SkipUnregister=true to preserve automount registration, so no message reached the service and staged upgrades sat in PendingUpgrade\ indefinitely. Changes: - PendingUpgradeHandler: return UpgradeResult enum instead of void, add concurrency lock, add IsPending() helper - PendingUpgradeMonitor: new class — monitors mount process exits, debounces (5s), retries on new mounts, self-stops on completion - GVFSService: start monitor when startup upgrade is deferred, dispose on shutdown - upgrade-tests.yaml: add unmount-all-triggers-upgrade CI scenario Assisted-by: Claude Opus 4.6 Signed-off-by: Tyrie Vella --- .github/workflows/upgrade-tests.yaml | 31 +++ GVFS/GVFS.Service/GVFSService.Windows.cs | 17 +- GVFS/GVFS.Service/Handlers/RequestHandler.cs | 2 +- GVFS/GVFS.Service/PendingUpgradeHandler.cs | 115 ++++++-- GVFS/GVFS.Service/PendingUpgradeMonitor.cs | 266 +++++++++++++++++++ 5 files changed, 409 insertions(+), 22 deletions(-) create mode 100644 GVFS/GVFS.Service/PendingUpgradeMonitor.cs diff --git a/.github/workflows/upgrade-tests.yaml b/.github/workflows/upgrade-tests.yaml index 44eaaac94..a398d6666 100644 --- a/.github/workflows/upgrade-tests.yaml +++ b/.github/workflows/upgrade-tests.yaml @@ -33,6 +33,7 @@ jobs: - double-staging - staging-then-clean - mount-safety-deferral + - unmount-all-triggers-upgrade fail-fast: false steps: @@ -274,6 +275,36 @@ jobs: Write-Host "PASS: Mount safety deferral works correctly" } + "unmount-all-triggers-upgrade" { + Write-Host "=== Scenario: unmount-all triggers staged upgrade ===" + # Install LKG, mount, staging upgrade with new installer (which + # replaces GVFS.Service.exe in-place with the new version that + # includes PendingUpgradeMonitor). Then unmount via --unmount-all. + # The new service monitors mount process exits and applies the + # upgrade automatically — no pipe message from gvfs.exe needed. + Install-GVFS $lkgInstaller + Assert-ServiceRunning + $mountPid = Mount-TestRepo + + Install-GVFS $newInstaller @("/STAGEIFMOUNTED=true") + Assert-MountAlive $mountPid + Assert-PendingUpgrade $true + + # Unmount via --unmount-all (uses LKG gvfs.exe — no new pipe msg) + & "$installDir\gvfs.exe" service --unmount-all 2>&1 | Write-Host + if ($LASTEXITCODE -ne 0) { throw "unmount-all failed" } + + # The monitor's debounce timer fires 5s after the last mount + # process exits, then applies the upgrade. Wait for completion. + $deadline = (Get-Date).AddSeconds(30) + while ((Test-Path "$installDir\PendingUpgrade") -and (Get-Date) -lt $deadline) { + Start-Sleep -Seconds 2 + } + + Assert-PendingUpgrade $false + Write-Host "PASS: unmount-all triggers staged upgrade via process monitor" + } + default { throw "Unknown scenario: ${{ matrix.scenario }}" } diff --git a/GVFS/GVFS.Service/GVFSService.Windows.cs b/GVFS/GVFS.Service/GVFSService.Windows.cs index bedfa9a7e..72a313a9d 100644 --- a/GVFS/GVFS.Service/GVFSService.Windows.cs +++ b/GVFS/GVFS.Service/GVFSService.Windows.cs @@ -26,6 +26,7 @@ public class GVFSService : ServiceBase private RepoRegistry repoRegistry; private WindowsRequestHandler requestHandler; private INotificationHandler notificationHandler; + private PendingUpgradeMonitor pendingUpgradeMonitor; public GVFSService(JsonTracer tracer) { @@ -46,8 +47,14 @@ public void Run() // Check for a staged upgrade before doing anything else. // If no GVFS.Mount processes are running (typical at boot or after // unmount-all), copy staged files in-place and proceed normally. - // If mounts ARE running, the upgrade is deferred to next restart. - PendingUpgradeHandler.TryApplyPendingUpgrade(this.tracer); + // If mounts ARE running, start a monitor that will apply the + // upgrade when all mount processes exit. + UpgradeResult upgradeResult = PendingUpgradeHandler.TryApplyPendingUpgrade(this.tracer); + if (upgradeResult == UpgradeResult.DeferredMountsRunning) + { + this.pendingUpgradeMonitor = new PendingUpgradeMonitor(this.tracer); + this.pendingUpgradeMonitor.Start(); + } this.repoRegistry = new RepoRegistry( this.tracer, @@ -99,6 +106,12 @@ public void StopRunning() this.tracer.RelatedInfo("Stopping"); } + if (this.pendingUpgradeMonitor != null) + { + this.pendingUpgradeMonitor.Dispose(); + this.pendingUpgradeMonitor = null; + } + if (this.serviceStopped != null) { this.serviceStopped.Set(); diff --git a/GVFS/GVFS.Service/Handlers/RequestHandler.cs b/GVFS/GVFS.Service/Handlers/RequestHandler.cs index 4d665c416..8f0447b52 100644 --- a/GVFS/GVFS.Service/Handlers/RequestHandler.cs +++ b/GVFS/GVFS.Service/Handlers/RequestHandler.cs @@ -15,7 +15,7 @@ namespace GVFS.Service.Handlers /// public class RequestHandler { - private const int PendingUpgradeDelayMs = 5000; + private const int PendingUpgradeDelayMs = 2000; protected const string EnableProjFSRequestDescription = "attach volume"; protected string requestDescription; diff --git a/GVFS/GVFS.Service/PendingUpgradeHandler.cs b/GVFS/GVFS.Service/PendingUpgradeHandler.cs index 6bfabece4..c2701be23 100644 --- a/GVFS/GVFS.Service/PendingUpgradeHandler.cs +++ b/GVFS/GVFS.Service/PendingUpgradeHandler.cs @@ -14,8 +14,9 @@ namespace GVFS.Service /// When the installer runs with mounts active, it stages new files to /// {installDir}\PendingUpgrade\ instead of replacing files in-place. /// This class applies the upgrade when no GVFS.Mount processes are - /// running — either on service start (before automount) or after a - /// repo unmount (via deferred check from RequestHandler). + /// running — either on service start (before automount), after a + /// repo unmount (via deferred check from RequestHandler), or when + /// PendingUpgradeMonitor detects all mount processes have exited. /// /// 1. Move old files from install dir → PreviousVersion\ /// 2. Move new files from PendingUpgrade\ → install dir @@ -36,6 +37,9 @@ public static class PendingUpgradeHandler private const string Phase1CompleteMarkerFileName = ".phase1-complete"; private const string ServiceExeName = "GVFS.Service.exe"; private const string MountProcessName = "GVFS.Mount"; + private const string MountExeName = "GVFS.Mount.exe"; + + private static readonly object ApplyLock = new object(); // Executables that users or the service can launch to start new // mount/hook processes. During upgrade these are moved out first @@ -53,7 +57,78 @@ public static class PendingUpgradeHandler /// /// Checks for and applies a pending staged upgrade. /// - public static void TryApplyPendingUpgrade(ITracer tracer) + public static UpgradeResult TryApplyPendingUpgrade(ITracer tracer) + { + lock (ApplyLock) + { + return TryApplyPendingUpgradeLocked(tracer); + } + } + + /// + /// Returns true if a PendingUpgrade directory with a .ready marker exists. + /// + public static bool IsPending() + { + string pendingUpgradeDir = Path.Combine(Configuration.AssemblyPath, PendingUpgradeDirectoryName); + if (!Directory.Exists(pendingUpgradeDir)) + { + return false; + } + + string readyMarker = Path.Combine(pendingUpgradeDir, ReadyMarkerFileName); + return File.Exists(readyMarker); + } + + /// + /// Returns GVFS.Mount processes whose executable is in the install + /// directory. Processes from dev builds or other installs are excluded + /// so they don't block upgrades of the system install. If a process's + /// path cannot be read (access denied, 32/64-bit mismatch), it is + /// included conservatively. + /// Caller must dispose the returned Process objects. + /// + public static List GetInstalledMountProcesses(ITracer tracer) + { + string installDir = Configuration.AssemblyPath; + string expectedPath = Path.Combine(installDir, MountExeName); + Process[] allMountProcesses = Process.GetProcessesByName(MountProcessName); + List installed = new List(); + + foreach (Process process in allMountProcesses) + { + bool include = true; + try + { + string processPath = process.MainModule?.FileName; + if (processPath != null && + !PathComparer.Equals(processPath, expectedPath)) + { + include = false; + tracer.RelatedInfo( + $"{nameof(PendingUpgradeHandler)}: Skipping GVFS.Mount PID {process.Id} " + + $"(path: {processPath}, not in install dir)"); + } + } + catch (Exception) + { + // Access denied or process exited — include conservatively + } + + if (include) + { + installed.Add(process); + } + else + { + process.Dispose(); + } + } + + return installed; + } + + private static UpgradeResult TryApplyPendingUpgradeLocked(ITracer tracer) { string installDir = Configuration.AssemblyPath; string pendingUpgradeDir = Path.Combine(installDir, PendingUpgradeDirectoryName); @@ -61,14 +136,10 @@ public static void TryApplyPendingUpgrade(ITracer tracer) if (!Directory.Exists(pendingUpgradeDir)) { - // No pending upgrade. Clean up PreviousVersion if it exists - // (leftover from a completed upgrade where cleanup was interrupted). TryDeleteDirectory(tracer, previousVersionDir, "leftover PreviousVersion"); - return; + return UpgradeResult.NoPending; } - // Installer writes .ready marker as its last step. If missing, - // the installer was interrupted mid-write — don't apply partial files. string readyMarker = Path.Combine(pendingUpgradeDir, ReadyMarkerFileName); if (!File.Exists(readyMarker)) { @@ -79,28 +150,25 @@ public static void TryApplyPendingUpgrade(ITracer tracer) $"{nameof(PendingUpgradeHandler)}: PendingUpgrade directory exists but {ReadyMarkerFileName} marker " + "is missing — installer was likely interrupted. Skipping until next install completes.", Keywords.Telemetry); - return; + return UpgradeResult.NotReady; } tracer.RelatedInfo($"{nameof(PendingUpgradeHandler)}: Pending upgrade detected at {pendingUpgradeDir}"); - // Don't apply if GVFS.Mount processes are still running — their - // executables are locked and moves would fail. Upgrade will be - // retried on next service start when no mounts are active. - Process[] mountProcesses = Array.Empty(); + List mountProcesses = new List(); try { - mountProcesses = Process.GetProcessesByName(MountProcessName); - if (mountProcesses.Length > 0) + mountProcesses = GetInstalledMountProcesses(tracer); + if (mountProcesses.Count > 0) { EventMetadata deferMetadata = new EventMetadata(); - deferMetadata.Add("MountProcessCount", mountProcesses.Length); + deferMetadata.Add("MountProcessCount", mountProcesses.Count); tracer.RelatedEvent( EventLevel.Informational, $"{nameof(PendingUpgradeHandler)}_Deferred", deferMetadata, Keywords.Telemetry); - return; + return UpgradeResult.DeferredMountsRunning; } } finally @@ -217,7 +285,7 @@ public static void TryApplyPendingUpgrade(ITracer tracer) $"{nameof(PendingUpgradeHandler)}_Complete", successMetadata, Keywords.Telemetry); - return; + return UpgradeResult.Applied; } catch (Exception ex) { @@ -229,7 +297,7 @@ public static void TryApplyPendingUpgrade(ITracer tracer) "PendingUpgrade retained for retry on next service start. " + "If PreviousVersion exists, old files are preserved for manual recovery.", Keywords.Telemetry); - return; + return UpgradeResult.Failed; } } @@ -440,4 +508,13 @@ private static void TryDeleteDirectory(ITracer tracer, string path, string descr } } } + + public enum UpgradeResult + { + NoPending, + Applied, + DeferredMountsRunning, + NotReady, + Failed, + } } diff --git a/GVFS/GVFS.Service/PendingUpgradeMonitor.cs b/GVFS/GVFS.Service/PendingUpgradeMonitor.cs new file mode 100644 index 000000000..fd9e07d00 --- /dev/null +++ b/GVFS/GVFS.Service/PendingUpgradeMonitor.cs @@ -0,0 +1,266 @@ +using GVFS.Common; +using GVFS.Common.Tracing; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.Threading; + +namespace GVFS.Service +{ + /// + /// Monitors GVFS.Mount process exits and applies staged upgrades when + /// all mount processes have exited. Event-driven — no polling. + /// + /// The installer always replaces GVFS.Service.exe in-place, so this + /// monitor runs as part of the new service version regardless of what + /// version of gvfs.exe the user has. This solves the bootstrap problem + /// where old gvfs.exe clients cannot send new pipe messages. + /// + /// Lock ordering: syncLock is never held when calling into + /// PendingUpgradeHandler (which acquires its own ApplyLock). + /// TryApplyPendingUpgrade is called outside syncLock, then syncLock + /// is re-acquired to act on the result. + /// + public sealed class PendingUpgradeMonitor : IDisposable + { + private const int DebouncePeriodMs = 1000; + + private readonly ITracer tracer; + private readonly object syncLock = new object(); + private List trackedProcesses = new List(); + private Timer debounceTimer; + private bool disposed; + + public PendingUpgradeMonitor(ITracer tracer) + { + this.tracer = tracer; + } + + /// + /// Begin monitoring GVFS.Mount processes. When all exit, attempts + /// to apply the pending upgrade. If new mounts start in the + /// meantime, re-registers on those. + /// + public void Start() + { + this.tracer.RelatedInfo($"{nameof(PendingUpgradeMonitor)}: Starting mount process monitor for pending upgrade"); + this.RegisterOnMountProcesses(); + } + + public void Dispose() + { + lock (this.syncLock) + { + if (this.disposed) + { + return; + } + + this.disposed = true; + this.CleanupTrackedProcesses(); + + if (this.debounceTimer != null) + { + this.debounceTimer.Dispose(); + this.debounceTimer = null; + } + } + } + + private void RegisterOnMountProcesses() + { + lock (this.syncLock) + { + if (this.disposed) + { + return; + } + + this.CleanupTrackedProcesses(); + + List mountProcesses; + try + { + mountProcesses = PendingUpgradeHandler.GetInstalledMountProcesses(this.tracer); + } + catch (Exception ex) + { + this.tracer.RelatedWarning( + $"{nameof(PendingUpgradeMonitor)}: Failed to enumerate mount processes: {ex.Message}"); + return; + } + + if (mountProcesses.Count == 0) + { + this.tracer.RelatedInfo( + $"{nameof(PendingUpgradeMonitor)}: No mount processes found, scheduling upgrade check"); + this.ScheduleDebouncedCheck(); + return; + } + + this.tracer.RelatedInfo( + $"{nameof(PendingUpgradeMonitor)}: Monitoring {mountProcesses.Count} mount process(es) for exit"); + + bool anyAlive = false; + foreach (Process process in mountProcesses) + { + bool added = false; + try + { + process.EnableRaisingEvents = true; + process.Exited += this.OnMountProcessExited; + this.trackedProcesses.Add(process); + added = true; + + if (process.HasExited) + { + this.ScheduleDebouncedCheck(); + } + else + { + anyAlive = true; + } + } + catch (InvalidOperationException) + { + if (!added) + { + process.Dispose(); + } + + this.ScheduleDebouncedCheck(); + } + catch (Win32Exception ex) + { + this.tracer.RelatedWarning( + $"{nameof(PendingUpgradeMonitor)}: Cannot monitor PID {process.Id}: {ex.Message}"); + if (!added) + { + process.Dispose(); + } + } + } + + if (!anyAlive) + { + this.ScheduleDebouncedCheck(); + } + } + } + + private void OnMountProcessExited(object sender, EventArgs e) + { + Process exitedProcess = sender as Process; + int pid = 0; + try + { + pid = exitedProcess?.Id ?? 0; + } + catch (InvalidOperationException) + { + } + + this.tracer.RelatedInfo( + $"{nameof(PendingUpgradeMonitor)}: Mount process exited (PID {pid})"); + + lock (this.syncLock) + { + this.ScheduleDebouncedCheck(); + } + } + + /// + /// Must be called while holding syncLock. + /// + private void ScheduleDebouncedCheck() + { + if (this.disposed) + { + return; + } + + if (this.debounceTimer == null) + { + this.debounceTimer = new Timer( + this.OnDebounceTimerFired, + null, + DebouncePeriodMs, + Timeout.Infinite); + } + else + { + this.debounceTimer.Change(DebouncePeriodMs, Timeout.Infinite); + } + } + + private void OnDebounceTimerFired(object state) + { + lock (this.syncLock) + { + if (this.disposed) + { + return; + } + } + + this.tracer.RelatedInfo( + $"{nameof(PendingUpgradeMonitor)}: Checking pending upgrade after mount process exit"); + + UpgradeResult result = PendingUpgradeHandler.TryApplyPendingUpgrade(this.tracer); + + lock (this.syncLock) + { + if (this.disposed) + { + return; + } + + switch (result) + { + case UpgradeResult.DeferredMountsRunning: + this.tracer.RelatedInfo( + $"{nameof(PendingUpgradeMonitor)}: New mounts detected, re-registering"); + this.RegisterOnMountProcesses(); + break; + + case UpgradeResult.Applied: + this.tracer.RelatedInfo( + $"{nameof(PendingUpgradeMonitor)}: Upgrade applied successfully, stopping monitor"); + this.CleanupTrackedProcesses(); + break; + + case UpgradeResult.NoPending: + this.tracer.RelatedInfo( + $"{nameof(PendingUpgradeMonitor)}: No pending upgrade, stopping monitor"); + this.CleanupTrackedProcesses(); + break; + + default: + this.tracer.RelatedWarning( + $"{nameof(PendingUpgradeMonitor)}: Upgrade returned {result}, stopping monitor"); + this.CleanupTrackedProcesses(); + break; + } + } + } + + private void CleanupTrackedProcesses() + { + foreach (Process process in this.trackedProcesses) + { + try + { + process.Exited -= this.OnMountProcessExited; + } + catch (Exception) + { + } + + process.Dispose(); + } + + this.trackedProcesses.Clear(); + } + } +}