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