Skip to content

Commit ef6aacf

Browse files
authored
Fix TryOpenInner InvalidCast race and add unit regression tests (#3314) (#4179)
1 parent dc19dcd commit ef6aacf

2 files changed

Lines changed: 125 additions & 1 deletion

File tree

src/Microsoft.Data.SqlClient/src/Microsoft/Data/SqlClient/SqlConnection.cs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2242,7 +2242,18 @@ private bool TryOpenInner(TaskCompletionSource<DbConnectionInternal> retry)
22422242
}
22432243
// does not require GC.KeepAlive(this) because of ReRegisterForFinalize below.
22442244

2245-
var tdsInnerConnection = (SqlConnectionInternal)InnerConnection;
2245+
// Capture InnerConnection once into a local to avoid a TOCTOU race: another thread
2246+
// concurrently calling Open() on the same SqlConnection instance can change
2247+
// _innerConnection to DbConnectionClosedConnecting between the TryOpenConnection()
2248+
// call above and the cast below. Without this local capture the second read of
2249+
// InnerConnection may return DbConnectionClosedConnecting, which is not assignable
2250+
// to SqlConnectionInternal and would produce an opaque InvalidCastException.
2251+
// See GitHub issue #3314.
2252+
var innerConnection = InnerConnection;
2253+
if (innerConnection is not SqlConnectionInternal tdsInnerConnection)
2254+
{
2255+
throw ADP.ConnectionAlreadyOpen(innerConnection.State);
2256+
}
22462257

22472258
Debug.Assert(tdsInnerConnection.Parser != null, "Where's the parser?");
22482259

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for more information.
4+
5+
using System;
6+
using System.Data;
7+
using System.Reflection;
8+
using System.Threading.Tasks;
9+
using Microsoft.Data.ProviderBase;
10+
using Microsoft.Data.SqlClient.Connection;
11+
using Xunit;
12+
13+
namespace Microsoft.Data.SqlClient.UnitTests.Microsoft.Data.SqlClient
14+
{
15+
/// <summary>
16+
/// Regression tests for GitHub issue #3314.
17+
///
18+
/// Root cause: TryOpenInner() read InnerConnection twice - once for TryOpenConnection() and
19+
/// again for the cast to SqlConnectionInternal. Between those two reads another thread could
20+
/// change _innerConnection to DbConnectionClosedConnecting, which is not assignable to
21+
/// SqlConnectionInternal, causing an opaque InvalidCastException.
22+
///
23+
/// Fix: InnerConnection is now captured into a local variable once; if it is not a
24+
/// SqlConnectionInternal an InvalidOperationException with a descriptive message is thrown
25+
/// instead of an InvalidCastException.
26+
/// </summary>
27+
public class SqlConnectionConcurrentOpenTests
28+
{
29+
private static readonly MethodInfo s_tryOpenInner = typeof(SqlConnection)
30+
.GetMethod("TryOpenInner", BindingFlags.Instance | BindingFlags.NonPublic)!;
31+
32+
private static DbConnectionInternal GetConnectingSingleton()
33+
{
34+
return DbConnectionClosedConnecting.SingletonInstance;
35+
}
36+
37+
private static void ForceInnerConnection(SqlConnection connection, DbConnectionInternal innerConnectionValue)
38+
{
39+
connection.SetInnerConnectionTo(innerConnectionValue);
40+
}
41+
42+
private static bool InvokeTryOpenInner(SqlConnection connection, TaskCompletionSource<DbConnectionInternal> retry)
43+
{
44+
try
45+
{
46+
return (bool)s_tryOpenInner.Invoke(connection, [retry])!;
47+
}
48+
catch (TargetInvocationException tie) when (tie.InnerException != null)
49+
{
50+
throw tie.InnerException;
51+
}
52+
}
53+
54+
[Fact]
55+
public void InnerConnection_DbConnectionClosedConnecting_IsNotAssignableToSqlConnectionInternal()
56+
{
57+
DbConnectionInternal connectingSingleton = GetConnectingSingleton();
58+
59+
Assert.False(
60+
connectingSingleton is SqlConnectionInternal,
61+
"DbConnectionClosedConnecting must NOT be assignable to SqlConnectionInternal. " +
62+
"If it were, the race condition in #3314 would not manifest.");
63+
}
64+
65+
[Fact]
66+
public void InnerConnection_InConnectingState_ReportsConnectingState()
67+
{
68+
DbConnectionInternal connectingSingleton = GetConnectingSingleton();
69+
70+
var connection = new SqlConnection("Data Source=localhost");
71+
ForceInnerConnection(connection, connectingSingleton);
72+
73+
Assert.Equal(ConnectionState.Connecting, connection.State);
74+
}
75+
76+
[Fact]
77+
public void Open_WhenAlreadyConnecting_ThrowsInvalidOperation()
78+
{
79+
DbConnectionInternal connectingSingleton = GetConnectingSingleton();
80+
81+
var connection = new SqlConnection("Data Source=localhost");
82+
ForceInnerConnection(connection, connectingSingleton);
83+
84+
Assert.Throws<InvalidOperationException>(() => connection.Open());
85+
}
86+
87+
[Fact]
88+
public void TryOpenInner_WhenInnerConnectionRacesToNonSqlConnectionInternalState_ThrowsInvalidOperation_NotInvalidCast()
89+
{
90+
DbConnectionInternal initialConnectingState = GetConnectingSingleton();
91+
DbConnectionInternal racedNonSqlConnectionInternalState = DbConnectionOpenBusy.SingletonInstance;
92+
93+
var connection = new SqlConnection("Data Source=localhost");
94+
ForceInnerConnection(connection, initialConnectingState);
95+
96+
TaskCompletionSource<DbConnectionInternal> completedRetry = new();
97+
completedRetry.SetResult(racedNonSqlConnectionInternalState);
98+
99+
Exception ex = Assert.ThrowsAny<Exception>(() =>
100+
{
101+
InvokeTryOpenInner(connection, completedRetry);
102+
});
103+
104+
Assert.True(
105+
ex is InvalidOperationException,
106+
$"Expected InvalidOperationException but got {ex.GetType().Name}: {ex.Message}. " +
107+
"The fix for #3314 must throw InvalidOperationException (not InvalidCastException) " +
108+
"when _innerConnection races to a non-SqlConnectionInternal state inside TryOpenInner.");
109+
110+
Assert.Contains("connection", ex.Message, StringComparison.OrdinalIgnoreCase);
111+
}
112+
}
113+
}

0 commit comments

Comments
 (0)