Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
f329d87
feat: Improve Unix socket connection handling for proxy compatibility
thomhurst Dec 30, 2025
a48643b
fix: Update to Assert.ThrowsAnyAsync for OperationCanceledException i…
thomhurst Jan 2, 2026
ed2fc11
fix: Improve Unix socket connection handling by using configured cred…
thomhurst Jan 2, 2026
568f421
fix: Refactor DockerClientConfiguration constructor parameters for cl…
thomhurst Jan 19, 2026
12796ee
refactor: Simplify Unix socket handling by removing SocketsHttpHandle…
thomhurst Jan 19, 2026
22980b9
feat: Enhance Unix socket handling with improved connection support a…
thomhurst Jan 19, 2026
9d275f3
refactor: Modernize ManagedHandler for proxy compatibility while pres…
thomhurst Jan 20, 2026
444c5d2
refactor: Remove redundant PrivateMakeRequestAsync overload
thomhurst Jan 20, 2026
a50cff0
feat: Modernize ManagedHandler with memory-efficient APIs, connection…
thomhurst Jan 20, 2026
efac370
fix: Remove PipeReader from BufferedReadStream to avoid chunked strea…
thomhurst Jan 20, 2026
b0c8447
refactor: Remove retry logic from ManagedHandler
thomhurst Jan 20, 2026
ccb228e
refactor: Remove connection pooling from ManagedHandler
thomhurst Jan 20, 2026
c5d2b50
refactor: Remove Memory APIs and IAsyncDisposable from stream classes
thomhurst Jan 20, 2026
e27268b
fix: Disable proxy resolution for Unix socket and named pipe connections
thomhurst Jan 20, 2026
018aa5a
refactor: Rename SocketConfiguration to SocketConnectionConfiguration
thomhurst Jan 20, 2026
104f89a
fix: Address PR feedback for socket handling improvements
thomhurst Jan 25, 2026
d9a0654
revert: Keep SocketConnectionConfiguration.Default as singleton
thomhurst Jan 25, 2026
31772e0
refactor: Remove ConnectTimeout to keep PR focused
thomhurst Jan 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 62 additions & 13 deletions src/Docker.DotNet/DockerClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ internal DockerClient(DockerClientConfiguration configuration, Version requested
Plugin = new PluginOperations(this);
Exec = new ExecOperations(this);

ManagedHandler handler;
HttpMessageHandler handler;
var uri = Configuration.EndpointBaseUri;
switch (uri.Scheme.ToLowerInvariant())
{
Expand All @@ -60,7 +60,7 @@ internal DockerClient(DockerClientConfiguration configuration, Version requested
var pipeName = uri.Segments[2];

uri = new UriBuilder("http", pipeName).Uri;
handler = new ManagedHandler(async (host, port, cancellationToken) =>
var pipeHandler = new ManagedHandler(async (host, port, cancellationToken) =>
{
var timeout = (int)Configuration.NamedPipeConnectTimeout.TotalMilliseconds;
var stream = new NamedPipeClientStream(serverName, pipeName, PipeDirection.InOut, PipeOptions.Asynchronous);
Expand All @@ -71,6 +71,9 @@ await stream.ConnectAsync(timeout, cancellationToken)

return dockerStream;
}, logger);
// Named pipes are local connections - disable proxy resolution
pipeHandler.UseProxy = false;
handler = pipeHandler;
break;

case "tcp":
Expand All @@ -80,25 +83,71 @@ await stream.ConnectAsync(timeout, cancellationToken)
Scheme = configuration.Credentials.IsTlsCredentials() ? "https" : "http"
};
uri = builder.Uri;
handler = new ManagedHandler(logger);
handler = new ManagedHandler(logger, Configuration.SocketConnectionConfiguration);
break;

case "https":
handler = new ManagedHandler(logger);
handler = new ManagedHandler(logger, Configuration.SocketConnectionConfiguration);
break;

case "unix":
var pipeString = uri.LocalPath;
handler = new ManagedHandler(async (host, port, cancellationToken) =>
{
var sock = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);

await sock.ConnectAsync(new Microsoft.Net.Http.Client.UnixDomainSocketEndPoint(pipeString))
.ConfigureAwait(false);

return sock;
}, logger);
var socketTimeout = Configuration.SocketConnectTimeout;
var socketConfig = Configuration.SocketConnectionConfiguration;
uri = new UriBuilder("http", uri.Segments.Last()).Uri;

// Use ManagedHandler for Unix socket connections.
// ManagedHandler is required for hijacked stream operations (attach/exec/logs)
// as it provides HttpConnectionResponseContent needed for connection hijacking.
// SocketsHttpHandler cannot support hijacking because it encapsulates the transport stream.
var unixHandler = new ManagedHandler(async (_, _, cancellationToken) =>
{
var endpoint = new Microsoft.Net.Http.Client.UnixDomainSocketEndPoint(pipeString);
var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);

try
{
// Apply socket configuration for better proxy compatibility
socketConfig.ApplyTo(socket);

using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(socketTimeout);

#if NET5_0_OR_GREATER
// Use modern ConnectAsync with cancellation support
await socket.ConnectAsync(endpoint, timeoutCts.Token)
.ConfigureAwait(false);
#else
var connectTask = socket.ConnectAsync(endpoint);
var timeoutTask = Task.Delay(Timeout.InfiniteTimeSpan, timeoutCts.Token);

var completedTask = await Task.WhenAny(connectTask, timeoutTask)
.ConfigureAwait(false);

if (completedTask == timeoutTask)
{
cancellationToken.ThrowIfCancellationRequested();
throw new TimeoutException($"Connection to Unix socket '{pipeString}' timed out after {socketTimeout.TotalSeconds}s.");
}

await connectTask.ConfigureAwait(false);
#endif
return socket;
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
socket.Dispose();
throw new TimeoutException($"Connection to Unix socket '{pipeString}' timed out after {socketTimeout.TotalSeconds}s.");
}
catch
{
socket.Dispose();
throw;
}
}, logger, socketConfig);
// Unix sockets are local connections - disable proxy resolution
unixHandler.UseProxy = false;
handler = unixHandler;
break;

default:
Expand Down
41 changes: 35 additions & 6 deletions src/Docker.DotNet/DockerClientConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ public DockerClientConfiguration(
Credentials credentials = null,
TimeSpan defaultTimeout = default,
TimeSpan namedPipeConnectTimeout = default,
IReadOnlyDictionary<string, string> defaultHttpRequestHeaders = null)
: this(GetLocalDockerEndpoint(), credentials, defaultTimeout, namedPipeConnectTimeout, defaultHttpRequestHeaders)
TimeSpan socketConnectTimeout = default,
IReadOnlyDictionary<string, string> defaultHttpRequestHeaders = null,
SocketConnectionConfiguration socketConfiguration = null)
: this(GetLocalDockerEndpoint(), credentials, defaultTimeout, namedPipeConnectTimeout, socketConnectTimeout, defaultHttpRequestHeaders, socketConfiguration)
{
}

Expand All @@ -18,7 +20,9 @@ public DockerClientConfiguration(
Credentials credentials = null,
TimeSpan defaultTimeout = default,
TimeSpan namedPipeConnectTimeout = default,
IReadOnlyDictionary<string, string> defaultHttpRequestHeaders = null)
TimeSpan socketConnectTimeout = default,
IReadOnlyDictionary<string, string> defaultHttpRequestHeaders = null,
SocketConnectionConfiguration socketConfiguration = null)
{
if (endpoint == null)
{
Expand All @@ -34,22 +38,47 @@ public DockerClientConfiguration(
Credentials = credentials ?? new AnonymousCredentials();
DefaultTimeout = TimeSpan.Equals(TimeSpan.Zero, defaultTimeout) ? TimeSpan.FromSeconds(100) : defaultTimeout;
NamedPipeConnectTimeout = TimeSpan.Equals(TimeSpan.Zero, namedPipeConnectTimeout) ? TimeSpan.FromMilliseconds(100) : namedPipeConnectTimeout;
SocketConnectTimeout = TimeSpan.Equals(TimeSpan.Zero, socketConnectTimeout) ? TimeSpan.FromSeconds(30) : socketConnectTimeout;
DefaultHttpRequestHeaders = defaultHttpRequestHeaders ?? new Dictionary<string, string>();
SocketConnectionConfiguration = socketConfiguration ?? SocketConnectionConfiguration.Default;
}

/// <summary>
/// Gets the collection of default HTTP request headers.
/// Gets the Docker endpoint base URI.
/// </summary>
public IReadOnlyDictionary<string, string> DefaultHttpRequestHeaders { get; }

public Uri EndpointBaseUri { get; }

/// <summary>
/// Gets the credentials used for authentication.
/// </summary>
public Credentials Credentials { get; }

/// <summary>
/// Gets the default timeout for API requests.
/// </summary>
public TimeSpan DefaultTimeout { get; }

/// <summary>
/// Gets the timeout for named pipe connections (Windows).
/// </summary>
public TimeSpan NamedPipeConnectTimeout { get; }

/// <summary>
/// Gets the timeout for Unix domain socket connections.
/// </summary>
public TimeSpan SocketConnectTimeout { get; }

/// <summary>
/// Gets the socket configuration options for connection handling.
/// These settings help improve proxy compatibility and connection reliability.
/// </summary>
public SocketConnectionConfiguration SocketConnectionConfiguration { get; }

/// <summary>
/// Gets the collection of default HTTP request headers.
/// </summary>
public IReadOnlyDictionary<string, string> DefaultHttpRequestHeaders { get; }

public DockerClient CreateClient(Version requestedApiVersion = null, ILogger logger = null)
{
return new DockerClient(this, requestedApiVersion, logger);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ internal sealed class BufferedReadStream : WriteClosableStream, IPeekableStream
private int _bufferRefCount;
private int _bufferOffset;
private int _bufferCount;
private bool _disposed;

public BufferedReadStream(Stream inner, Socket socket, ILogger logger)
: this(inner, socket, 8192, logger)
Expand Down Expand Up @@ -59,8 +60,14 @@ public override long Position

protected override void Dispose(bool disposing)
{
if (_disposed)
{
return;
}

if (disposing)
{
_disposed = true;
if (Interlocked.Exchange(ref _bufferRefCount, 0) == 1)
{
ArrayPool<byte>.Shared.Return(_buffer);
Expand Down
13 changes: 12 additions & 1 deletion src/Docker.DotNet/Microsoft.Net.Http.Client/ChunkedReadStream.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ internal sealed class ChunkedReadStream : Stream
private readonly BufferedReadStream _inner;
private int _chunkBytesRemaining;
private bool _done;
private bool _disposed;

public ChunkedReadStream(BufferedReadStream stream)
{
Expand Down Expand Up @@ -146,4 +147,14 @@ public override void Flush()
{
_inner.Flush();
}
}

protected override void Dispose(bool disposing)
{
if (!_disposed && disposing)
{
_disposed = true;
// Note: We don't dispose _inner here as it's owned by HttpConnection
}
base.Dispose(disposing);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ internal sealed class ChunkedWriteStream : Stream
private static readonly byte[] EndOfContentBytes = Encoding.ASCII.GetBytes("0\r\n\r\n");

private readonly Stream _inner;
private bool _disposed;

public ChunkedWriteStream(Stream stream)
{
Expand Down Expand Up @@ -87,4 +88,14 @@ public Task EndContentAsync(CancellationToken cancellationToken)
{
return _inner.WriteAsync(EndOfContentBytes, 0, EndOfContentBytes.Length, cancellationToken);
}
}

protected override void Dispose(bool disposing)
{
if (!_disposed && disposing)
{
_disposed = true;
// Note: We don't dispose _inner here as it's owned by the caller
}
base.Dispose(disposing);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,9 @@ public override async Task<int> ReadAsync(byte[] buffer, int offset, int count,

protected override void Dispose(bool disposing)
{
if (disposing)
if (disposing && !_disposed)
{
_disposed = true;
// TODO: Sync drain with timeout if small number of bytes remaining? This will let us re-use the connection.
_inner.Dispose();
}
Expand Down
18 changes: 13 additions & 5 deletions src/Docker.DotNet/Microsoft.Net.Http.Client/HttpConnection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,27 +18,35 @@ public async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, Can
// Serialize headers & send
string rawRequest = SerializeRequest(request);
byte[] requestBytes = Encoding.ASCII.GetBytes(rawRequest);
await Transport.WriteAsync(requestBytes, 0, requestBytes.Length, cancellationToken);
await Transport.WriteAsync(requestBytes, 0, requestBytes.Length, cancellationToken).ConfigureAwait(false);

if (request.Content != null)
{
if (request.Content.Headers.ContentLength.HasValue)
{
await request.Content.CopyToAsync(Transport);
#if NET5_0_OR_GREATER
await request.Content.CopyToAsync(Transport, cancellationToken).ConfigureAwait(false);
#else
await request.Content.CopyToAsync(Transport).ConfigureAwait(false);
#endif
}
else
{
// The length of the data is unknown. Send it in chunked mode.
using (var chunkedStream = new ChunkedWriteStream(Transport))
{
await request.Content.CopyToAsync(chunkedStream);
await chunkedStream.EndContentAsync(cancellationToken);
#if NET5_0_OR_GREATER
await request.Content.CopyToAsync(chunkedStream, cancellationToken).ConfigureAwait(false);
#else
await request.Content.CopyToAsync(chunkedStream).ConfigureAwait(false);
#endif
await chunkedStream.EndContentAsync(cancellationToken).ConfigureAwait(false);
}
}
}

// Receive headers
List<string> responseLines = await ReadResponseLinesAsync(cancellationToken);
List<string> responseLines = await ReadResponseLinesAsync(cancellationToken).ConfigureAwait(false);

// Receive body and determine the response type (Content-Length, Transfer-Encoding, Opaque)
return CreateResponseMessage(responseLines);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ protected override void Dispose(bool disposing)
{
if (disposing)
{
_responseStream.Dispose();
_responseStream?.Dispose();
_connection.Dispose();
}
}
Expand All @@ -71,4 +71,4 @@ protected override void Dispose(bool disposing)
base.Dispose(disposing);
}
}
}
}
Loading