diff --git a/src/libraries/System.IO.Pipes/src/System/IO/Pipes/NamedPipeClientStream.Unix.cs b/src/libraries/System.IO.Pipes/src/System/IO/Pipes/NamedPipeClientStream.Unix.cs index 4c7df8d63e73a4..3ce699faf14f0b 100644 --- a/src/libraries/System.IO.Pipes/src/System/IO/Pipes/NamedPipeClientStream.Unix.cs +++ b/src/libraries/System.IO.Pipes/src/System/IO/Pipes/NamedPipeClientStream.Unix.cs @@ -35,6 +35,19 @@ private bool TryConnect(int _ /* timeout */) // either succeeding immediately if the server is listening or failing // immediately if it isn't. The only delay will be between the time the server // has called Bind and Listen, with the latter immediately following the former. + + // If the socket file doesn't exist yet, skip the socket allocation and + // Connect call that would throw a SocketException. Only ENOENT (not found) + // is short-circuited; permission errors and other conditions still reach + // socket.Connect so they surface their specific exceptions. + // TOCTOU note: the file could appear between this check and the next retry + // iteration, but ConnectInternal's polling loop handles that naturally. + if (Interop.Sys.Stat(_normalizedPipePath!, out Interop.Sys.FileStatus _) != 0 && + Interop.Sys.GetLastErrorInfo().Error == Interop.Error.ENOENT) + { + return false; + } + var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); SafePipeHandle? clientHandle = null; try diff --git a/src/libraries/System.IO.Pipes/tests/NamedPipeTests/NamedPipeTest.Specific.cs b/src/libraries/System.IO.Pipes/tests/NamedPipeTests/NamedPipeTest.Specific.cs index 8ae58d74c23075..1978b0510e5e7c 100644 --- a/src/libraries/System.IO.Pipes/tests/NamedPipeTests/NamedPipeTest.Specific.cs +++ b/src/libraries/System.IO.Pipes/tests/NamedPipeTests/NamedPipeTest.Specific.cs @@ -6,6 +6,7 @@ using System.Security.Principal; using System.Threading; using System.Threading.Tasks; +using Microsoft.DotNet.RemoteExecutor; using Xunit; namespace System.IO.Pipes.Tests @@ -632,6 +633,45 @@ public void ClientConnect_Throws_Timeout_When_Pipe_Not_Found() } } + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + [PlatformSpecific(TestPlatforms.AnyUnix)] + [SkipOnPlatform(TestPlatforms.iOS | TestPlatforms.tvOS, "iOS/tvOS blocks binding to UNIX sockets")] + public void ClientConnect_PipeNotFound_DoesNotFloodFirstChanceExceptions() + { + RemoteExecutor.Invoke(() => + { + string pipeName = PipeStreamConformanceTests.GetUniquePipeName(); + int socketExceptionCount = 0; + + EventHandler handler = (sender, args) => + { + if (args.Exception is Net.Sockets.SocketException) + { + Interlocked.Increment(ref socketExceptionCount); + } + }; + + AppDomain.CurrentDomain.FirstChanceException += handler; + try + { + using (NamedPipeClientStream client = new NamedPipeClientStream(pipeName)) + { + Assert.Throws(() => client.Connect(500)); + } + } + finally + { + AppDomain.CurrentDomain.FirstChanceException -= handler; + } + + // Before the fix, connecting to a non-existent pipe for 500ms would + // throw hundreds or thousands of SocketExceptions internally. + // With the Stat-based guard, zero SocketExceptions should be thrown + // when the pipe file never exists. Allow a small margin for races. + Assert.InRange(socketExceptionCount, 0, 5); + }).Dispose(); + } + [Theory] [MemberData(nameof(GetCancellationTokens))] [SkipOnPlatform(TestPlatforms.iOS | TestPlatforms.tvOS, "iOS/tvOS blocks binding to UNIX sockets")]