Skip to content

Commit fa39ff5

Browse files
committed
no-mistakes(test): Bind servers and containers to OS-assigned ports
1 parent b732c18 commit fa39ff5

26 files changed

Lines changed: 369 additions & 693 deletions

File tree

‎docs/CONFORMANCE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ regenerate with `ls -d fixtures/<corpus>/*/ | wc -l` for directory-shaped corpor
3434
| [`fixtures/extract-conformance/`](../fixtures/extract-conformance/) | 48 | ✓ | ✓ | inherits the shared JVM engine | ✓ | ✓ |
3535
| [`fixtures/output-prompt-conformance/`](../fixtures/output-prompt-conformance/) | 17 | ✓ | ✓ | ✓ | ✓ | ✓ |
3636
| [`fixtures/persistence-conformance/`](../fixtures/persistence-conformance/) | 59 (53 query + 6 migration) | all 59; the 24 `report-*` and the `-15` decimal scenarios also on SQLite, MySQL and D1, `decimal-exact` also on MySQL (a scenario's `engines:` key is exact) | 53 query (migrations TS-only, ADR-0015); the `report-*` and `engines:` scenarios also on SQLite and MySQL | 53 query (via Exposed); the `report-*` and `engines:` scenarios also on SQLite and MySQL | 53 query; the `report-*` and `engines:` scenarios also on SQLite and MySQL | 53 query (pg8000); the `report-*` and `engines:` scenarios also on SQLite (`sqlite3`) and MySQL (PyMySQL) |
37-
| [`fixtures/api-contract-conformance/`](../fixtures/api-contract-conformance/) | 106 (31 core + 10 tph + 9 m2m + 4 jsonb + 2 write-through + 12 projection + 27 report + 11 decimal) | ✓ (Fastify reference + generated lane); `report/` and `decimal/` also on SQLite, MySQL and D1 | ✓ (embedded HTTP + JDBC); `report/` and `decimal/` generated controllers over OMDB on Postgres, SQLite and MySQL | ✓ (embedded HTTP + Exposed); `report/` and `decimal/` also on SQLite and MySQL | ✓ (HttpListener + Npgsql); `report/` and `decimal/` also on SQLite and MySQL | ✓ (FastAPI + pg8000); `report/` and `decimal/` also on SQLite and MySQL |
37+
| [`fixtures/api-contract-conformance/`](../fixtures/api-contract-conformance/) | 106 (31 core + 10 tph + 9 m2m + 4 jsonb + 2 write-through + 12 projection + 27 report + 11 decimal) | ✓ (Fastify reference + generated lane); `report/` and `decimal/` also on SQLite, MySQL and D1 | ✓ (embedded HTTP + JDBC); `report/` and `decimal/` generated controllers over OMDB on Postgres, SQLite and MySQL | ✓ (embedded HTTP + Exposed); `report/` and `decimal/` also on SQLite and MySQL | ✓ (Kestrel + Npgsql); `report/` and `decimal/` also on SQLite and MySQL | ✓ (FastAPI + pg8000); `report/` and `decimal/` also on SQLite and MySQL |
3838
| [`fixtures/decimal-wire/`](../fixtures/decimal-wire/) | 1 vector file (format, parse, filter operand, 15-digit read) | ✓ (`runtime-ts` and the generated helper) | ✓ (`CanonicalDecimal`) | ✓ (the generated wire file) | ✓ (`DecimalWire`) | ✓ (`decimal_wire`) |
3939
| [`fixtures/validation-conformance/`](../fixtures/validation-conformance/) | 42 cases | ✓ (generated Zod + run-time `runValidators`) | ✓ | ✓ | ✓ | ✓ (generated Pydantic + run-time `run_validators`) |
4040
| [`fixtures/registry-conformance/`](../fixtures/registry-conformance/) | 1 canonical manifest | ✓ (reference emitter) | ✓ | ✓ | ✓ | ✓ |

‎fixtures/api-contract-conformance/jsonb/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ booted over HTTP) are wired and green for:
132132
converter refuses polymorphic bodies → HTTP 415), so the Kotlin generated lane
133133
wires a small Jackson `JsonElement` codec module (the consumer's serialization
134134
wiring).
135-
- **C#** — reference (hand-rolled `HttpListener` over Postgres testcontainer) +
135+
- **C#** — reference (hand-rolled Kestrel server over Postgres testcontainer) +
136136
generated (the real `MetaObjects.Codegen` `DocumentRoutes` + `AppDbContext`
137137
Roslyn-compiled and booted full-stack on Kestrel over Postgres testcontainer).
138138
The generated lane locks the #105 codegen: the `payload` property is typed

‎server/csharp/MetaObjects.IntegrationTests/Api/AuthorApiServer.cs‎

Lines changed: 43 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,21 @@
1-
// AuthorApiServer — minimal reference ASP.NET / HttpListener server implementing
1+
// AuthorApiServer — minimal reference ASP.NET Core (Kestrel) server implementing
22
// the cross-port REST API contract for the Author entity from the
33
// api-contract-conformance corpus.
44
//
55
// Why hand-rolled (vs the RoutesGenerator output): the generator's output is a
6-
// Minimal-API mapping requiring a full WebApplication host pulling EF Core +
7-
// Kestrel. For a 10-route contract test that's disproportionate, and the
8-
// pattern matches the Java/Kotlin reference runners byte-for-byte. The corpus
6+
// Minimal-API mapping over EF Core. This server is the independent fixed point
7+
// the generated lane is measured against: one catch-all handler over raw Npgsql,
8+
// and the pattern matches the Java/Kotlin reference runners byte-for-byte. The corpus
99
// is the contract both must satisfy.
1010
//
1111
// Backend is a Postgres testcontainer (matches the persistence-conformance
1212
// runner — keeps a single integration backend across both corpora and proves
1313
// the wire shape against real autoincrement + null semantics).
1414

1515
using System.Globalization;
16-
using System.Net;
16+
using Microsoft.AspNetCore.Builder;
17+
using Microsoft.AspNetCore.Http;
18+
using Microsoft.Extensions.Logging;
1719
using System.Text;
1820
using System.Text.Json;
1921
using System.Text.Json.Nodes;
@@ -77,18 +79,15 @@ private sealed record FilterResult(List<FilterPredicate> Predicates, string? Err
7779
private const string TimestampFmt = "yyyy-MM-ddTHH:mm:ss";
7880

7981
private readonly PostgresContainer _pg;
80-
private readonly HttpListener _listener;
81-
private readonly Task _loop;
82-
private readonly CancellationTokenSource _cts = new();
82+
private readonly WebApplication _app;
8383

8484
public string BaseUrl { get; }
8585

86-
private AuthorApiServer(PostgresContainer pg, HttpListener listener, string baseUrl)
86+
private AuthorApiServer(PostgresContainer pg, WebApplication app, string baseUrl)
8787
{
8888
_pg = pg;
89-
_listener = listener;
89+
_app = app;
9090
BaseUrl = baseUrl;
91-
_loop = Task.Run(AcceptLoopAsync);
9291
}
9392

9493
public static async Task<AuthorApiServer> StartAsync(PostgresContainer pg)
@@ -114,20 +113,23 @@ bio VARCHAR(1000),
114113
)";
115114
await cmd.ExecuteNonQueryAsync();
116115
}
117-
int port = PickFreePort();
118-
var listener = new HttpListener();
119-
string baseUrl = $"http://127.0.0.1:{port}";
120-
listener.Prefixes.Add(baseUrl + "/");
121-
listener.Start();
122-
return new AuthorApiServer(pg, listener, baseUrl);
116+
var builder = WebApplication.CreateBuilder();
117+
LoopbackKestrel.BindAnyPort(builder);
118+
builder.Logging.ClearProviders();
119+
var app = builder.Build();
120+
AuthorApiServer? server = null;
121+
// The bound address exists only after StartAsync, and no client can reach the
122+
// server before it is returned, so the handler is wired before the instance exists.
123+
app.Run(ctx => server!.HandleAsync(ctx));
124+
await app.StartAsync();
125+
server = new AuthorApiServer(pg, app, LoopbackKestrel.BoundUrl(app));
126+
return server;
123127
}
124128

125129
public async ValueTask DisposeAsync()
126130
{
127-
_cts.Cancel();
128-
try { _listener.Stop(); _listener.Close(); } catch { /* ignored */ }
129-
try { await _loop; } catch { /* ignored */ }
130-
_cts.Dispose();
131+
try { await _app.StopAsync(); } catch { /* ignored */ }
132+
try { await _app.DisposeAsync(); } catch { /* ignored */ }
131133
}
132134

133135
// ----- seed / truncate -----
@@ -183,22 +185,7 @@ public async Task ApplySeedAsync(IReadOnlyList<Dictionary<string, object?>> rows
183185

184186
// ----- HTTP loop -----
185187

186-
private async Task AcceptLoopAsync()
187-
{
188-
while (!_cts.IsCancellationRequested)
189-
{
190-
HttpListenerContext ctx;
191-
try
192-
{
193-
ctx = await _listener.GetContextAsync();
194-
}
195-
catch (HttpListenerException) { break; }
196-
catch (ObjectDisposedException) { break; }
197-
_ = Task.Run(() => HandleAsync(ctx));
198-
}
199-
}
200-
201-
private async Task HandleAsync(HttpListenerContext ctx)
188+
private async Task HandleAsync(HttpContext ctx)
202189
{
203190
try
204191
{
@@ -213,18 +200,14 @@ private async Task HandleAsync(HttpListenerContext ctx)
213200
}
214201
catch { /* nothing we can do */ }
215202
}
216-
finally
217-
{
218-
try { ctx.Response.Close(); } catch { /* ignored */ }
219-
}
220203
}
221204

222-
private async Task DispatchAsync(HttpListenerContext ctx)
205+
private async Task DispatchAsync(HttpContext ctx)
223206
{
224-
string method = ctx.Request.HttpMethod.ToUpperInvariant();
225-
string rawPath = ctx.Request.Url?.AbsolutePath ?? "";
226-
var qs = ctx.Request.QueryString;
227-
string rawQuery = ctx.Request.Url?.Query ?? "";
207+
string method = ctx.Request.Method.ToUpperInvariant();
208+
string rawPath = ctx.Request.Path.Value ?? "";
209+
var qs = ctx.Request.Query;
210+
string rawQuery = ctx.Request.QueryString.Value ?? "";
228211
if (rawQuery.StartsWith("?")) rawQuery = rawQuery.Substring(1);
229212

230213
string trimmed = rawPath.StartsWith("/api/authors")
@@ -246,8 +229,8 @@ private async Task DispatchAsync(HttpListenerContext ctx)
246229
}
247230

248231
private async Task ListAuthorsAsync(
249-
HttpListenerContext ctx,
250-
System.Collections.Specialized.NameValueCollection qs,
232+
HttpContext ctx,
233+
IQueryCollection qs,
251234
string rawQuery)
252235
{
253236
string sortField = "id";
@@ -504,7 +487,7 @@ private static void BindAll(NpgsqlCommand cmd, List<object?> values)
504487
cmd.Parameters.AddWithValue("@p" + i, values[i] ?? (object)DBNull.Value);
505488
}
506489

507-
private async Task GetAuthorAsync(HttpListenerContext ctx, string idStr)
490+
private async Task GetAuthorAsync(HttpContext ctx, string idStr)
508491
{
509492
long? id = ParseLongOrNull(idStr);
510493
if (id is null) { await SendJsonAsync(ctx, 400, new Dictionary<string, object?> { ["error"] = "invalid_id" }); return; }
@@ -529,7 +512,7 @@ private static bool ExceedsAuthorMaxLength(Dictionary<string, object?> body) =>
529512
(body.GetValueOrDefault("name") is string n && n.Length > 100)
530513
|| (body.GetValueOrDefault("bio") is string b && b.Length > 1000);
531514

532-
private async Task CreateAuthorAsync(HttpListenerContext ctx)
515+
private async Task CreateAuthorAsync(HttpContext ctx)
533516
{
534517
var parsed = await ReadJsonBodyAsync(ctx);
535518
if (parsed is not Dictionary<string, object?> body)
@@ -579,7 +562,7 @@ private async Task CreateAuthorAsync(HttpListenerContext ctx)
579562
await SendJsonAsync(ctx, 201, row);
580563
}
581564

582-
private async Task UpdateAuthorAsync(HttpListenerContext ctx, string idStr)
565+
private async Task UpdateAuthorAsync(HttpContext ctx, string idStr)
583566
{
584567
long? id = ParseLongOrNull(idStr);
585568
if (id is null) { await SendJsonAsync(ctx, 400, new Dictionary<string, object?> { ["error"] = "invalid_id" }); return; }
@@ -662,7 +645,7 @@ private async Task UpdateAuthorAsync(HttpListenerContext ctx, string idStr)
662645
await SendJsonAsync(ctx, 200, row);
663646
}
664647

665-
private async Task DeleteAuthorAsync(HttpListenerContext ctx, string idStr)
648+
private async Task DeleteAuthorAsync(HttpContext ctx, string idStr)
666649
{
667650
long? id = ParseLongOrNull(idStr);
668651
if (id is null) { await SendJsonAsync(ctx, 400, new Dictionary<string, object?> { ["error"] = "invalid_id" }); return; }
@@ -708,9 +691,9 @@ private async Task DeleteAuthorAsync(HttpListenerContext ctx, string idStr)
708691
return rdr.IsDBNull(ord) ? null : rdr.GetDateTime(ord).ToString(TimestampFmt, CultureInfo.InvariantCulture);
709692
}
710693

711-
private static async Task<object?> ReadJsonBodyAsync(HttpListenerContext ctx)
694+
private static async Task<object?> ReadJsonBodyAsync(HttpContext ctx)
712695
{
713-
using var reader = new StreamReader(ctx.Request.InputStream, ctx.Request.ContentEncoding);
696+
using var reader = new StreamReader(ctx.Request.Body, Encoding.UTF8);
714697
string text = await reader.ReadToEndAsync();
715698
if (string.IsNullOrEmpty(text)) return null;
716699
return JsonNodeToObject(JsonNode.Parse(text));
@@ -743,20 +726,20 @@ private async Task DeleteAuthorAsync(HttpListenerContext ctx, string idStr)
743726
return null;
744727
}
745728

746-
private static async Task SendJsonAsync(HttpListenerContext ctx, int status, object body)
729+
private static async Task SendJsonAsync(HttpContext ctx, int status, object body)
747730
{
748731
byte[] bytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(body, JsonOpts));
749732
ctx.Response.StatusCode = status;
750733
ctx.Response.ContentType = "application/json";
751-
ctx.Response.ContentLength64 = bytes.Length;
752-
await ctx.Response.OutputStream.WriteAsync(bytes);
734+
ctx.Response.ContentLength = bytes.Length;
735+
await ctx.Response.Body.WriteAsync(bytes);
753736
}
754737

755-
private static async Task SendNoContentAsync(HttpListenerContext ctx)
738+
private static async Task SendNoContentAsync(HttpContext ctx)
756739
{
757740
ctx.Response.StatusCode = 204;
758-
ctx.Response.ContentLength64 = 0;
759-
await ctx.Response.OutputStream.FlushAsync();
741+
ctx.Response.ContentLength = 0;
742+
await ctx.Response.Body.FlushAsync();
760743
}
761744

762745
private static readonly JsonSerializerOptions JsonOpts = new()
@@ -778,15 +761,4 @@ private static DateTime ParseTimestamp(string s) =>
778761
if (s is null) return null;
779762
return long.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n) ? n : null;
780763
}
781-
782-
private static int PickFreePort()
783-
{
784-
// Bind to port 0, read what the OS gave us, release. Race-free for our
785-
// single-instance test usage; HttpListener will re-bind in StartAsync.
786-
var l = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
787-
l.Start();
788-
int port = ((System.Net.IPEndPoint)l.LocalEndpoint).Port;
789-
l.Stop();
790-
return port;
791-
}
792764
}

‎server/csharp/MetaObjects.IntegrationTests/Api/GeneratedAuthorServerFactory.cs‎

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -106,11 +106,8 @@ bio VARCHAR(1000),
106106
var routesType = generatedAssembly.GetType($"{GeneratedNamespace}.AuthorRoutes")
107107
?? throw new InvalidOperationException("generated AuthorRoutes type not found");
108108

109-
int port = PickFreePort();
110-
string baseUrl = $"http://127.0.0.1:{port}";
111-
112109
var builder = WebApplication.CreateBuilder();
113-
builder.WebHost.UseUrls(baseUrl);
110+
LoopbackKestrel.BindAnyPort(builder);
114111
// Quiet the host (no console spew during the test run).
115112
builder.Logging.ClearProviders();
116113

@@ -137,7 +134,7 @@ bio VARCHAR(1000),
137134
mapMethod.Invoke(null, new object[] { app, "/api" });
138135

139136
await app.StartAsync();
140-
return new GeneratedAuthorServerFactory(pg, app, baseUrl);
137+
return new GeneratedAuthorServerFactory(pg, app, LoopbackKestrel.BoundUrl(app));
141138
}
142139

143140
public async ValueTask DisposeAsync()
@@ -341,15 +338,6 @@ private static DateTime ParseTimestamp(string s) =>
341338
DateTime.ParseExact(s, TimestampFmt, System.Globalization.CultureInfo.InvariantCulture,
342339
System.Globalization.DateTimeStyles.None),
343340
DateTimeKind.Utc);
344-
345-
private static int PickFreePort()
346-
{
347-
var l = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
348-
l.Start();
349-
int port = ((System.Net.IPEndPoint)l.LocalEndpoint).Port;
350-
l.Stop();
351-
return port;
352-
}
353341
}
354342

355343
/// <summary>

‎server/csharp/MetaObjects.IntegrationTests/Api/GeneratedRoutesServer.cs‎

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -79,14 +79,8 @@ public static async Task<GeneratedRoutesServer> StartAsync(
7979
var assembly = Assembly.Load(ms.ToArray());
8080
var dbContextType = assembly.GetType($"{ns}.AppDbContext") ?? throw new InvalidOperationException("no AppDbContext");
8181

82-
var l = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
83-
l.Start();
84-
int port = ((System.Net.IPEndPoint)l.LocalEndpoint).Port;
85-
l.Stop();
86-
var baseUrl = $"http://127.0.0.1:{port}";
87-
8882
var builder = WebApplication.CreateBuilder();
89-
builder.WebHost.UseUrls(baseUrl);
83+
LoopbackKestrel.BindAnyPort(builder);
9084
builder.Logging.ClearProviders();
9185
var addDbContext = typeof(EntityFrameworkServiceCollectionExtensions).GetMethods(BindingFlags.Public | BindingFlags.Static)
9286
.First(m => m.Name == "AddDbContext" && m.IsGenericMethodDefinition && m.GetGenericArguments().Length == 1 && m.GetParameters().Length == 4)
@@ -99,7 +93,7 @@ public static async Task<GeneratedRoutesServer> StartAsync(
9993
assembly.GetType($"{ns}.{name}Routes")!.GetMethod($"Map{name}Routes", BindingFlags.Public | BindingFlags.Static)!
10094
.Invoke(null, [app, "/api"]);
10195
await app.StartAsync();
102-
return new GeneratedRoutesServer(app, baseUrl);
96+
return new GeneratedRoutesServer(app, LoopbackKestrel.BoundUrl(app));
10397
}
10498

10599
public async ValueTask DisposeAsync()

‎server/csharp/MetaObjects.IntegrationTests/Api/JsonbGeneratedServerFactory.cs‎

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,8 @@ public static async Task<JsonbGeneratedServerFactory> StartAsync(PostgresContain
5555
var dbContextType = assembly.GetType($"{GeneratedNamespace}.AppDbContext")
5656
?? throw new InvalidOperationException("generated AppDbContext type not found");
5757

58-
int port = PickFreePort();
59-
string baseUrl = $"http://127.0.0.1:{port}";
60-
6158
var builder = WebApplication.CreateBuilder();
62-
builder.WebHost.UseUrls(baseUrl);
59+
LoopbackKestrel.BindAnyPort(builder);
6360
builder.Logging.ClearProviders();
6461
RegisterGeneratedDbContext(builder.Services, dbContextType, pg.ConnectionString);
6562
// The documented host registration: a field.timestamp crosses the wire as UTC with the Z
@@ -79,7 +76,7 @@ public static async Task<JsonbGeneratedServerFactory> StartAsync(PostgresContain
7976
}
8077

8178
await app.StartAsync();
82-
return new JsonbGeneratedServerFactory(pg, app, baseUrl);
79+
return new JsonbGeneratedServerFactory(pg, app, LoopbackKestrel.BoundUrl(app));
8380
}
8481

8582
public async Task ApplySeedAsync() =>
@@ -195,13 +192,4 @@ private static void RegisterGeneratedDbContext(
195192
services, configure, ServiceLifetime.Scoped, ServiceLifetime.Scoped,
196193
});
197194
}
198-
199-
private static int PickFreePort()
200-
{
201-
var l = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
202-
l.Start();
203-
int port = ((System.Net.IPEndPoint)l.LocalEndpoint).Port;
204-
l.Stop();
205-
return port;
206-
}
207195
}

0 commit comments

Comments
 (0)