From 03a14651552e8a016363a108e504516eae91284a Mon Sep 17 00:00:00 2001 From: haltandcatchwater Date: Fri, 20 Mar 2026 15:08:05 -0700 Subject: [PATCH 1/2] Avoid SocketException flood in NamedPipeClientStream on Unix When connecting to a non-existent named pipe on Linux, TryConnect creates a Socket and calls Connect on every retry iteration, throwing and catching a SocketException each time. This floods FirstChanceException handlers and wastes CPU on exception allocation. Add a Stat check before the socket path: if the pipe file doesn't exist (ENOENT), return false immediately without allocating a Socket or throwing. Permission errors and all other conditions still reach socket.Connect so they surface their specific exceptions. Fix #117718 Signed-off-by: haltandcatchwater --- .../IO/Pipes/NamedPipeClientStream.Unix.cs | 13 +++++++ .../NamedPipeTests/NamedPipeTest.Specific.cs | 36 +++++++++++++++++++ 2 files changed, 49 insertions(+) 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..78d848fdfe3e6e 100644 --- a/src/libraries/System.IO.Pipes/tests/NamedPipeTests/NamedPipeTest.Specific.cs +++ b/src/libraries/System.IO.Pipes/tests/NamedPipeTests/NamedPipeTest.Specific.cs @@ -632,6 +632,42 @@ public void ClientConnect_Throws_Timeout_When_Pipe_Not_Found() } } + [Fact] + [PlatformSpecific(TestPlatforms.AnyUnix)] + [SkipOnPlatform(TestPlatforms.iOS | TestPlatforms.tvOS, "iOS/tvOS blocks binding to UNIX sockets")] + public void ClientConnect_PipeNotFound_DoesNotFloodFirstChanceExceptions() + { + 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); + } + [Theory] [MemberData(nameof(GetCancellationTokens))] [SkipOnPlatform(TestPlatforms.iOS | TestPlatforms.tvOS, "iOS/tvOS blocks binding to UNIX sockets")] From 459bf9a960610e6fd1eb29a3f8056aa262fb52e3 Mon Sep 17 00:00:00 2001 From: haltandcatchwater Date: Sat, 21 Mar 2026 18:56:13 -0700 Subject: [PATCH 2/2] Use RemoteExecutor for FirstChanceException test Address review feedback: run the SocketException counting test in an isolated process via RemoteExecutor to prevent other concurrent tests from triggering FirstChanceException and causing flaky results. --- .../NamedPipeTests/NamedPipeTest.Specific.cs | 52 ++++++++++--------- 1 file changed, 28 insertions(+), 24 deletions(-) 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 78d848fdfe3e6e..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,40 +633,43 @@ public void ClientConnect_Throws_Timeout_When_Pipe_Not_Found() } } - [Fact] + [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() { - string pipeName = PipeStreamConformanceTests.GetUniquePipeName(); - int socketExceptionCount = 0; - - EventHandler handler = (sender, args) => + RemoteExecutor.Invoke(() => { - if (args.Exception is Net.Sockets.SocketException) + string pipeName = PipeStreamConformanceTests.GetUniquePipeName(); + int socketExceptionCount = 0; + + EventHandler handler = (sender, args) => { - Interlocked.Increment(ref socketExceptionCount); - } - }; + if (args.Exception is Net.Sockets.SocketException) + { + Interlocked.Increment(ref socketExceptionCount); + } + }; - AppDomain.CurrentDomain.FirstChanceException += handler; - try - { - using (NamedPipeClientStream client = new NamedPipeClientStream(pipeName)) + AppDomain.CurrentDomain.FirstChanceException += handler; + try { - Assert.Throws(() => client.Connect(500)); + using (NamedPipeClientStream client = new NamedPipeClientStream(pipeName)) + { + Assert.Throws(() => client.Connect(500)); + } + } + finally + { + AppDomain.CurrentDomain.FirstChanceException -= handler; } - } - 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); + // 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]