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();
+ }
+ }
+}