Skip to content

Commit cc3a5e6

Browse files
committed
MARS ships over the TDS endpoint: prelogin acks MARS only when requested, post-login traffic runs through an SMP multiplexer whose send window advances from every inbound frame's WNDW field and logical sessions execute under serialized cooperative multiplexing preserving the engine's one-executor-per-connection contract, with parity for shared SPID/temp-table/SET/transaction state across overlapping commands, per-session attention isolation, and EF Core lazy-loading via UseLazyLoadingProxies covered in-process and over the wire. SMP frame shape pinned to a cleartext capture of the real server's mux (no server SYN, piggybacked windows, mid-message-only ACKs), the deviation set that native SNI rejects with SMux error 19 but managed SNI tolerates.
1 parent 782ed7a commit cc3a5e6

16 files changed

Lines changed: 1301 additions & 10 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ Per-feature deep-dives live under `docs/claude/`. Each entry below is a trigger:
165165
- **New top-level statement parser or dispatch-loop separator rules, double-quoted identifiers / QUOTED_IDENTIFIER**[`grammar.md`](docs/claude/grammar.md) + [`control-flow.md`](docs/claude/control-flow.md).
166166
- **BACPAC import** (`Simulation.ImportBacpac` — multi-database via repeated calls, `BacpacImportOptions`, `ModelXmlReader` dispatcher, BCP wire format, `BacpacBuilder` test harness) → [`bacpac-loader.md`](docs/claude/bacpac-loader.md).
167167
- **Linked servers** (`Simulation.AddRemoteSimulation`, `sp_addlinkedserver` / `sp_dropserver`, four-part FROM routing through the remote's ADO.NET pipeline, `OPENQUERY(server,'query')` ad-hoc pass-through + compile-time schema discovery, `sys.servers`) → [`linked-servers.md`](docs/claude/linked-servers.md).
168-
- **TDS network endpoint** (`Simulation.ListenAsync``SimulatedNetworkListener`; real SqlClient over loopback TCP+TLS; SQLBatch/RPC/TM/BulkLoad (`SqlBulkCopy`); TVP (`0xF3` `SqlDbType.Structured`) RPC params via the shared `TdsColumnDecoder`; `sp_cursor*` API-server-cursor RPC family; mid-stream attention (cancel / `CommandTimeout`) via a carry-forward concurrent read → `DONE_ATTN` ack + probed XACT_ABORT tx semantics; credential enforcement via the `CREATE LOGIN` registry, Msg 18456 on mismatch; EF via plain `UseSqlServer`; oracles = `*.Tests.SqlClient` + `*.Tests.Smo`, the real-SMO consumer oracle) → [`tds-endpoint.md`](docs/claude/tds-endpoint.md).
168+
- **TDS network endpoint** (`Simulation.ListenAsync``SimulatedNetworkListener`; real SqlClient over loopback TCP+TLS; SQLBatch/RPC/TM/BulkLoad (`SqlBulkCopy`); TVP (`0xF3` `SqlDbType.Structured`) RPC params via the shared `TdsColumnDecoder`; `sp_cursor*` API-server-cursor RPC family; mid-stream attention (cancel / `CommandTimeout`) via a carry-forward concurrent read → `DONE_ATTN` ack + probed XACT_ABORT tx semantics; MARS (`MultipleActiveResultSets=True`) via an SMP demux/mux over one shared connection with serialized cooperative multiplexing + per-session attention; credential enforcement via the `CREATE LOGIN` registry, Msg 18456 on mismatch; EF via plain `UseSqlServer`; oracles = `*.Tests.SqlClient` + `*.Tests.Smo`, the real-SMO consumer oracle) → [`tds-endpoint.md`](docs/claude/tds-endpoint.md).
169169

170170
## Not modeled
171171

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
using Microsoft.EntityFrameworkCore;
2+
using SqlServerSimulator.EFCore;
3+
4+
namespace SqlServerSimulator;
5+
6+
/// <summary>
7+
/// EF Core lazy-loading proxies over the simulator — the canonical MARS
8+
/// (Multiple Active Result Sets) shape: iterating a parent query keeps its
9+
/// reader open while touching a navigation property fires a second query per
10+
/// row. Under vanilla <c>UseSqlServer</c> that throws "There is already an open
11+
/// DataReader" unless MARS is enabled; the in-process
12+
/// <see cref="SimulatedDbConnection"/> permits the overlap natively (a MARS-
13+
/// enabled superset), and the wire endpoint now negotiates MARS so plain
14+
/// <c>UseSqlServer</c> with <c>MultipleActiveResultSets=True</c> works too.
15+
/// The same model also exercises <c>AsSplitQuery</c> and a nested foreach over
16+
/// two independent queries — both natural MARS shapes.
17+
/// </summary>
18+
[TestClass]
19+
public class EFCoreLazyLoadMars
20+
{
21+
public TestContext TestContext { get; set; } = null!;
22+
23+
// Lazy-loading proxies subclass the entities at runtime, so they must be
24+
// public/protected-constructible and their navigations virtual.
25+
public class Blog
26+
{
27+
public int Id { get; set; }
28+
29+
public string Name { get; set; } = "";
30+
31+
public virtual List<Post> Posts { get; set; } = [];
32+
}
33+
34+
public class Post
35+
{
36+
public int Id { get; set; }
37+
38+
public string Title { get; set; } = "";
39+
40+
public int BlogId { get; set; }
41+
42+
public virtual Blog? Blog { get; set; }
43+
}
44+
45+
private sealed class BlogContext(Action<DbContextOptionsBuilder> configure) : DbContext
46+
{
47+
public DbSet<Blog> Blogs => this.Set<Blog>();
48+
49+
public DbSet<Post> Posts => this.Set<Post>();
50+
51+
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) =>
52+
configure(optionsBuilder.UseLazyLoadingProxies());
53+
54+
protected override void OnModelCreating(ModelBuilder modelBuilder)
55+
{
56+
_ = modelBuilder.Entity<Blog>(b =>
57+
{
58+
_ = b.Property(x => x.Id).ValueGeneratedNever();
59+
_ = b.Property(x => x.Name).HasColumnType("nvarchar(50)");
60+
});
61+
_ = modelBuilder.Entity<Post>(p =>
62+
{
63+
_ = p.Property(x => x.Id).ValueGeneratedNever();
64+
_ = p.Property(x => x.Title).HasColumnType("nvarchar(50)");
65+
});
66+
}
67+
}
68+
69+
private static void Seed(Simulation simulation)
70+
{
71+
_ = simulation
72+
.CreateOpenConnection()
73+
.CreateCommand("""
74+
create table Blogs (Id int not null primary key, Name nvarchar(50) not null);
75+
create table Posts (Id int not null primary key, Title nvarchar(50) not null, BlogId int not null);
76+
insert into Blogs values (1, 'Tech'), (2, 'Food'), (3, 'Travel');
77+
insert into Posts values
78+
(10, 'A', 1), (11, 'B', 1), (12, 'C', 1),
79+
(20, 'D', 2), (21, 'E', 2),
80+
(30, 'F', 3);
81+
""")
82+
.ExecuteNonQuery();
83+
}
84+
85+
private static Dictionary<int, int> ExpectedCounts() => new() { [1] = 3, [2] = 2, [3] = 1 };
86+
87+
[TestMethod]
88+
public void InProcess_LazyLoad_NavigationPerRow_WhileReaderOpen()
89+
{
90+
var simulation = new Simulation();
91+
Seed(simulation);
92+
using var context = new BlogContext(o => o.UseSqlServerSimulator(simulation.CreateDbConnection()));
93+
94+
var counts = new Dictionary<int, int>();
95+
// Streaming the outer query keeps its reader open; touching Posts fires a
96+
// lazy-load query per blog on the same connection — the overlap the
97+
// in-process connection permits natively.
98+
foreach (var blog in context.Blogs.OrderBy(b => b.Id))
99+
counts[blog.Id] = blog.Posts.Count;
100+
101+
CollectionAssert.AreEquivalent(ExpectedCounts(), counts);
102+
}
103+
104+
[TestMethod]
105+
public async Task OverWire_LazyLoad_NavigationPerRow_RequiresMars()
106+
{
107+
var simulation = new Simulation();
108+
Seed(simulation);
109+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
110+
var connectionString =
111+
$"Server=127.0.0.1,{listener.Port};User ID=sa;Password=x;TrustServerCertificate=True;" +
112+
"MultipleActiveResultSets=True;Connect Timeout=15";
113+
114+
using var context = new BlogContext(o => o.UseSqlServer(connectionString));
115+
116+
var counts = new Dictionary<int, int>();
117+
foreach (var blog in context.Blogs.OrderBy(b => b.Id))
118+
counts[blog.Id] = blog.Posts.Count;
119+
120+
CollectionAssert.AreEquivalent(ExpectedCounts(), counts);
121+
}
122+
123+
[TestMethod]
124+
public async Task OverWire_SplitQuery_LoadsCollectionsAcrossSessions()
125+
{
126+
var simulation = new Simulation();
127+
Seed(simulation);
128+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
129+
var connectionString =
130+
$"Server=127.0.0.1,{listener.Port};User ID=sa;Password=x;TrustServerCertificate=True;" +
131+
"MultipleActiveResultSets=True;Connect Timeout=15";
132+
133+
using var context = new BlogContext(o => o.UseSqlServer(connectionString));
134+
135+
var blogs = await context.Blogs
136+
.Include(b => b.Posts)
137+
.AsSplitQuery()
138+
.OrderBy(b => b.Id)
139+
.ToListAsync(TestContext.CancellationToken);
140+
141+
var counts = blogs.ToDictionary(b => b.Id, b => b.Posts.Count);
142+
CollectionAssert.AreEquivalent(ExpectedCounts(), counts);
143+
}
144+
145+
[TestMethod]
146+
public async Task OverWire_NestedForeachOverTwoQueries_OnOneContext()
147+
{
148+
var simulation = new Simulation();
149+
Seed(simulation);
150+
await using var listener = await simulation.ListenAsync(0, TestContext.CancellationToken);
151+
var connectionString =
152+
$"Server=127.0.0.1,{listener.Port};User ID=sa;Password=x;TrustServerCertificate=True;" +
153+
"MultipleActiveResultSets=True;Connect Timeout=15";
154+
155+
using var context = new BlogContext(o => o.UseSqlServer(connectionString));
156+
157+
var pairs = new List<string>();
158+
// Two independent streaming queries interleaved on one context — the
159+
// inner reader opens while the outer reader is still draining.
160+
foreach (var blog in context.Blogs.OrderBy(b => b.Id))
161+
{
162+
foreach (var post in context.Posts.Where(p => p.BlogId == blog.Id).OrderBy(p => p.Id))
163+
pairs.Add($"{blog.Id}:{post.Id}");
164+
}
165+
166+
CollectionAssert.AreEqual(
167+
new[] { "1:10", "1:11", "1:12", "2:20", "2:21", "3:30" },
168+
pairs);
169+
}
170+
}

SqlServerSimulator.Tests.EFCore/SqlServerSimulator.Tests.EFCore.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
2424
<PrivateAssets>all</PrivateAssets>
2525
</PackageReference>
26+
<PackageReference Include="Microsoft.EntityFrameworkCore.Proxies" Version="10.0.2" />
2627
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.2" />
2728
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.3.0" />
2829
<PackageReference Include="MSTest.TestAdapter" Version="4.1.0" />
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
using System.Buffers.Binary;
2+
using System.Net;
3+
using System.Net.Sockets;
4+
using SqlServerSimulator.Network;
5+
using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert;
6+
7+
namespace SqlServerSimulator;
8+
9+
/// <summary>
10+
/// Frame-level regression guard pinning the server-to-client SMP (Session
11+
/// Multiplex Protocol) shape the real SQL Server 2025 emits — captured
12+
/// cleartext (Encrypt=False login-only encryption) via a tee proxy on
13+
/// 2026-07-18. The critical facts, and why they matter: Windows native SNI
14+
/// validates SMUX strictly and drops the physical connection ("Physical
15+
/// connection is not usable", SMux error 19) on shapes managed SNI (Linux)
16+
/// tolerates. These assertions fail on Linux if the multiplexer regresses,
17+
/// rather than only surfacing on a Windows host.
18+
/// <list type="bullet">
19+
/// <item>The server sends NO SYN frame — a client SYN opens a session and the
20+
/// server's first frame on it is the DATA response.</item>
21+
/// <item>A complete (EOM) request gets NO standalone ACK; its DATA response
22+
/// piggybacks the advanced receive window (received + slack).</item>
23+
/// <item>A mid-message (EOM-clear) packet of a multi-packet request DOES get a
24+
/// standalone ACK advancing the window, since no response comes until the whole
25+
/// message arrives.</item>
26+
/// <item>Session close echoes a FIN whose SEQNUM is the last DATA sequence sent
27+
/// and whose WNDW is received + slack.</item>
28+
/// </list>
29+
/// The multiplexer is driven directly over a loopback socket pair through the
30+
/// <see cref="ISmpHost"/> seam with a canned session runner, so the test needs
31+
/// no TLS / login / engine.
32+
/// </summary>
33+
[TestClass]
34+
public sealed class SmpFrameTests
35+
{
36+
public TestContext TestContext { get; set; } = null!;
37+
38+
private const byte Syn = Tds.SmpFlagSyn;
39+
private const byte Ack = Tds.SmpFlagAck;
40+
private const byte Fin = Tds.SmpFlagFin;
41+
private const byte Data = Tds.SmpFlagData;
42+
43+
private sealed record Frame(byte Flags, ushort Sid, uint Length, uint Seq, uint Window, byte[] Payload);
44+
45+
/// <summary>
46+
/// Canned host: reads one whole TDS message per session then writes a single
47+
/// tabular-result packet as the response, mirroring the real session loop's
48+
/// FIN on completion. No engine involved.
49+
/// </summary>
50+
private sealed class CannedHost : ISmpHost
51+
{
52+
public SmpMultiplexer Multiplexer = null!;
53+
54+
public async Task RunMarsSessionAsync(SmpSession session, CancellationToken cancellationToken)
55+
{
56+
using var stream = new SmpSessionStream(session);
57+
var transport = new TdsPacketTransport(stream) { PacketSize = Tds.DefaultPacketSize };
58+
var message = await transport.ReadMessageAsync(cancellationToken).ConfigureAwait(false);
59+
if (message is not null)
60+
await transport.WritePacketAsync(Tds.PacketTabularResult, new byte[] { Tds.TokenDone, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, endOfMessage: true, cancellationToken).ConfigureAwait(false);
61+
await this.Multiplexer.SendFinAsync(session, CancellationToken.None).ConfigureAwait(false);
62+
}
63+
64+
public void CancelConnectionExecution()
65+
{
66+
}
67+
}
68+
69+
private static byte[] BuildFrame(byte flags, ushort sid, uint seq, uint window, byte[]? payload = null)
70+
{
71+
payload ??= [];
72+
var frame = new byte[Tds.SmpHeaderSize + payload.Length];
73+
frame[0] = Tds.SmpSmid;
74+
frame[1] = flags;
75+
BinaryPrimitives.WriteUInt16LittleEndian(frame.AsSpan(2), sid);
76+
BinaryPrimitives.WriteUInt32LittleEndian(frame.AsSpan(4), (uint)frame.Length);
77+
BinaryPrimitives.WriteUInt32LittleEndian(frame.AsSpan(8), seq);
78+
BinaryPrimitives.WriteUInt32LittleEndian(frame.AsSpan(12), window);
79+
payload.CopyTo(frame.AsSpan(Tds.SmpHeaderSize));
80+
return frame;
81+
}
82+
83+
/// <summary>A minimal single-packet SQLBatch TDS packet (header + trivial body).</summary>
84+
private static byte[] TdsBatchPacket(bool endOfMessage)
85+
{
86+
var body = new byte[] { 0, 0, 0, 0, 1, 0 }; // ALL_HEADERS-free trivial payload; content is irrelevant to framing.
87+
var packet = new byte[Tds.HeaderSize + body.Length];
88+
packet[0] = Tds.PacketSqlBatch;
89+
packet[1] = endOfMessage ? Tds.StatusEndOfMessage : (byte)0;
90+
packet[2] = (byte)((Tds.HeaderSize + body.Length) >> 8);
91+
packet[3] = (byte)(Tds.HeaderSize + body.Length);
92+
body.CopyTo(packet.AsSpan(Tds.HeaderSize));
93+
return packet;
94+
}
95+
96+
private static async Task<List<Frame>> RunAsync(CancellationToken cancellationToken, params byte[][] clientFrames)
97+
{
98+
using var listener = new TcpListener(IPAddress.Loopback, 0);
99+
listener.Start();
100+
using var clientSocket = new TcpClient();
101+
var acceptTask = listener.AcceptTcpClientAsync(cancellationToken).AsTask();
102+
await clientSocket.ConnectAsync(IPAddress.Loopback, ((IPEndPoint)listener.LocalEndpoint).Port, cancellationToken);
103+
using var serverTcp = await acceptTask;
104+
await using var serverStream = serverTcp.GetStream();
105+
await using var clientStream = clientSocket.GetStream();
106+
107+
var host = new CannedHost();
108+
using var mux = new SmpMultiplexer(serverStream, host);
109+
host.Multiplexer = mux;
110+
var muxTask = mux.RunAsync(cancellationToken);
111+
112+
foreach (var frame in clientFrames)
113+
{
114+
await clientStream.WriteAsync(frame, cancellationToken);
115+
await clientStream.FlushAsync(cancellationToken);
116+
}
117+
118+
var frames = new List<Frame>();
119+
var header = new byte[Tds.SmpHeaderSize];
120+
using var readCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
121+
readCts.CancelAfter(TimeSpan.FromSeconds(5));
122+
try
123+
{
124+
while (true)
125+
{
126+
await clientStream.ReadExactlyAsync(header, readCts.Token);
127+
var length = BinaryPrimitives.ReadUInt32LittleEndian(header.AsSpan(4));
128+
var payload = new byte[length - Tds.SmpHeaderSize];
129+
if (payload.Length > 0)
130+
await clientStream.ReadExactlyAsync(payload, readCts.Token);
131+
frames.Add(new Frame(
132+
header[1],
133+
BinaryPrimitives.ReadUInt16LittleEndian(header.AsSpan(2)),
134+
length,
135+
BinaryPrimitives.ReadUInt32LittleEndian(header.AsSpan(8)),
136+
BinaryPrimitives.ReadUInt32LittleEndian(header.AsSpan(12)),
137+
payload));
138+
139+
// Stop once every opened session has been FIN-ed by the server.
140+
if ((header[1] & Fin) != 0)
141+
break;
142+
}
143+
}
144+
catch (OperationCanceledException)
145+
{
146+
}
147+
catch (EndOfStreamException)
148+
{
149+
}
150+
151+
return frames;
152+
}
153+
154+
[TestMethod]
155+
public async Task CompleteRequest_ServerEmitsNoSyn_DataThenFin()
156+
{
157+
var frames = await RunAsync(
158+
TestContext.CancellationToken,
159+
BuildFrame(Syn, sid: 1, seq: 0, window: 4),
160+
BuildFrame(Data, sid: 1, seq: 1, window: 4, TdsBatchPacket(endOfMessage: true)));
161+
162+
var synCount = frames.Count(f => (f.Flags & Syn) != 0);
163+
var ackCount = frames.Count(f => (f.Flags & Ack) != 0);
164+
AreEqual(0, synCount, "Server must never emit a SYN frame (native SNI drops the connection on one).");
165+
AreEqual(0, ackCount, "A complete request needs no standalone ACK — the DATA response piggybacks the window.");
166+
167+
var data = frames.Single(f => (f.Flags & Data) != 0);
168+
AreEqual((ushort)1, data.Sid);
169+
AreEqual(1u, data.Seq);
170+
AreEqual(5u, data.Window); // received 1 + slack 4
171+
172+
var fin = frames.Single(f => (f.Flags & Fin) != 0);
173+
AreEqual((ushort)1, fin.Sid);
174+
AreEqual(1u, fin.Seq); // SEQNUM = last DATA sequence sent
175+
AreEqual(5u, fin.Window);
176+
}
177+
178+
[TestMethod]
179+
public async Task MultiPacketRequest_MidMessagePacketGetsAck()
180+
{
181+
var frames = await RunAsync(
182+
TestContext.CancellationToken,
183+
BuildFrame(Syn, sid: 1, seq: 0, window: 4),
184+
BuildFrame(Data, sid: 1, seq: 1, window: 4, TdsBatchPacket(endOfMessage: false)),
185+
BuildFrame(Data, sid: 1, seq: 2, window: 5, TdsBatchPacket(endOfMessage: true)));
186+
187+
var synCount = frames.Count(f => (f.Flags & Syn) != 0);
188+
AreEqual(0, synCount, "Server must never emit a SYN frame.");
189+
190+
// The mid-message (EOM-clear) first packet is ACKed to advance the send
191+
// window; the completing second packet is not (its response piggybacks).
192+
var ack = frames.Single(f => f.Flags == Ack);
193+
AreEqual((ushort)1, ack.Sid);
194+
AreEqual(5u, ack.Window); // received 1 + slack 4
195+
196+
var dataCount = frames.Count(f => (f.Flags & Data) != 0);
197+
var finCount = frames.Count(f => (f.Flags & Fin) != 0);
198+
IsGreaterThan(0, dataCount, "The completed request must produce a DATA response.");
199+
IsGreaterThan(0, finCount, "The session must be FIN-ed on completion.");
200+
}
201+
}

0 commit comments

Comments
 (0)