diff --git a/src/Renci.SshNet/Abstractions/SocketAbstraction.cs b/src/Renci.SshNet/Abstractions/SocketAbstraction.cs
index 080d2231a..d4511efd2 100644
--- a/src/Renci.SshNet/Abstractions/SocketAbstraction.cs
+++ b/src/Renci.SshNet/Abstractions/SocketAbstraction.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Globalization;
using System.Net;
using System.Net.Sockets;
@@ -214,10 +214,38 @@ public static int Read(Socket socket, byte[] buffer, int offset, int size, TimeS
var totalBytesRead = 0;
var totalBytesToRead = size;
- socket.ReceiveTimeout = readTimeout.AsTimeout();
+ // Use a polling deadline because Mono may report a receive timeout as WouldBlock,
+ // which the resumable-error handling below would otherwise keep retrying.
+ //
+ // Preserve Socket.ReceiveTimeout's zero and infinite values, as well as its
+ // validation and millisecond conversion, before accessing the socket.
+ var timeoutMilliseconds = readTimeout.AsTimeout();
+
+ // Timeout.InfiniteTimeSpan (-1 ms) means "wait forever" and must NOT be added to
+ // DateTime.UtcNow: doing so produces a deadline already in the past (UtcNow - 1ms),
+ // which makes the very first non-blocking poll below spuriously throw an
+ // SshOperationTimeoutException ("...timed out after -1 milliseconds") whenever data
+ // is not already available at that exact instant, even though the caller asked to
+ // wait indefinitely. Skip deadline tracking entirely in that case and let
+ // PollWithDeadline block forever on each iteration.
+ var isInfiniteTimeout = timeoutMilliseconds <= 0;
+ var deadlineUtc = isInfiniteTimeout ? DateTime.MaxValue : DateTime.UtcNow + TimeSpan.FromMilliseconds(timeoutMilliseconds);
do
{
+ var remaining = isInfiniteTimeout ? Timeout.InfiniteTimeSpan : deadlineUtc - DateTime.UtcNow;
+ if (!isInfiniteTimeout && remaining < TimeSpan.Zero)
+ {
+ remaining = TimeSpan.Zero;
+ }
+
+ if (!PollWithDeadline(socket, remaining, out var _))
+ {
+ throw new SshOperationTimeoutException(string.Format(CultureInfo.InvariantCulture,
+ "Socket read operation has timed out after {0:F0} milliseconds.",
+ readTimeout.TotalMilliseconds));
+ }
+
try
{
var bytesRead = socket.Receive(buffer, offset + totalBytesRead, totalBytesToRead - totalBytesRead, SocketFlags.None);
@@ -251,6 +279,69 @@ public static int Read(Socket socket, byte[] buffer, int offset, int size, TimeS
return totalBytesRead;
}
+ ///
+ /// Waits, using , until the specified
+ /// has data available to read or the specified elapses.
+ ///
+ /// The to poll.
+ /// The maximum time to wait for data to become available.
+ /// The time remaining in when data became available.
+ ///
+ /// if data is available to read (or the connection was closed by the
+ /// remote host) before elapses; otherwise, .
+ ///
+ ///
+ /// Unlike , which is enforced differently (and, on some
+ /// runtimes, unreliably) across platforms, maps
+ /// directly onto the underlying select()/poll() system call and consistently unblocks when
+ /// its timeout elapses, even if the remote peer never sends any data and never resets or
+ /// closes the connection.
+ ///
+ private static bool PollWithDeadline(Socket socket, TimeSpan timeout, out TimeSpan remaining)
+ {
+ // Timeout.InfiniteTimeSpan (-1 ms) means "wait forever" and must NOT be added to
+ // DateTime.UtcNow below: doing so produces a deadline that is already in the past
+ // (UtcNow - 1ms), so the very first non-blocking Socket.Poll(0, ...) call that does not
+ // immediately find data available would incorrectly be treated as an expired deadline
+ // and spuriously throw an SshOperationTimeoutException ("...timed out after -1
+ // milliseconds") even though the caller asked to wait indefinitely. Poll with an actual
+ // infinite/blocking wait (-1 microseconds) instead in that case.
+ if (timeout == Timeout.InfiniteTimeSpan)
+ {
+ _ = socket.Poll(-1, SelectMode.SelectRead);
+ remaining = Timeout.InfiniteTimeSpan;
+ return true;
+ }
+
+ var deadlineUtc = DateTime.UtcNow + timeout;
+
+ while (true)
+ {
+ var timeUntilDeadline = deadlineUtc - DateTime.UtcNow;
+ if (timeUntilDeadline < TimeSpan.Zero)
+ {
+ timeUntilDeadline = TimeSpan.Zero;
+ }
+
+ // Socket.Poll caps its microseconds parameter at int.MaxValue; clamp accordingly
+ // instead of overflowing when timeUntilDeadline is very large (e.g. Timeout.InfiniteTimeSpan).
+ var microseconds = timeUntilDeadline.TotalMilliseconds * 1000d;
+ var pollMicroseconds = microseconds >= int.MaxValue ? int.MaxValue : (int)microseconds;
+
+ if (socket.Poll(pollMicroseconds, SelectMode.SelectRead))
+ {
+ remaining = deadlineUtc - DateTime.UtcNow;
+ return true;
+ }
+
+ if (DateTime.UtcNow >= deadlineUtc)
+ {
+ remaining = TimeSpan.Zero;
+ return false;
+ }
+ }
+ }
+
#if !NET
public static Task ReadAsync(Socket socket, byte[] buffer, CancellationToken cancellationToken)
{
diff --git a/src/Renci.SshNet/ForwardedPortDynamic.cs b/src/Renci.SshNet/ForwardedPortDynamic.cs
index 7210b403a..e50914eb5 100644
--- a/src/Renci.SshNet/ForwardedPortDynamic.cs
+++ b/src/Renci.SshNet/ForwardedPortDynamic.cs
@@ -334,23 +334,23 @@ private bool HandleSocks(IChannelDirectTcpip channel, Socket clientSocket, TimeS
throw new NotSupportedException(string.Format(CultureInfo.InvariantCulture, "SOCKS version {0} is not supported.", version));
}
}
+ catch (ObjectDisposedException)
+ {
+ // ignore exception caused by the client socket being disposed as part of closing
+ // the forwarded port while a blocking Socket.Poll/Receive call was in progress
+ return false;
+ }
catch (SocketException ex)
{
- // ignore exception thrown by interrupting the blocking receive as part of closing
- // the forwarded port
-#if NETFRAMEWORK
+ // ignore exception thrown by interrupting the blocking Socket.Poll call as part of
+ // closing the forwarded port; this is reported as SocketError.Interrupted regardless
+ // of the target framework, since Socket.Poll (unlike Socket.Receive) is not affected
+ // by the .NET 5+ change described in https://github.com/dotnet/runtime/issues/41585
if (ex.SocketErrorCode != SocketError.Interrupted)
{
RaiseExceptionEvent(ex);
}
-#else
- // Since .NET 5 the exception has been changed.
- // more info https://github.com/dotnet/runtime/issues/41585
- if (ex.SocketErrorCode != SocketError.ConnectionAborted)
- {
- RaiseExceptionEvent(ex);
- }
-#endif
+
return false;
}
finally
diff --git a/src/Renci.SshNet/Security/KeyExchange.cs b/src/Renci.SshNet/Security/KeyExchange.cs
index a9e6917cd..2378a5b2b 100644
--- a/src/Renci.SshNet/Security/KeyExchange.cs
+++ b/src/Renci.SshNet/Security/KeyExchange.cs
@@ -1,5 +1,5 @@
using System;
-using System.Linq;
+using System.Collections.Generic;
using System.Security.Cryptography;
using Microsoft.Extensions.Logging;
@@ -82,10 +82,9 @@ public virtual void Start(Session session, KeyExchangeInitMessage message, bool
}
// Determine host key algorithm
- var hostKeyAlgorithmName = (from b in session.ConnectionInfo.HostKeyAlgorithms.Keys
- from a in message.ServerHostKeyAlgorithms
- where a == b
- select a).FirstOrDefault();
+ var hostKeyAlgorithmName = SelectAlgorithm(
+ session.ConnectionInfo.HostKeyAlgorithms.Keys,
+ message.ServerHostKeyAlgorithms);
if (_logger.IsEnabled(LogLevel.Trace))
{
@@ -109,10 +108,9 @@ from a in message.ServerHostKeyAlgorithms
_hostKeyAlgorithmFactory = session.ConnectionInfo.HostKeyAlgorithms[hostKeyAlgorithmName];
// Determine client encryption algorithm
- var clientEncryptionAlgorithmName = (from b in session.ConnectionInfo.Encryptions.Keys
- from a in message.EncryptionAlgorithmsClientToServer
- where a == b
- select a).FirstOrDefault();
+ var clientEncryptionAlgorithmName = SelectAlgorithm(
+ session.ConnectionInfo.Encryptions.Keys,
+ message.EncryptionAlgorithmsClientToServer);
if (_logger.IsEnabled(LogLevel.Trace))
{
@@ -136,10 +134,9 @@ from a in message.EncryptionAlgorithmsClientToServer
_clientCipherInfo = session.ConnectionInfo.Encryptions[clientEncryptionAlgorithmName];
// Determine server encryption algorithm
- var serverDecryptionAlgorithmName = (from b in session.ConnectionInfo.Encryptions.Keys
- from a in message.EncryptionAlgorithmsServerToClient
- where a == b
- select a).FirstOrDefault();
+ var serverDecryptionAlgorithmName = SelectAlgorithm(
+ session.ConnectionInfo.Encryptions.Keys,
+ message.EncryptionAlgorithmsServerToClient);
if (_logger.IsEnabled(LogLevel.Trace))
{
@@ -165,10 +162,9 @@ from a in message.EncryptionAlgorithmsServerToClient
if (!_clientCipherInfo.IsAead)
{
// Determine client hmac algorithm
- var clientHmacAlgorithmName = (from b in session.ConnectionInfo.HmacAlgorithms.Keys
- from a in message.MacAlgorithmsClientToServer
- where a == b
- select a).FirstOrDefault();
+ var clientHmacAlgorithmName = SelectAlgorithm(
+ session.ConnectionInfo.HmacAlgorithms.Keys,
+ message.MacAlgorithmsClientToServer);
if (_logger.IsEnabled(LogLevel.Trace))
{
@@ -195,10 +191,9 @@ from a in message.MacAlgorithmsClientToServer
if (!_serverCipherInfo.IsAead)
{
// Determine server hmac algorithm
- var serverHmacAlgorithmName = (from b in session.ConnectionInfo.HmacAlgorithms.Keys
- from a in message.MacAlgorithmsServerToClient
- where a == b
- select a).FirstOrDefault();
+ var serverHmacAlgorithmName = SelectAlgorithm(
+ session.ConnectionInfo.HmacAlgorithms.Keys,
+ message.MacAlgorithmsServerToClient);
if (_logger.IsEnabled(LogLevel.Trace))
{
@@ -223,10 +218,9 @@ from a in message.MacAlgorithmsServerToClient
}
// Determine compression algorithm
- var compressionAlgorithmName = (from b in session.ConnectionInfo.CompressionAlgorithms.Keys
- from a in message.CompressionAlgorithmsClientToServer
- where a == b
- select a).FirstOrDefault();
+ var compressionAlgorithmName = SelectAlgorithm(
+ session.ConnectionInfo.CompressionAlgorithms.Keys,
+ message.CompressionAlgorithmsClientToServer);
if (_logger.IsEnabled(LogLevel.Trace))
{
@@ -250,10 +244,9 @@ from a in message.CompressionAlgorithmsClientToServer
_compressorFactory = session.ConnectionInfo.CompressionAlgorithms[compressionAlgorithmName];
// Determine decompression algorithm
- var decompressionAlgorithmName = (from b in session.ConnectionInfo.CompressionAlgorithms.Keys
- from a in message.CompressionAlgorithmsServerToClient
- where a == b
- select a).FirstOrDefault();
+ var decompressionAlgorithmName = SelectAlgorithm(
+ session.ConnectionInfo.CompressionAlgorithms.Keys,
+ message.CompressionAlgorithmsServerToClient);
if (_logger.IsEnabled(LogLevel.Trace))
{
@@ -277,6 +270,30 @@ from a in message.CompressionAlgorithmsServerToClient
_decompressorFactory = session.ConnectionInfo.CompressionAlgorithms[decompressionAlgorithmName];
}
+ ///
+ /// Selects the first algorithm in (in client preference order) that is
+ /// also present in . Explicit loops retain the existing
+ /// client preference order and use ordinal equality for SSH algorithm identifiers.
+ ///
+ /// The algorithms supported by the client, in preference order.
+ /// The algorithms offered by the server.
+ /// The first matching algorithm name, or if none match.
+ private static string SelectAlgorithm(IEnumerable clientAlgorithms, IEnumerable serverAlgorithms)
+ {
+ foreach (var clientAlgorithm in clientAlgorithms)
+ {
+ foreach (var serverAlgorithm in serverAlgorithms)
+ {
+ if (string.Equals(serverAlgorithm, clientAlgorithm, StringComparison.Ordinal))
+ {
+ return serverAlgorithm;
+ }
+ }
+ }
+
+ return null;
+ }
+
///
/// Finishes key exchange algorithm.
///
diff --git a/test/Renci.SshNet.Tests/Classes/SocketReadTimeoutTest.cs b/test/Renci.SshNet.Tests/Classes/SocketReadTimeoutTest.cs
new file mode 100644
index 000000000..96759150e
--- /dev/null
+++ b/test/Renci.SshNet.Tests/Classes/SocketReadTimeoutTest.cs
@@ -0,0 +1,65 @@
+#nullable enable
+using System;
+using System.Net;
+using System.Net.Sockets;
+using System.Threading.Tasks;
+
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+using Renci.SshNet.Abstractions;
+
+namespace Renci.SshNet.Tests.Classes
+{
+ /// Checks timeout compatibility when socket reads use polling.
+ [TestClass]
+ public class SocketReadTimeoutTest
+ {
+ /// Preserves the existing socket convention that a zero receive timeout waits indefinitely.
+ [TestMethod]
+ public async Task ZeroTimeoutWaitsForDelayedData()
+ {
+ var listener = new TcpListener(IPAddress.Loopback, 0);
+ listener.Start();
+ try
+ {
+ using var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
+ client.Connect(listener.LocalEndpoint);
+ using var peer = listener.AcceptSocket();
+ var buffer = new byte[1];
+ var read = Task.Run(() => SocketAbstraction.Read(client, buffer, 0, buffer.Length, TimeSpan.Zero));
+ try
+ {
+ await Task.Delay(100).ConfigureAwait(false);
+ _ = peer.Send(new byte[] { 42 });
+ var completed = await Task.WhenAny(read, Task.Delay(3000)).ConfigureAwait(false);
+ Assert.AreSame(read, completed, "The read did not complete after data became available.");
+ Assert.AreEqual(1, await read.ConfigureAwait(false));
+ Assert.AreEqual((byte)42, buffer[0]);
+ }
+ finally
+ {
+ peer.Shutdown(SocketShutdown.Both);
+ _ = await Task.WhenAny(read, Task.Delay(3000)).ConfigureAwait(false);
+ _ = read.Exception;
+ }
+ }
+ finally
+ {
+ listener.Stop();
+ }
+ }
+
+ /// Retains the timeout bounds checked before accessing the socket in the previous implementation.
+ [TestMethod]
+ [DataRow(-2L)]
+ [DataRow(2147483648L)]
+ public void InvalidTimeoutIsRejectedBeforeReading(long milliseconds)
+ {
+ using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
+ socket.Dispose();
+ var exception = Assert.ThrowsExactly(() =>
+ SocketAbstraction.Read(socket, new byte[1], 0, 1, TimeSpan.FromMilliseconds(milliseconds)));
+ Assert.AreEqual("readTimeout", exception.ParamName);
+ }
+ }
+}