1313
1414using System ;
1515using System . Collections . Generic ;
16+ using System . Net . Sockets ;
1617using System . Threading ;
1718using System . Threading . Tasks ;
1819using Dapr . Testcontainers . Common ;
@@ -48,6 +49,9 @@ public sealed class DaprdContainer : IAsyncStartable
4849 /// </summary>
4950 public int GrpcPort { get ; private set ; }
5051
52+ private readonly int ? _requestedHttpPort ;
53+ private readonly int ? _requestedGrpcPort ;
54+
5155 /// <summary>
5256 /// The hostname to locate the Dapr runtime on in the shared Docker network.
5357 /// </summary>
@@ -62,8 +66,22 @@ public sealed class DaprdContainer : IAsyncStartable
6266 /// <param name="network">The shared Docker network to connect to.</param>
6367 /// <param name="placementHostAndPort">The hostname and port of the Placement service.</param>
6468 /// <param name="schedulerHostAndPort">The hostname and port of the Scheduler service.</param>
65- public DaprdContainer ( string appId , string componentsHostFolder , DaprRuntimeOptions options , INetwork network , HostPortPair ? placementHostAndPort = null , HostPortPair ? schedulerHostAndPort = null )
69+ /// <param name="daprHttpPort">The host HTTP port to bind to.</param>
70+ /// <param name="daprGrpcPort">The host gRPC port to bind to.</param>
71+ public DaprdContainer (
72+ string appId ,
73+ string componentsHostFolder ,
74+ DaprRuntimeOptions options ,
75+ INetwork network ,
76+ HostPortPair ? placementHostAndPort = null ,
77+ HostPortPair ? schedulerHostAndPort = null ,
78+ int ? daprHttpPort = null ,
79+ int ? daprGrpcPort = null
80+ )
6681 {
82+ _requestedHttpPort = daprHttpPort ;
83+ _requestedGrpcPort = daprGrpcPort ;
84+
6785 const string componentsPath = "/components" ;
6886 var cmd =
6987 new List < string >
@@ -102,28 +120,89 @@ public DaprdContainer(string appId, string componentsHostFolder, DaprRuntimeOpti
102120 cmd . Add ( "" ) ;
103121 }
104122
105- _container = new ContainerBuilder ( )
123+ var containerBuilder = new ContainerBuilder ( )
106124 . WithImage ( options . RuntimeImageTag )
107125 . WithName ( _containerName )
108126 . WithLogger ( ConsoleLogger . Instance )
109127 . WithCommand ( cmd . ToArray ( ) )
110128 . WithNetwork ( network )
111129 . WithExtraHost ( ContainerHostAlias , "host-gateway" )
112- . WithPortBinding ( InternalHttpPort , assignRandomHostPort : true )
113- . WithPortBinding ( InternalGrpcPort , assignRandomHostPort : true )
114130 . WithBindMount ( componentsHostFolder , componentsPath , AccessMode . ReadOnly )
115131 . WithWaitStrategy ( Wait . ForUnixContainer ( )
116- . UntilMessageIsLogged ( "Internal gRPC server is running" ) )
132+ . UntilMessageIsLogged ( "Internal gRPC server is running" ) ) ;
117133 //.UntilMessageIsLogged(@"^dapr initialized. Status: Running. Init Elapsed "))
118- . Build ( ) ;
134+
135+ containerBuilder = daprHttpPort is not null ? containerBuilder . WithPortBinding ( containerPort : InternalHttpPort , hostPort : daprHttpPort . Value ) : containerBuilder . WithPortBinding ( port : InternalHttpPort , assignRandomHostPort : true ) ;
136+ containerBuilder = daprGrpcPort is not null ? containerBuilder . WithPortBinding ( containerPort : InternalGrpcPort , hostPort : daprGrpcPort . Value ) : containerBuilder . WithPortBinding ( port : InternalGrpcPort , assignRandomHostPort : true ) ;
137+
138+ _container = containerBuilder . Build ( ) ;
119139 }
120140
121141 /// <inheritdoc />
122142 public async Task StartAsync ( CancellationToken cancellationToken = default )
123143 {
124144 await _container . StartAsync ( cancellationToken ) ;
125- HttpPort = _container . GetMappedPublicPort ( InternalHttpPort ) ;
126- GrpcPort = _container . GetMappedPublicPort ( InternalGrpcPort ) ;
145+
146+ var mappedHttpPort = _container . GetMappedPublicPort ( InternalHttpPort ) ;
147+ var mappedGrpcPort = _container . GetMappedPublicPort ( InternalGrpcPort ) ;
148+
149+ if ( _requestedHttpPort is not null && mappedHttpPort != _requestedHttpPort . Value )
150+ {
151+ throw new InvalidOperationException (
152+ $ "Dapr HTTP port mapping mismatch. Requested { _requestedHttpPort . Value } , but Docker mapped { mappedHttpPort } ") ;
153+ }
154+
155+ if ( _requestedGrpcPort is not null && mappedGrpcPort != _requestedGrpcPort . Value )
156+ {
157+ throw new InvalidOperationException (
158+ $ "Dapr gRPC port mapping mismatch. Requested { _requestedGrpcPort . Value } , but Docker mapped { mappedGrpcPort } ") ;
159+ }
160+
161+ HttpPort = mappedHttpPort ;
162+ GrpcPort = mappedGrpcPort ;
163+
164+ // The container log wait strategy can fire before the host port is actually accepting connections
165+ // (especially on Windows). Ensure the ports are reachable from the test process.
166+ await WaitForTcpPortAsync ( "127.0.0.1" , HttpPort , TimeSpan . FromSeconds ( 30 ) , cancellationToken ) ;
167+ await WaitForTcpPortAsync ( "127.0.0.1" , GrpcPort , TimeSpan . FromSeconds ( 30 ) , cancellationToken ) ;
168+ }
169+
170+ private static async Task WaitForTcpPortAsync (
171+ string host ,
172+ int port ,
173+ TimeSpan timeout ,
174+ CancellationToken cancellationToken )
175+ {
176+ var start = DateTimeOffset . UtcNow ;
177+ Exception ? lastError = null ;
178+
179+ while ( DateTimeOffset . UtcNow - start < timeout )
180+ {
181+ cancellationToken . ThrowIfCancellationRequested ( ) ;
182+
183+ try
184+ {
185+ using var client = new TcpClient ( ) ;
186+ var connectTask = client . ConnectAsync ( host , port ) ;
187+
188+ var completed = await Task . WhenAny ( connectTask ,
189+ Task . Delay ( TimeSpan . FromMilliseconds ( 250 ) , cancellationToken ) ) ;
190+ if ( completed == connectTask )
191+ {
192+ // Will throw if connect failed
193+ await connectTask ;
194+ return ;
195+ }
196+ }
197+ catch ( Exception ex ) when ( ex is SocketException or InvalidOperationException )
198+ {
199+ lastError = ex ;
200+ }
201+
202+ await Task . Delay ( TimeSpan . FromMilliseconds ( 200 ) , cancellationToken ) ;
203+ }
204+
205+ throw new TimeoutException ( $ "Timed out waiting for TCP port { host } :{ port } to accept connections.", lastError ) ;
127206 }
128207
129208 /// <inheritdoc />
0 commit comments