Skip to content

Commit 0ff26f3

Browse files
committed
Mid-session USE over TDS no longer hangs SqlClient: the database-change ENVCHANGE + INFO 5701 are now written before the response's final DONE, matching real SQL Server's token order.
1 parent 417710b commit 0ff26f3

4 files changed

Lines changed: 178 additions & 12 deletions

File tree

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
using Microsoft.Data.SqlClient;
2+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
3+
4+
namespace SqlServerSimulator;
5+
6+
/// <summary>
7+
/// Mid-session <c>USE</c> over the wire. The database-change ENVCHANGE (+
8+
/// INFO 5701) must precede the response's final DONE — SqlClient's token
9+
/// reader stalls until command timeout on an ENVCHANGE that arrives after
10+
/// the last DONE (probe-confirmed 2026-07-15; go-mssqldb tolerates the late
11+
/// position, so only a real-SqlClient oracle catches the ordering).
12+
/// </summary>
13+
[TestClass]
14+
public sealed class UseDatabaseTests
15+
{
16+
public TestContext TestContext { get; set; } = null!;
17+
18+
[TestMethod]
19+
public async Task UseMaster_ExecuteNonQuery_SwitchesDatabase()
20+
{
21+
var simulation = new Simulation();
22+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
23+
await using var connection = await Wire.OpenAsync(listener, TestContext.CancellationToken);
24+
25+
await using (var use = new SqlCommand("use [master]", connection))
26+
_ = await use.ExecuteNonQueryAsync(TestContext.CancellationToken);
27+
28+
AreEqual("master", connection.Database);
29+
await using var query = new SqlCommand("select db_name()", connection);
30+
AreEqual("master", await query.ExecuteScalarAsync(TestContext.CancellationToken));
31+
}
32+
33+
[TestMethod]
34+
public async Task UseMaster_RaisesChangedDatabaseContextInfoMessage()
35+
{
36+
var simulation = new Simulation();
37+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
38+
await using var connection = await Wire.OpenAsync(listener, TestContext.CancellationToken);
39+
40+
var messages = new List<string>();
41+
connection.InfoMessage += (_, e) => messages.Add(e.Message);
42+
await using var use = new SqlCommand("use [master]", connection);
43+
_ = await use.ExecuteNonQueryAsync(TestContext.CancellationToken);
44+
45+
Contains("Changed database context to 'master'.", messages);
46+
}
47+
48+
[TestMethod]
49+
public async Task UseInMultiStatementBatch_WithResultSet()
50+
{
51+
var simulation = new Simulation();
52+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
53+
await using var connection = await Wire.OpenAsync(listener, TestContext.CancellationToken);
54+
55+
await using var command = new SqlCommand("use [master] select db_name()", connection);
56+
AreEqual("master", await command.ExecuteScalarAsync(TestContext.CancellationToken));
57+
AreEqual("master", connection.Database);
58+
}
59+
60+
[TestMethod]
61+
public async Task UseViaRpc_ParameterizedCommand_SwitchesDatabase()
62+
{
63+
var simulation = new Simulation();
64+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
65+
await using var connection = await Wire.OpenAsync(listener, TestContext.CancellationToken);
66+
67+
// A parameter forces the sp_executesql RPC path instead of SQLBatch.
68+
await using var command = new SqlCommand("use [master] select @x", connection);
69+
_ = command.Parameters.AddWithValue("@x", 1);
70+
AreEqual(1, await command.ExecuteScalarAsync(TestContext.CancellationToken));
71+
AreEqual("master", connection.Database);
72+
}
73+
74+
[TestMethod]
75+
public async Task UseThenError_StillSwitchesDatabase()
76+
{
77+
var simulation = new Simulation();
78+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
79+
await using var connection = await Wire.OpenAsync(listener, TestContext.CancellationToken);
80+
81+
await using (var command = new SqlCommand("use [master] select * from nosuchtable", connection))
82+
{
83+
var ex = await ThrowsExactlyAsync<SqlException>(() => command.ExecuteScalarAsync(TestContext.CancellationToken));
84+
AreEqual(208, ex.Number);
85+
}
86+
87+
AreEqual("master", connection.Database);
88+
await using var query = new SqlCommand("select db_name()", connection);
89+
AreEqual("master", await query.ExecuteScalarAsync(TestContext.CancellationToken));
90+
}
91+
92+
[TestMethod]
93+
public async Task UseRoundTrip_MasterAndBack()
94+
{
95+
var simulation = new Simulation();
96+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
97+
await using var connection = await Wire.OpenAsync(listener, TestContext.CancellationToken);
98+
99+
await using (var toMaster = new SqlCommand("use [master]", connection))
100+
_ = await toMaster.ExecuteNonQueryAsync(TestContext.CancellationToken);
101+
await using (var back = new SqlCommand("use [simulated]", connection))
102+
_ = await back.ExecuteNonQueryAsync(TestContext.CancellationToken);
103+
104+
AreEqual("simulated", connection.Database);
105+
await using var query = new SqlCommand("select db_name()", connection);
106+
AreEqual("simulated", await query.ExecuteScalarAsync(TestContext.CancellationToken));
107+
}
108+
109+
[TestMethod]
110+
public async Task UseMissingDatabase_RaisesMsg911_SessionSurvives()
111+
{
112+
var simulation = new Simulation();
113+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
114+
await using var connection = await Wire.OpenAsync(listener, TestContext.CancellationToken);
115+
116+
await using (var command = new SqlCommand("use [nosuchdb]", connection))
117+
{
118+
var ex = await ThrowsExactlyAsync<SqlException>(() => command.ExecuteNonQueryAsync(TestContext.CancellationToken));
119+
AreEqual(911, ex.Number);
120+
}
121+
122+
AreEqual("simulated", connection.Database);
123+
await using var query = new SqlCommand("select db_name()", connection);
124+
AreEqual("simulated", await query.ExecuteScalarAsync(TestContext.CancellationToken));
125+
}
126+
}

SqlServerSimulator/Network/TdsSession.Rpc.cs

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ private async ValueTask ExecuteRpcMessageAsync(TdsMessage message, TdsTokenWrite
4646
return;
4747
}
4848

49-
var databaseBefore = this.connection!.Database;
49+
this.databaseAtMessageStart = this.connection!.Database;
5050
for (var i = 0; i < requests.Count; i++)
5151
{
5252
var moreRequests = i < requests.Count - 1;
@@ -58,19 +58,19 @@ private async ValueTask ExecuteRpcMessageAsync(TdsMessage message, TdsTokenWrite
5858
{
5959
_ = this.FlushInfoMessages(writer);
6060
WriteErrors(writer, ex);
61+
if (!moreRequests)
62+
this.WriteDatabaseChangeIfAny(writer);
6163
writer.WriteDoneToken(Tds.TokenDoneProc, (ushort)(Tds.DoneError | (moreRequests ? Tds.DoneMore : Tds.DoneFinal)), 0);
6264
}
6365
catch (NotSupportedException ex)
6466
{
6567
_ = this.FlushInfoMessages(writer);
6668
writer.WriteErrorOrInfo(Tds.TokenError, 50000, 1, 16, $"SqlServerSimulator: {ex.Message}", "SIMULATED", "", 1);
69+
if (!moreRequests)
70+
this.WriteDatabaseChangeIfAny(writer);
6771
writer.WriteDoneToken(Tds.TokenDoneProc, (ushort)(Tds.DoneError | (moreRequests ? Tds.DoneMore : Tds.DoneFinal)), 0);
6872
}
6973
}
70-
71-
var databaseAfter = this.connection.Database;
72-
if (!string.Equals(databaseAfter, databaseBefore, StringComparison.Ordinal))
73-
writer.WriteEnvChange(Tds.EnvDatabase, databaseAfter, databaseBefore);
7474
}
7575

7676
private async ValueTask DispatchRpcAsync(TdsRpcRequest request, TdsTokenWriter writer, bool moreRequests, CancellationToken cancellationToken)
@@ -180,6 +180,8 @@ private async ValueTask ExecuteStatementRpcAsync(
180180
foreach (var (ordinal, wire, bound) in outputs)
181181
TdsTypeCodec.WriteReturnValue(writer, checked((ushort)ordinal), wire.Name, wire.DbType, bound.Value);
182182

183+
if (!moreRequests)
184+
this.WriteDatabaseChangeIfAny(writer);
183185
writer.WriteDoneToken(Tds.TokenDoneProc, moreRequests ? Tds.DoneMore : Tds.DoneFinal, 0);
184186
}
185187

@@ -212,6 +214,8 @@ private async ValueTask ExecuteProcedureRpcAsync(TdsRpcRequest request, TdsToken
212214
foreach (var (ordinal, wire, bound) in outputs)
213215
TdsTypeCodec.WriteReturnValue(writer, checked((ushort)ordinal), wire.Name, wire.DbType, bound.Value);
214216

217+
if (!moreRequests)
218+
this.WriteDatabaseChangeIfAny(writer);
215219
writer.WriteDoneToken(Tds.TokenDoneProc, moreRequests ? Tds.DoneMore : Tds.DoneFinal, 0);
216220
}
217221

SqlServerSimulator/Network/TdsSession.cs

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ private async ValueTask ExecuteBatchAsync(TdsMessage message, TdsTokenWriter wri
206206
writer.WriteResetConnectionAck();
207207
}
208208

209-
var databaseBefore = this.connection!.Database;
209+
this.databaseAtMessageStart = this.connection!.Database;
210210
try
211211
{
212212
using var command = this.connection.CreateCommand();
@@ -219,19 +219,46 @@ private async ValueTask ExecuteBatchAsync(TdsMessage message, TdsTokenWriter wri
219219
{
220220
_ = this.FlushInfoMessages(writer);
221221
WriteErrors(writer, ex);
222+
this.WriteDatabaseChangeIfAny(writer);
222223
writer.WriteDone(Tds.DoneError, 0);
223224
}
224225
catch (NotSupportedException ex)
225226
{
226227
_ = this.FlushInfoMessages(writer);
227228
writer.WriteErrorOrInfo(Tds.TokenError, 50000, 1, 16, $"SqlServerSimulator: {ex.Message}", "SIMULATED", "", 1);
229+
this.WriteDatabaseChangeIfAny(writer);
228230
writer.WriteDone(Tds.DoneError, 0);
229231
}
232+
}
230233

231-
_ = this.FlushInfoMessages(writer);
232-
var databaseAfter = this.connection.Database;
233-
if (!string.Equals(databaseAfter, databaseBefore, StringComparison.Ordinal))
234-
writer.WriteEnvChange(Tds.EnvDatabase, databaseAfter, databaseBefore);
234+
/// <summary>
235+
/// The session database when the current batch / RPC message began, for
236+
/// detecting a mid-message <c>USE</c>. Emitted as ENVCHANGE type 1 +
237+
/// INFO 5701 via <see cref="WriteDatabaseChangeIfAny"/>, which must run
238+
/// BEFORE the response's final DONE: SqlClient's token reader stalls
239+
/// until command timeout on an ENVCHANGE that arrives after the last
240+
/// DONE (probe-confirmed 2026-07-15 — the SSMS freeze on
241+
/// <c>use [master]</c>; go-mssqldb tolerates the late position, which is
242+
/// how the ordering shipped unnoticed).
243+
/// </summary>
244+
private string? databaseAtMessageStart;
245+
246+
/// <summary>
247+
/// Writes the database-change ENVCHANGE + INFO 5701 when the session
248+
/// database differs from <see cref="databaseAtMessageStart"/>, matching
249+
/// real SQL Server's token order for <c>USE</c> (ENVCHANGE, then INFO,
250+
/// then the statement's DONE). Idempotent — the first call records the
251+
/// new baseline, so the multiple call sites (per-final-DONE seams and
252+
/// error paths) emit at most once per change.
253+
/// </summary>
254+
private void WriteDatabaseChangeIfAny(TdsTokenWriter writer)
255+
{
256+
var current = this.connection!.Database;
257+
if (this.databaseAtMessageStart is null || string.Equals(current, this.databaseAtMessageStart, StringComparison.Ordinal))
258+
return;
259+
writer.WriteEnvChange(Tds.EnvDatabase, current, this.databaseAtMessageStart);
260+
writer.WriteErrorOrInfo(Tds.TokenInfo, 5701, 2, 0, $"Changed database context to '{current}'.", "SIMULATED", "", 1);
261+
this.databaseAtMessageStart = current;
235262
}
236263

237264
/// <summary>
@@ -276,7 +303,10 @@ private async ValueTask StreamOutcomesAsync(SimulatedDbCommand command, TdsToken
276303
}
277304

278305
hasOutcome = outcomes.MoveNext();
279-
writer.WriteDoneToken(doneToken, (ushort)(this.OutcomeDoneStatus(hasOutcome, trailingTokensFollow) | Tds.DoneCount), rows);
306+
var queryStatus = (ushort)(this.OutcomeDoneStatus(hasOutcome, trailingTokensFollow) | Tds.DoneCount);
307+
if ((queryStatus & Tds.DoneMore) == 0)
308+
this.WriteDatabaseChangeIfAny(writer);
309+
writer.WriteDoneToken(doneToken, queryStatus, rows);
280310
}
281311
else
282312
{
@@ -286,16 +316,22 @@ private async ValueTask StreamOutcomesAsync(SimulatedDbCommand command, TdsToken
286316
if (affected >= 0)
287317
status |= Tds.DoneCount;
288318

319+
if ((status & Tds.DoneMore) == 0)
320+
this.WriteDatabaseChangeIfAny(writer);
289321
writer.WriteDoneToken(doneToken, status, Math.Max(affected, 0));
290322
}
291323
}
292324

293325
// Trailing messages (batch ends in PRINT): INFO may never follow the
294326
// final DONE, so the last outcome's DONE stayed DONE_MORE (see
295327
// OutcomeDoneStatus) and the batch closes with its own final DONE.
328+
// A mid-batch USE's ENVCHANGE must likewise precede the final DONE.
296329
var flushedTrailing = this.FlushInfoMessages(writer);
297330
if (!trailingTokensFollow && (flushedTrailing || !anyOutcome))
331+
{
332+
this.WriteDatabaseChangeIfAny(writer);
298333
writer.WriteDoneToken(doneToken, Tds.DoneFinal, 0);
334+
}
299335
}
300336

301337
/// <summary>

docs/claude/tds-endpoint.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ Everything lives in `Network/` (internal) except the public `SimulatedNetworkLis
2828
- **SQLBatch** (type 1): ALL_HEADERS skipped via its leading length DWORD; UCS-2 text executed on the session connection. Per result set: COLMETADATA + ROW stream + DONE (`DONE_COUNT` + `DONE_MORE` when more outcomes follow); per non-query statement: DONE with `DONE_COUNT` only when `RecordsAffected >= 0`. Zero outcomes → single final DONE. All tokens are `0xFD DONE` (DONEPROC/DONEINPROC are proc-scoped and RPC isn't shipped).
2929
- **Errors**: `SimulatedSqlException.Errors` map field-for-field onto ERROR tokens (number/state/class/message/server/procedure/line) + DONE with `DONE_ERROR`; the session survives and keeps serving. `NotSupportedException` becomes a synthetic ERROR number 50000 class 16 prefixed `SqlServerSimulator:`.
3030
- **PRINT / low-severity RAISERROR**: the session subscribes to `SimulatedDbConnection.InfoMessage` and drains the queue as INFO tokens between statements and at batch end. **Each INFO flush in a SQLBatch response gets its own DONE**, mirroring real per-statement DONEs: info preceding an outcome is followed by `DONE_MORE` count 0 before the outcome's tokens, and trailing info (batch ends in PRINT) forces the last outcome's DONE to `DONE_MORE` with a closing `DONE_FINAL` count 0 after the INFO — an INFO token must never follow the final DONE. Without both, SqlClient's token reader stalls until command timeout on any batch mixing PRINT with a result set, and go-mssqldb silently drops the message (go-sqlcmd shakedown, 2026-07-14; the pre-fix oracle only covered PRINT-without-result-set). RPC responses are unaffected — every DONEINPROC already carries `DONE_MORE`.
31-
- **`USE`**: database change detected by before/after comparison and emitted as ENVCHANGE type 1 (after the DONEs, before flush — SqlClient processes it anywhere pre-EOM).
31+
- **`USE`**: database change detected by comparing the session database against its value at message start (`databaseAtMessageStart`) and emitted as ENVCHANGE type 1 + INFO 5701 (`Changed database context to '<db>'.`) **before the response's final DONE** — the seam fires at every final-DONE site (batch outcome DONEs, the closing DONE, error-path DONEs, and the RPC handlers' final DONEPROC) and is idempotent, so it emits at most once per message. Ordering is load-bearing: SqlClient's token reader stalls until command timeout on an ENVCHANGE that arrives after the last DONE (probe-confirmed 2026-07-15 — this froze SSMS on its first `use [master]` once master existed; go-mssqldb tolerates the late position, which is how the original after-the-DONEs ordering shipped unnoticed). The INFO 5701 is wire-layer-only — the in-process engine raises no InfoMessage for `USE`, a minor in-process/wire asymmetry matching the login response's synthesized 5701.
3232
- **Reset-connection status bit** (pooled-connection recycle): backing connection disposed and recreated on the same database, acked with the empty ENVCHANGE type 18 before the batch's tokens.
3333
- **Attention** (type 6): acked with DONE `DONE_ATTN`. Execution is synchronous per message, so attention is only observed between messages — a cancel never interrupts a running statement server-side, it just gets acked when the stream drains. In-process execution is fast enough that this matches observable SqlClient behavior.
3434
- **Bulk-load (7)**: ERROR 50000 naming the unsupported request type + DONE error (`SqlBulkCopy` is a planned follow-up).

0 commit comments

Comments
 (0)