diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
index 1aaa7ba42..e517316ea 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.53.0.vfs.0.7' }}
+ GIT_VERSION: ${{ github.event.inputs.git_version || 'v2.54.0.vfs.0.2' }}
jobs:
validate:
diff --git a/GVFS/GVFS.Common/CommandLineEscaping.cs b/GVFS/GVFS.Common/CommandLineEscaping.cs
new file mode 100644
index 000000000..e902f0363
--- /dev/null
+++ b/GVFS/GVFS.Common/CommandLineEscaping.cs
@@ -0,0 +1,105 @@
+using System.Text;
+
+namespace GVFS.Common
+{
+ ///
+ /// Windows command-line argument escaping per the rules used by
+ /// CommandLineToArgvW and the Microsoft C runtime.
+ ///
+ ///
+ ///
+ /// When the service spawns a child process via CreateProcessAsUser,
+ /// or any code path builds a lpCommandLine string, the receiving
+ /// process re-parses that string into argv. Embedded
+ /// " characters that aren't escaped get stripped, which
+ /// silently corrupts JSON payloads (e.g. --internal_use_only) and
+ /// any other argument that contains quotes. System.Text.Json (unlike
+ /// Newtonsoft.Json) is strict about quoted property names, so the
+ /// corruption now manifests as a hard failure.
+ ///
+ ///
+ /// The escaping rules (see
+ /// ):
+ ///
+ ///
+ /// - Arguments are separated by whitespace (space or tab).
+ /// - A string surrounded by " is treated as one argument
+ /// even if it contains whitespace.
+ /// - \" is interpreted as a literal ".
+ /// - Backslashes are literal unless they immediately
+ /// precede a ": then 2n backslashes become n
+ /// backslashes followed by a quote terminator, and 2n+1
+ /// backslashes become n backslashes followed by a literal
+ /// ".
+ ///
+ ///
+ public static class CommandLineEscaping
+ {
+ ///
+ /// Escapes a single argument for inclusion in a Windows command line
+ /// that will be parsed by CommandLineToArgvW (the default
+ /// parser used by the CRT and .NET).
+ ///
+ /// The raw argument value.
+ ///
+ /// The escaped argument, including surrounding quotes when needed.
+ /// Always quotes when the argument is empty or contains a space,
+ /// tab, double-quote, or is otherwise ambiguous to the parser.
+ ///
+ public static string EscapeArgument(string argument)
+ {
+ if (argument == null)
+ {
+ return "\"\"";
+ }
+
+ if (argument.Length > 0 && argument.IndexOfAny(CharactersThatRequireQuoting) < 0)
+ {
+ return argument;
+ }
+
+ StringBuilder builder = new StringBuilder(argument.Length + 2);
+ builder.Append('"');
+
+ int i = 0;
+ while (i < argument.Length)
+ {
+ int backslashes = 0;
+ while (i < argument.Length && argument[i] == '\\')
+ {
+ backslashes++;
+ i++;
+ }
+
+ if (i == argument.Length)
+ {
+ // Backslashes at the end of the argument: double them so
+ // they don't escape the closing quote we're about to add.
+ builder.Append('\\', backslashes * 2);
+ break;
+ }
+
+ if (argument[i] == '"')
+ {
+ // Backslashes preceding a literal quote: double them, then
+ // emit \" for the literal quote itself.
+ builder.Append('\\', backslashes * 2 + 1);
+ builder.Append('"');
+ }
+ else
+ {
+ // Backslashes not followed by a quote: emit verbatim.
+ builder.Append('\\', backslashes);
+ builder.Append(argument[i]);
+ }
+
+ i++;
+ }
+
+ builder.Append('"');
+ return builder.ToString();
+ }
+
+ private static readonly char[] CharactersThatRequireQuoting = new[] { ' ', '\t', '"' };
+ }
+}
diff --git a/GVFS/GVFS.Common/GVFSEnlistment.cs b/GVFS/GVFS.Common/GVFSEnlistment.cs
index 286b32037..457e3775b 100644
--- a/GVFS/GVFS.Common/GVFSEnlistment.cs
+++ b/GVFS/GVFS.Common/GVFSEnlistment.cs
@@ -219,22 +219,48 @@ public static bool WaitUntilMounted(ITracer tracer, string enlistmentRoot, bool
}
public static bool WaitUntilMounted(ITracer tracer, string pipeName, string enlistmentRoot, bool unattended, out string errorMessage)
+ {
+ return WaitUntilMounted(tracer, pipeName, enlistmentRoot, unattended, mountProcessStatus: null, out errorMessage);
+ }
+
+ ///
+ /// Waits for the GVFS.Mount process to come up and signal readiness
+ /// over its named pipe.
+ ///
+ ///
+ /// Optional snapshot delegate. When provided, the wait loop polls it
+ /// during pipe-connection attempts so the caller can fail fast if the
+ /// mount process exits before the pipe is created — instead of
+ /// blocking on the full 60-second pipe timeout. Callers that don't
+ /// have a handle to the child process (e.g. clients connecting to
+ /// somebody else's mount) pass null.
+ ///
+ public static bool WaitUntilMounted(
+ ITracer tracer,
+ string pipeName,
+ string enlistmentRoot,
+ bool unattended,
+ Func mountProcessStatus,
+ out string errorMessage)
{
tracer.RelatedInfo($"{nameof(WaitUntilMounted)}: Creating NamedPipeClient for pipe '{pipeName}'");
+ tracer.RelatedInfo($"{nameof(WaitUntilMounted)}: Connecting to '{pipeName}'");
errorMessage = null;
- using (NamedPipeClient pipeClient = new NamedPipeClient(pipeName))
+ int totalTimeoutMs = unattended ? 300000 : 60000;
+ NamedPipeClient pipeClient = TryConnectWithProcessTracking(
+ tracer,
+ pipeName,
+ totalTimeoutMs,
+ mountProcessStatus,
+ out errorMessage);
+ if (pipeClient == null)
{
- tracer.RelatedInfo($"{nameof(WaitUntilMounted)}: Connecting to '{pipeName}'");
-
- int timeout = unattended ? 300000 : 60000;
- if (!pipeClient.Connect(timeout))
- {
- tracer.RelatedError($"{nameof(WaitUntilMounted)}: Failed to connect to '{pipeName}' after {timeout} ms");
- errorMessage = "Unable to mount because the GVFS.Mount process is not responding.";
- return false;
- }
+ return false;
+ }
+ using (pipeClient)
+ {
tracer.RelatedInfo($"{nameof(WaitUntilMounted)}: Connected to '{pipeName}'");
while (true)
@@ -280,6 +306,86 @@ public static bool WaitUntilMounted(ITracer tracer, string pipeName, string enli
}
}
+ private static NamedPipeClient TryConnectWithProcessTracking(
+ ITracer tracer,
+ string pipeName,
+ int totalTimeoutMs,
+ Func mountProcessStatus,
+ out string errorMessage)
+ {
+ errorMessage = null;
+
+ // When no process snapshot is supplied, fall back to a single
+ // long-timeout connect to preserve previous behavior for callers
+ // that don't own the mount process (e.g. external pipe clients).
+ if (mountProcessStatus == null)
+ {
+ NamedPipeClient pipeClient = new NamedPipeClient(pipeName);
+ if (pipeClient.Connect(totalTimeoutMs))
+ {
+ return pipeClient;
+ }
+
+ pipeClient.Dispose();
+ tracer.RelatedError($"{nameof(WaitUntilMounted)}: Failed to connect to '{pipeName}' after {totalTimeoutMs} ms");
+ errorMessage = "Unable to mount because the GVFS.Mount process is not responding.";
+ return null;
+ }
+
+ // With process tracking, retry with short connect attempts so we
+ // can detect early termination within seconds.
+ const int PerAttemptTimeoutMs = 500;
+ DateTime deadline = DateTime.UtcNow.AddMilliseconds(totalTimeoutMs);
+ while (true)
+ {
+ MountProcessSnapshot snapshot = mountProcessStatus();
+ if (snapshot.HasExited)
+ {
+ errorMessage = string.Format(
+ "GVFS.Mount process (Id {0}) exited with code {1} before the named pipe was ready.",
+ snapshot.ProcessId,
+ snapshot.ExitCode);
+ tracer.RelatedError($"{nameof(WaitUntilMounted)}: {errorMessage}");
+ return null;
+ }
+
+ NamedPipeClient pipeClient = new NamedPipeClient(pipeName);
+ if (pipeClient.Connect(PerAttemptTimeoutMs))
+ {
+ return pipeClient;
+ }
+
+ pipeClient.Dispose();
+
+ if (DateTime.UtcNow >= deadline)
+ {
+ tracer.RelatedError($"{nameof(WaitUntilMounted)}: Failed to connect to '{pipeName}' after {totalTimeoutMs} ms (mount process Id {snapshot.ProcessId} still running)");
+ errorMessage = "Unable to mount because the GVFS.Mount process is not responding.";
+ return null;
+ }
+ }
+ }
+
+ ///
+ /// Snapshot of a child mount process's liveness, used by
+ ///
+ /// to short-circuit the pipe-wait when the child has crashed.
+ ///
+ public readonly struct MountProcessSnapshot
+ {
+ public MountProcessSnapshot(int processId, bool hasExited, int exitCode)
+ {
+ this.ProcessId = processId;
+ this.HasExited = hasExited;
+ this.ExitCode = exitCode;
+ }
+
+ public int ProcessId { get; }
+ public bool HasExited { get; }
+ public int ExitCode { get; }
+ }
+
+
public void SetGitVersion(string gitVersion)
{
this.SetOnce(gitVersion, ref this.gitVersion);
diff --git a/GVFS/GVFS.Platform.Windows/CurrentUser.cs b/GVFS/GVFS.Platform.Windows/CurrentUser.cs
index 1d1ff151f..1b4fda012 100644
--- a/GVFS/GVFS.Platform.Windows/CurrentUser.cs
+++ b/GVFS/GVFS.Platform.Windows/CurrentUser.cs
@@ -1,9 +1,12 @@
-using GVFS.Common.Tracing;
+using GVFS.Common;
+using GVFS.Common.Tracing;
+using Microsoft.Win32.SafeHandles;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Security.Principal;
+using System.Text;
namespace GVFS.Platform.Windows
{
@@ -108,12 +111,38 @@ private enum CreateProcessFlags : uint
///
/// Launches a process for the current user.
- /// This code will only work when running in a windows service running as LocalSystem
- /// with the SE_TCB_NAME privilege.
+ /// This code will only work when running in a windows service running
+ /// as LocalSystem with the SE_TCB_NAME privilege.
///
- /// True on successful process start
- public bool RunAs(string processName, string args)
+ /// Full path to the executable to launch.
+ ///
+ /// Argument values exactly as the child process should see them in its
+ /// argv. Each value is escaped according to
+ /// CommandLineToArgvW rules so embedded quotes, spaces, and
+ /// backslashes round-trip safely. Passing pre-concatenated argument
+ /// strings here would re-introduce the quote-stripping bug that
+ /// silently corrupts the service's --internal_use_only JSON.
+ ///
+ ///
+ /// On success, an owned handle to the newly created process. Callers
+ /// can use and
+ /// to query the
+ /// child's liveness without racing on PID lookup (the kernel keeps
+ /// the process object alive as long as we hold the handle, even
+ /// after the child exits, so the handle is always queryable and
+ /// never aliases a reused PID). Callers must
+ /// it when done — usually with using.
+ ///
+ ///
+ /// On success, the PID of the newly created process (for logging
+ /// and diagnostics only — use for
+ /// any liveness check).
+ ///
+ /// true if the process was successfully created.
+ public bool TryRunAs(string processName, string[] arguments, out SafeProcessHandle processHandle, out int processId)
{
+ processHandle = null;
+ processId = 0;
IntPtr environment = IntPtr.Zero;
IntPtr duplicate = IntPtr.Zero;
if (this.token == IntPtr.Zero)
@@ -121,6 +150,8 @@ public bool RunAs(string processName, string args)
return false;
}
+ string commandLine = BuildCommandLine(processName, arguments);
+
try
{
if (DuplicateTokenEx(
@@ -140,7 +171,7 @@ public bool RunAs(string processName, string args)
if (CreateProcessAsUser(
duplicate,
null,
- string.Format("\"{0}\" {1}", processName, args),
+ commandLine,
IntPtr.Zero,
IntPtr.Zero,
inheritHandles: false,
@@ -150,16 +181,14 @@ public bool RunAs(string processName, string args)
startupInfo: ref info,
processInformation: out procInfo))
{
- try
- {
- this.tracer.RelatedInfo("Started process '{0} {1}' with Id {2}", processName, args, procInfo.ProcessId);
- }
- finally
- {
- CloseHandle(procInfo.ProcessHandle);
- CloseHandle(procInfo.ThreadHandle);
- }
-
+ // Always close the thread handle (we never use it).
+ // Wrap the process handle in a SafeProcessHandle that
+ // owns it; callers get a race-free liveness handle that
+ // remains queryable even after the child exits.
+ CloseHandle(procInfo.ThreadHandle);
+ processId = procInfo.ProcessId;
+ processHandle = new SafeProcessHandle(procInfo.ProcessHandle, ownsHandle: true);
+ this.tracer.RelatedInfo("Started process '{0}' with Id {1}", commandLine, processId);
return true;
}
else
@@ -193,6 +222,22 @@ public bool RunAs(string processName, string args)
return false;
}
+ private static string BuildCommandLine(string processName, string[] arguments)
+ {
+ StringBuilder builder = new StringBuilder();
+ builder.Append(CommandLineEscaping.EscapeArgument(processName));
+ if (arguments != null)
+ {
+ foreach (string argument in arguments)
+ {
+ builder.Append(' ');
+ builder.Append(CommandLineEscaping.EscapeArgument(argument));
+ }
+ }
+
+ return builder.ToString();
+ }
+
///
/// Returns session IDs for sessions that have a logged-in user
/// whose token can be queried via WTSQueryUserToken.
diff --git a/GVFS/GVFS.Platform.Windows/ProcessHandleHelper.cs b/GVFS/GVFS.Platform.Windows/ProcessHandleHelper.cs
new file mode 100644
index 000000000..d2b831864
--- /dev/null
+++ b/GVFS/GVFS.Platform.Windows/ProcessHandleHelper.cs
@@ -0,0 +1,58 @@
+using Microsoft.Win32.SafeHandles;
+using System.Runtime.InteropServices;
+
+namespace GVFS.Platform.Windows
+{
+ ///
+ /// Race-free liveness checks against a Win32 process handle.
+ /// Unlike ,
+ /// these helpers query an already-opened handle, so they cannot lose the
+ /// race where the target exits between the caller starting it and the
+ /// caller looking it up, and they cannot alias a reused PID. The kernel
+ /// keeps the process object alive for as long as the handle is held,
+ /// even after the child has exited and its PID has been reused.
+ ///
+ public static class ProcessHandleHelper
+ {
+ // From WinBase.h. WaitForSingleObject signals the process handle
+ // immediately when the process has exited; we pass a zero timeout
+ // so we never block.
+ private const uint WaitObject0 = 0x00000000;
+ private const uint WaitTimeout = 0x00000102;
+
+ ///
+ /// Returns true if the process has exited. Uses a non-blocking
+ /// wait so it's safe to call from a polling loop.
+ ///
+ public static bool HasExited(SafeProcessHandle handle)
+ {
+ uint result = WaitForSingleObject(handle, 0);
+ return result == WaitObject0;
+ }
+
+ ///
+ /// Reads the process's exit code. Only meaningful after
+ /// returns true. Returns false
+ /// if the underlying Win32 call fails.
+ ///
+ public static bool TryGetExitCode(SafeProcessHandle handle, out int exitCode)
+ {
+ uint code;
+ if (GetExitCodeProcess(handle, out code))
+ {
+ exitCode = unchecked((int)code);
+ return true;
+ }
+
+ exitCode = -1;
+ return false;
+ }
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ private static extern uint WaitForSingleObject(SafeProcessHandle handle, uint timeoutMilliseconds);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool GetExitCodeProcess(SafeProcessHandle handle, out uint exitCode);
+ }
+}
diff --git a/GVFS/GVFS.Platform.Windows/WindowsPlatform.cs b/GVFS/GVFS.Platform.Windows/WindowsPlatform.cs
index b8593f417..df8ccf873 100644
--- a/GVFS/GVFS.Platform.Windows/WindowsPlatform.cs
+++ b/GVFS/GVFS.Platform.Windows/WindowsPlatform.cs
@@ -142,7 +142,7 @@ public override Process StartBackgroundVFS4GProcess(ITracer tracer, string progr
string programArguments = string.Empty;
try
{
- programArguments = string.Join(" ", args.Select(arg => arg.Contains(' ') ? "\"" + arg + "\"" : arg));
+ programArguments = string.Join(" ", args.Select(arg => CommandLineEscaping.EscapeArgument(arg)));
ProcessStartInfo processInfo = new ProcessStartInfo(programName, programArguments);
// UseShellExecute=true uses ShellExecuteEx which does NOT inherit
diff --git a/GVFS/GVFS.Service/GVFSMountProcess.cs b/GVFS/GVFS.Service/GVFSMountProcess.cs
index 4e65e6c62..08f4746aa 100644
--- a/GVFS/GVFS.Service/GVFSMountProcess.cs
+++ b/GVFS/GVFS.Service/GVFSMountProcess.cs
@@ -1,7 +1,9 @@
-using GVFS.Common;
+using GVFS.Common;
using GVFS.Common.Tracing;
using GVFS.Platform.Windows;
using GVFS.Service.Handlers;
+using Microsoft.Win32.SafeHandles;
+using System;
namespace GVFS.Service
{
@@ -28,47 +30,94 @@ public bool MountRepository(string repoRoot, int sessionId)
using (CurrentUser currentUser = new CurrentUser(this.tracer, sessionId))
{
- if (!this.CallGVFSMount(repoRoot, currentUser))
+ SafeProcessHandle mountHandle;
+ int mountProcessId;
+ if (!this.TryCallGVFSMount(repoRoot, currentUser, out mountHandle, out mountProcessId))
{
this.tracer.RelatedError($"{nameof(this.MountRepository)}: Unable to start the GVFS.exe process.");
return false;
}
- string errorMessage;
- string pipeName = GVFSPlatform.Instance.GetNamedPipeName(repoRoot);
- string worktreeError;
- GVFSEnlistment.WorktreeInfo wtInfo = GVFSEnlistment.TryGetWorktreeInfo(repoRoot, out worktreeError);
- if (worktreeError != null)
+ // Always own the handle for the rest of this call so the
+ // kernel keeps the process object alive while we poll it.
+ using (mountHandle)
{
- this.tracer.RelatedError($"Failed to check worktree status for '{repoRoot}': {worktreeError}");
- return false;
- }
+ string errorMessage;
+ string pipeName = GVFSPlatform.Instance.GetNamedPipeName(repoRoot);
+ string worktreeError;
+ GVFSEnlistment.WorktreeInfo wtInfo = GVFSEnlistment.TryGetWorktreeInfo(repoRoot, out worktreeError);
+ if (worktreeError != null)
+ {
+ this.tracer.RelatedError($"Failed to check worktree status for '{repoRoot}': {worktreeError}");
+ return false;
+ }
- if (wtInfo?.SharedGitDir != null)
- {
- string enlistmentRoot = wtInfo.GetEnlistmentRoot();
- if (enlistmentRoot != null)
+ if (wtInfo?.SharedGitDir != null)
{
- pipeName = GVFSPlatform.Instance.GetNamedPipeName(enlistmentRoot) + wtInfo.PipeSuffix;
+ string enlistmentRoot = wtInfo.GetEnlistmentRoot();
+ if (enlistmentRoot != null)
+ {
+ pipeName = GVFSPlatform.Instance.GetNamedPipeName(enlistmentRoot) + wtInfo.PipeSuffix;
+ }
}
- }
- if (!GVFSEnlistment.WaitUntilMounted(this.tracer, pipeName, repoRoot, false, out errorMessage))
- {
- this.tracer.RelatedError(errorMessage);
- return false;
+ // Track the spawned mount process so the wait short-circuits
+ // when it dies early — e.g. on argument-parsing failures that
+ // exit before any log file is created. Without this, the
+ // service would block for the full 60-second pipe timeout
+ // with no diagnostic beyond "not responding."
+ //
+ // We use the SafeProcessHandle returned by TryRunAs rather
+ // than Process.GetProcessById(pid) so we cannot race against
+ // the child exiting between CreateProcessAsUser and the
+ // lookup, and cannot alias a reused PID.
+ SafeProcessHandle handle = mountHandle;
+ Func snapshot =
+ () => SnapshotMountProcess(handle, mountProcessId);
+
+ if (!GVFSEnlistment.WaitUntilMounted(this.tracer, pipeName, repoRoot, unattended: false, snapshot, out errorMessage))
+ {
+ this.tracer.RelatedError(errorMessage);
+ return false;
+ }
}
}
return true;
}
- private bool CallGVFSMount(string repoRoot, CurrentUser currentUser)
+ private static GVFSEnlistment.MountProcessSnapshot SnapshotMountProcess(SafeProcessHandle handle, int processId)
+ {
+ if (!ProcessHandleHelper.HasExited(handle))
+ {
+ return new GVFSEnlistment.MountProcessSnapshot(processId, hasExited: false, exitCode: 0);
+ }
+
+ int exitCode;
+ if (!ProcessHandleHelper.TryGetExitCode(handle, out exitCode))
+ {
+ exitCode = -1;
+ }
+
+ return new GVFSEnlistment.MountProcessSnapshot(processId, hasExited: true, exitCode: exitCode);
+ }
+
+ private bool TryCallGVFSMount(string repoRoot, CurrentUser currentUser, out SafeProcessHandle processHandle, out int processId)
{
InternalVerbParameters mountInternal = new InternalVerbParameters(startedByService: true);
- return currentUser.RunAs(
+ return currentUser.TryRunAs(
Configuration.Instance.GVFSLocation,
- $"mount {repoRoot} --{GVFSConstants.VerbParameters.InternalUseOnly} {mountInternal.ToJson()}");
+ new[]
+ {
+ "mount",
+ repoRoot,
+ "--" + GVFSConstants.VerbParameters.InternalUseOnly,
+ mountInternal.ToJson(),
+ },
+ out processHandle,
+ out processId);
}
}
}
+
+
diff --git a/GVFS/GVFS.UnitTests/Common/CommandLineEscapingTests.cs b/GVFS/GVFS.UnitTests/Common/CommandLineEscapingTests.cs
new file mode 100644
index 000000000..a34939963
--- /dev/null
+++ b/GVFS/GVFS.UnitTests/Common/CommandLineEscapingTests.cs
@@ -0,0 +1,99 @@
+using GVFS.Common;
+using GVFS.Tests.Should;
+using NUnit.Framework;
+using System.Runtime.InteropServices;
+
+namespace GVFS.UnitTests.Common
+{
+ [TestFixture]
+ public class CommandLineEscapingTests
+ {
+ [TestCase("simple", "simple", Description = "No special characters: no quoting")]
+ [TestCase("with space", "\"with space\"")]
+ [TestCase("with\ttab", "\"with\ttab\"")]
+ [TestCase("with\"quote", "\"with\\\"quote\"")]
+ [TestCase("ends_with_backslash\\", "ends_with_backslash\\")]
+ [TestCase("path\\with\\backslashes", "path\\with\\backslashes")]
+ [TestCase("path with\\backslashes", "\"path with\\backslashes\"")]
+ [TestCase("trailing_backslash_with quote\\", "\"trailing_backslash_with quote\\\\\"")]
+ [TestCase("a\\\\b c", "\"a\\\\b c\"", Description = "Internal double-backslash not before quote: kept verbatim")]
+ [TestCase("a\\\\\"b", "\"a\\\\\\\\\\\"b\"", Description = "Two backslashes before quote: doubled to four plus escaped quote")]
+ [TestCase("", "\"\"", Description = "Empty argument: must be quoted so it isn't lost")]
+ public void EscapeArgument_ProducesExpectedString(string input, string expected)
+ {
+ CommandLineEscaping.EscapeArgument(input).ShouldEqual(expected);
+ }
+
+ [TestCase]
+ public void EscapeArgument_NullInputIsEmptyQuotedString()
+ {
+ CommandLineEscaping.EscapeArgument(null).ShouldEqual("\"\"");
+ }
+
+ [TestCase("simple")]
+ [TestCase("with space")]
+ [TestCase("with\"quote")]
+ [TestCase("ends_with_backslash\\")]
+ [TestCase("a\\\\\"b")]
+ [TestCase("path\\with\\backslashes")]
+ [TestCase("path with\\backslashes")]
+ [TestCase("trailing_backslash_with quote\\")]
+ [TestCase("{\"ServiceName\":null,\"StartedByService\":true,\"MaintenanceJob\":null,\"PackfileMaintenanceBatchSize\":null}",
+ Description = "The exact InternalVerbParameters.ToJson() shape that triggered the automount regression")]
+ [TestCase("")]
+ public void EscapeArgument_RoundTripsThroughCommandLineToArgvW(string input)
+ {
+ if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ Assert.Ignore("CommandLineToArgvW is a Windows-only API.");
+ }
+
+ // Build a command line that begins with a fake program name so
+ // CommandLineToArgvW has a leading token to chew on, then the
+ // escaped argument we care about.
+ string commandLine = "stub.exe " + CommandLineEscaping.EscapeArgument(input);
+
+ string[] argv = CommandLineToArgs(commandLine);
+
+ argv.Length.ShouldEqual(2, "Expected exactly one argument after the program name");
+ argv[1].ShouldEqual(input ?? string.Empty);
+ }
+
+ private static string[] CommandLineToArgs(string commandLine)
+ {
+ int argc;
+ System.IntPtr argv = NativeMethods.CommandLineToArgvW(commandLine, out argc);
+ if (argv == System.IntPtr.Zero)
+ {
+ throw new System.ComponentModel.Win32Exception();
+ }
+
+ try
+ {
+ string[] args = new string[argc];
+ for (int i = 0; i < argc; i++)
+ {
+ System.IntPtr p = Marshal.ReadIntPtr(argv, i * System.IntPtr.Size);
+ args[i] = Marshal.PtrToStringUni(p);
+ }
+
+ return args;
+ }
+ finally
+ {
+ NativeMethods.LocalFree(argv);
+ }
+ }
+
+ private static class NativeMethods
+ {
+ [DllImport("shell32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
+ public static extern System.IntPtr CommandLineToArgvW(
+ [MarshalAs(UnmanagedType.LPWStr)] string lpCmdLine,
+ out int pNumArgs);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ public static extern System.IntPtr LocalFree(System.IntPtr hMem);
+ }
+ }
+}
diff --git a/GVFS/GVFS.UnitTests/Common/WaitUntilMountedProcessTrackingTests.cs b/GVFS/GVFS.UnitTests/Common/WaitUntilMountedProcessTrackingTests.cs
new file mode 100644
index 000000000..cf04194eb
--- /dev/null
+++ b/GVFS/GVFS.UnitTests/Common/WaitUntilMountedProcessTrackingTests.cs
@@ -0,0 +1,88 @@
+using GVFS.Common;
+using GVFS.Tests.Should;
+using GVFS.UnitTests.Mock.Common;
+using NUnit.Framework;
+using System;
+using System.Threading;
+
+namespace GVFS.UnitTests.Common
+{
+ [TestFixture]
+ public class WaitUntilMountedProcessTrackingTests
+ {
+ [TestCase]
+ public void ReturnsImmediatelyWhenMountProcessSnapshotReportsExited()
+ {
+ const int FakePid = 13579;
+ const int FakeExitCode = 42;
+ int snapshotCallCount = 0;
+ Func snapshot = () =>
+ {
+ Interlocked.Increment(ref snapshotCallCount);
+ return new GVFSEnlistment.MountProcessSnapshot(FakePid, hasExited: true, exitCode: FakeExitCode);
+ };
+
+ string errorMessage;
+ DateTime start = DateTime.UtcNow;
+ bool result = GVFSEnlistment.WaitUntilMounted(
+ new MockTracer(),
+ pipeName: "GVFS_no_such_pipe_for_test_" + Guid.NewGuid().ToString("N"),
+ enlistmentRoot: "C:\\fake\\root",
+ unattended: false,
+ mountProcessStatus: snapshot,
+ out errorMessage);
+ TimeSpan elapsed = DateTime.UtcNow - start;
+
+ result.ShouldBeFalse();
+ errorMessage.ShouldNotBeNull();
+ errorMessage.ShouldContain(FakePid.ToString());
+ errorMessage.ShouldContain(FakeExitCode.ToString());
+ snapshotCallCount.ShouldBeAtLeast(1);
+
+ // The legacy code path would have blocked for the full 60 second
+ // pipe timeout. With process tracking we should bail out in well
+ // under a second since the snapshot is checked before any
+ // connect attempt.
+ Assert.That(elapsed.TotalSeconds, Is.LessThan(5), "WaitUntilMounted should bail out quickly when the snapshot reports the mount process exited");
+ }
+
+ [TestCase]
+ public void DetectsLateProcessExitWhilePipeNeverAppears()
+ {
+ const int FakePid = 24680;
+ const int FakeExitCode = -1;
+ int snapshotCallCount = 0;
+ Func snapshot = () =>
+ {
+ int count = Interlocked.Increment(ref snapshotCallCount);
+
+ // Pretend the mount process is still running for the first
+ // couple of polls, then suddenly report it exited.
+ bool exited = count >= 2;
+ return new GVFSEnlistment.MountProcessSnapshot(
+ FakePid,
+ hasExited: exited,
+ exitCode: exited ? FakeExitCode : 0);
+ };
+
+ string errorMessage;
+ DateTime start = DateTime.UtcNow;
+ bool result = GVFSEnlistment.WaitUntilMounted(
+ new MockTracer(),
+ pipeName: "GVFS_no_such_pipe_for_test_" + Guid.NewGuid().ToString("N"),
+ enlistmentRoot: "C:\\fake\\root",
+ unattended: false,
+ mountProcessStatus: snapshot,
+ out errorMessage);
+ TimeSpan elapsed = DateTime.UtcNow - start;
+
+ result.ShouldBeFalse();
+ errorMessage.ShouldContain(FakePid.ToString());
+ errorMessage.ShouldContain(FakeExitCode.ToString());
+
+ // Per-attempt connect timeout is 500ms; we expect to discover the
+ // exit on the second poll, well within a few seconds.
+ Assert.That(elapsed.TotalSeconds, Is.LessThan(10), "WaitUntilMounted should detect late process exit within a few connect retries");
+ }
+ }
+}