Skip to content

Commit c828a3e

Browse files
committed
fix(daemon): log bound startup address
1 parent 512f48c commit c828a3e

3 files changed

Lines changed: 307 additions & 3 deletions

File tree

MCServerLauncher.Daemon/Application.cs

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,10 @@ public static async Task ServeAsync()
4444
gs.OnShutdown += () => OnStopping.Invoke();
4545

4646
await HttpService.StartAsync();
47-
var config = AppConfig.Get();
48-
Log.Information("[Remote] Ws Server started at ws://0.0.0.0:{0}/api/v1", config.Port);
49-
Log.Information("[Remote] Http Server started at http://0.0.0.0:{0}/", config.Port);
47+
var remoteAddress = GetBoundRemoteAddress();
48+
Log.Information("[Remote] Ws Server started at ws://{RemoteAddress}/api/v1", remoteAddress);
49+
Log.Information("[Remote] Http Server started at http://{RemoteAddress}/", remoteAddress);
50+
Log.Information("[Remote] Apifox docs available at http://{RemoteAddress}/apifox.json", remoteAddress);
5051

5152
await (OnStarted?.Invoke() ?? Task.CompletedTask);
5253

@@ -71,6 +72,24 @@ await Task.WhenAll(HttpService.Resolver.GetRequiredService<WsContextContainer>()
7172
.Select(kv => kv.Value.GetWebsocket().CloseAsync("Daemon exit", cts.Token)));
7273
}
7374

75+
private static string GetBoundRemoteAddress()
76+
{
77+
var endpoint = HttpService.Monitors
78+
.Select(monitor => monitor.Socket.LocalEndPoint)
79+
.OfType<System.Net.IPEndPoint>()
80+
.FirstOrDefault();
81+
82+
if (endpoint is null)
83+
{
84+
return $"0.0.0.0:{AppConfig.Get().Port}";
85+
}
86+
87+
var address = endpoint.Address.ToString();
88+
return endpoint.Address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6
89+
? $"[{address}]:{endpoint.Port}"
90+
: $"{address}:{endpoint.Port}";
91+
}
92+
7493

7594
public static async Task<bool> InitAsync()
7695
{

MCServerLauncher.ProtocolTests/DaemonInboundTransportPipelineTests.cs

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,131 @@ public void DaemonServiceComposition_ConfigurePlugins_LocksApiV1WebsocketPathAnd
345345
Assert.Contains("options.SetAutoPong(true)", source, StringComparison.Ordinal);
346346
}
347347

348+
[Fact]
349+
[Trait("Category", "Inbound")]
350+
[Trait("Category", "DaemonInbound")]
351+
[Trait("Category", "DaemonInboundStatic")]
352+
[Trait("Category", "Documentation")]
353+
public void ApplicationStartupLog_PointsToApifoxDocsOnly()
354+
{
355+
var source = ReadSourceFile("MCServerLauncher.Daemon/Application.cs");
356+
357+
Assert.Contains("var remoteAddress = GetBoundRemoteAddress();", source, StringComparison.Ordinal);
358+
Assert.Contains("[Remote] Apifox docs available at http://{RemoteAddress}/apifox.json", source,
359+
StringComparison.Ordinal);
360+
Assert.DoesNotContain("[Remote] Apifox docs available at http://0.0.0.0:{0}/apifox.json", source,
361+
StringComparison.Ordinal);
362+
Assert.DoesNotContain("postman_collection.json", source, StringComparison.OrdinalIgnoreCase);
363+
}
364+
365+
[Fact]
366+
[Trait("Category", "Inbound")]
367+
[Trait("Category", "DaemonInbound")]
368+
[Trait("Category", "DaemonInboundStatic")]
369+
[Trait("Category", "Documentation")]
370+
public void DaemonAssembly_EmbedsApifoxProjectAndProtocolDocsOnly()
371+
{
372+
var resourceNames = typeof(HttpPlugin).Assembly.GetManifestResourceNames();
373+
374+
Assert.DoesNotContain("MCServerLauncher.Daemon.Resources.Docs.openapi.json", resourceNames);
375+
Assert.DoesNotContain("MCServerLauncher.Daemon.Resources.Docs.postman_collection.json", resourceNames);
376+
Assert.Contains("MCServerLauncher.Daemon.Resources.Docs.apifox.json", resourceNames);
377+
Assert.DoesNotContain("MCServerLauncher.Daemon.Resources.Docs.swagger.index.html", resourceNames);
378+
Assert.DoesNotContain("MCServerLauncher.Daemon.Resources.Docs.swagger.swagger-ui.css", resourceNames);
379+
Assert.DoesNotContain("MCServerLauncher.Daemon.Resources.Docs.swagger.swagger-ui-bundle.js", resourceNames);
380+
Assert.DoesNotContain("MCServerLauncher.Daemon.Resources.Docs.swagger.swagger-ui-standalone-preset.js", resourceNames);
381+
Assert.Contains("MCServerLauncher.Daemon.Resources.Docs.protocol.topics.connection.md", resourceNames);
382+
Assert.Contains("MCServerLauncher.Daemon.Resources.Docs.protocol.topics.actions.md", resourceNames);
383+
}
384+
385+
[Fact]
386+
[Trait("Category", "Inbound")]
387+
[Trait("Category", "DaemonInbound")]
388+
[Trait("Category", "DaemonInboundStatic")]
389+
[Trait("Category", "Documentation")]
390+
public void HttpPluginFile_ExposesEmbeddedApifoxProjectAndProtocolDocs()
391+
{
392+
AssertFileContains("MCServerLauncher.Daemon/Remote/HttpPlugin.cs",
393+
"EmbeddedDocumentation.TryGetResource(request.URL, out var document)");
394+
AssertFileDoesNotContain("MCServerLauncher.Daemon/Remote/EmbeddedDocumentation.cs",
395+
"\"/openapi.json\"");
396+
AssertFileDoesNotContain("MCServerLauncher.Daemon/Remote/EmbeddedDocumentation.cs",
397+
"\"/postman_collection.json\"");
398+
AssertFileContains("MCServerLauncher.Daemon/Remote/EmbeddedDocumentation.cs",
399+
"\"/apifox.json\"");
400+
AssertFileContains("MCServerLauncher.Daemon/Remote/EmbeddedDocumentation.cs",
401+
"\"/docs/protocol/\"");
402+
AssertFileDoesNotContain("MCServerLauncher.Daemon/Remote/EmbeddedDocumentation.cs",
403+
"\"/swagger\"");
404+
}
405+
406+
[Fact]
407+
[Trait("Category", "Inbound")]
408+
[Trait("Category", "DaemonInbound")]
409+
[Trait("Category", "DaemonInboundStatic")]
410+
[Trait("Category", "Documentation")]
411+
public void HttpPluginFile_DoesNotExposeActionHttpBridge()
412+
{
413+
AssertFileDoesNotContain("MCServerLauncher.Daemon/Remote/HttpPlugin.cs",
414+
"HttpActionBridge.TryGetActionName(request.URL, out var actionName)");
415+
AssertFileDoesNotContain("MCServerLauncher.Daemon/Remote/HttpPlugin.cs",
416+
"HttpActionBridge.HandleAsync");
417+
Assert.False(File.Exists(Path.Combine(ResolveRepoRoot(), "MCServerLauncher.Daemon/Remote/HttpActionBridge.cs")));
418+
}
419+
420+
[Fact]
421+
[Trait("Category", "Inbound")]
422+
[Trait("Category", "DaemonInbound")]
423+
[Trait("Category", "DaemonInboundStatic")]
424+
[Trait("Category", "Documentation")]
425+
public void OpenApiArtifacts_AreNotGeneratedOrEmbedded()
426+
{
427+
var repoRoot = ResolveRepoRoot();
428+
Assert.False(File.Exists(Path.Combine(repoRoot, "MCServerLauncher.Daemon/.Resources/Docs/openapi.json")));
429+
Assert.False(File.Exists(Path.Combine(repoRoot, "../mcsl-future-protocol/openapi.json")));
430+
AssertFileDoesNotContain("MCServerLauncher.Daemon/MCServerLauncher.Daemon.csproj",
431+
".Resources\\Docs\\openapi.json");
432+
}
433+
434+
[Fact]
435+
[Trait("Category", "Inbound")]
436+
[Trait("Category", "DaemonInbound")]
437+
[Trait("Category", "DaemonInboundStatic")]
438+
[Trait("Category", "Documentation")]
439+
public void PostmanArtifacts_AreNotGeneratedOrEmbedded()
440+
{
441+
var repoRoot = ResolveRepoRoot();
442+
443+
Assert.False(File.Exists(Path.Combine(repoRoot, "MCServerLauncher.Daemon/.Resources/Docs/postman_collection.json")));
444+
AssertFileDoesNotContain("MCServerLauncher.Daemon/MCServerLauncher.Daemon.csproj",
445+
".Resources\\Docs\\postman_collection.json");
446+
AssertFileDoesNotContain("MCServerLauncher.Daemon/Remote/EmbeddedDocumentation.cs",
447+
"postman_collection.json");
448+
}
449+
450+
[Fact]
451+
[Trait("Category", "Inbound")]
452+
[Trait("Category", "DaemonInbound")]
453+
[Trait("Category", "DaemonInboundStatic")]
454+
[Trait("Category", "Documentation")]
455+
public void EmbeddedApifoxProject_DocumentsWebSocketActionsAsWebSocketApis()
456+
{
457+
var repoRoot = ResolveRepoRoot();
458+
var apifoxPath = Path.Combine(repoRoot, "MCServerLauncher.Daemon/.Resources/Docs/apifox.json");
459+
using var apifox = JsonDocument.Parse(File.ReadAllText(apifoxPath));
460+
var root = apifox.RootElement;
461+
462+
Assert.Equal("1.0.0", root.GetProperty("apifoxProject").GetString());
463+
Assert.Contains("{{wsUrl}}?token={{token}}", root.GetRawText(), StringComparison.Ordinal);
464+
Assert.DoesNotContain("/api/v1/actions/", root.GetRawText(), StringComparison.Ordinal);
465+
Assert.Contains("protocol/topics/actions.md", root.GetRawText(), StringComparison.Ordinal);
466+
Assert.Contains("protocol/topics/models.md", root.GetRawText(), StringComparison.Ordinal);
467+
Assert.Contains("ActionResponse", root.GetRawText(), StringComparison.Ordinal);
468+
Assert.Contains("SystemInfo", root.GetRawText(), StringComparison.Ordinal);
469+
AssertApifoxWebSocketActionsMatchActionType(root);
470+
AssertApifoxProjectIncludesMfpSchemasAndDocs(root);
471+
}
472+
348473
[Fact]
349474
[Trait("Category", "Inbound")]
350475
[Trait("Category", "DaemonInbound")]
@@ -408,6 +533,16 @@ private static void AssertFileDoesNotContain(string relativePath, string forbidd
408533
Assert.DoesNotContain(forbiddenText, source, StringComparison.Ordinal);
409534
}
410535

536+
private static void AssertSchemaReferenceExists(JsonElement schemaReference, JsonElement schemas, string context)
537+
{
538+
var reference = schemaReference.GetProperty("$ref").GetString();
539+
Assert.False(string.IsNullOrWhiteSpace(reference), $"{context} schema reference is empty");
540+
const string prefix = "#/components/schemas/";
541+
Assert.StartsWith(prefix, reference, StringComparison.Ordinal);
542+
var schemaName = reference[prefix.Length..];
543+
Assert.True(schemas.TryGetProperty(schemaName, out _), $"{context} schema '{schemaName}' is missing");
544+
}
545+
411546
private static string ResolveRepoRoot()
412547
{
413548
var dir = AppDomain.CurrentDomain.BaseDirectory;
@@ -425,6 +560,102 @@ private static string ReadSourceFile(string relativePath)
425560
return File.ReadAllText(Path.Combine(repoRoot, relativePath));
426561
}
427562

563+
private static void AssertApifoxWebSocketActionsMatchActionType(JsonElement root)
564+
{
565+
var expected = Enum.GetNames<ActionType>()
566+
.Select(JsonNamingPolicy.SnakeCaseLower.ConvertName)
567+
.Order(StringComparer.Ordinal)
568+
.ToArray();
569+
570+
Assert.Equal("根目录", root.GetProperty("webSocketCollection")[0].GetProperty("name").GetString());
571+
Assert.Equal("WebSocket actions", root.GetProperty("webSocketCollection")[0]
572+
.GetProperty("items")[0]
573+
.GetProperty("name")
574+
.GetString());
575+
576+
var webSocketItems = root.GetProperty("webSocketCollection")
577+
.EnumerateArray()
578+
.SelectMany(EnumerateApifoxApiItems)
579+
.ToArray();
580+
581+
var actions = webSocketItems
582+
.Select(item => item.GetProperty("name").GetString())
583+
.Order(StringComparer.Ordinal)
584+
.ToArray();
585+
586+
Assert.Equal(expected, actions);
587+
588+
foreach (var api in webSocketItems.Select(item => item.GetProperty("api")))
589+
{
590+
Assert.Equal("{{wsUrl}}?token={{token}}", api.GetProperty("path").GetString());
591+
Assert.False(api.TryGetProperty("type", out _), "Native Apifox WebSocket APIs omit api.type");
592+
Assert.False(api.TryGetProperty("method", out _), "Apifox WebSocket APIs must not masquerade as HTTP requests");
593+
Assert.True(api.GetProperty("requestBody").TryGetProperty("message", out var message));
594+
Assert.False(string.IsNullOrWhiteSpace(message.GetString()));
595+
Assert.Equal(JsonValueKind.Array, api.GetProperty("requestBody").GetProperty("parameters").ValueKind);
596+
Assert.Equal(JsonValueKind.Array, api.GetProperty("parameters").GetProperty("query").ValueKind);
597+
Assert.Equal(JsonValueKind.Array, api.GetProperty("parameters").GetProperty("path").ValueKind);
598+
Assert.Equal(JsonValueKind.Array, api.GetProperty("parameters").GetProperty("cookie").ValueKind);
599+
Assert.Equal(JsonValueKind.Array, api.GetProperty("parameters").GetProperty("header").ValueKind);
600+
Assert.Equal(JsonValueKind.Array, api.GetProperty("commonParameters").GetProperty("query").ValueKind);
601+
Assert.Equal(JsonValueKind.Array, api.GetProperty("commonParameters").GetProperty("body").ValueKind);
602+
Assert.Equal(JsonValueKind.Array, api.GetProperty("commonParameters").GetProperty("cookie").ValueKind);
603+
Assert.Equal(JsonValueKind.Array, api.GetProperty("commonParameters").GetProperty("header").ValueKind);
604+
}
605+
}
606+
607+
private static IEnumerable<JsonElement> EnumerateApifoxApiItems(JsonElement item)
608+
{
609+
if (item.ValueKind != JsonValueKind.Object)
610+
{
611+
yield break;
612+
}
613+
614+
if (item.TryGetProperty("api", out _))
615+
{
616+
yield return item;
617+
}
618+
619+
if (!item.TryGetProperty("items", out var children))
620+
{
621+
yield break;
622+
}
623+
624+
foreach (var child in children.EnumerateArray())
625+
{
626+
foreach (var apiItem in EnumerateApifoxApiItems(child))
627+
{
628+
yield return apiItem;
629+
}
630+
}
631+
}
632+
633+
private static void AssertApifoxProjectIncludesMfpSchemasAndDocs(JsonElement root)
634+
{
635+
var schemaNames = root.GetProperty("schemaCollection")
636+
.EnumerateArray()
637+
.SelectMany(group => group.GetProperty("items").EnumerateArray())
638+
.Select(item => item.GetProperty("name").GetString())
639+
.ToHashSet(StringComparer.Ordinal);
640+
641+
Assert.Contains("ActionRequest", schemaNames);
642+
Assert.Contains("ActionResponse", schemaNames);
643+
Assert.Contains("EventPacket", schemaNames);
644+
Assert.Contains("SystemInfo", schemaNames);
645+
Assert.Contains("DaemonReport", schemaNames);
646+
Assert.Contains("InstanceConfig", schemaNames);
647+
648+
var docNames = root.GetProperty("docCollection")
649+
.EnumerateArray()
650+
.SelectMany(group => group.GetProperty("items").EnumerateArray())
651+
.Select(item => item.GetProperty("name").GetString())
652+
.ToHashSet(StringComparer.Ordinal);
653+
654+
Assert.Contains("MFP actions", docNames);
655+
Assert.Contains("MFP models", docNames);
656+
Assert.Contains("MFP connection", docNames);
657+
}
658+
428659
private sealed class FakeExecutor : IActionExecutor
429660
{
430661
public FakeExecutor(IReadOnlyDictionary<ActionType, ActionHandlerMeta>? handlerMetas = null)
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# Daemon Bound Address Log Implementation Plan
2+
3+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4+
5+
**Goal:** Log daemon WebSocket, HTTP, and Apifox documentation URLs with the address actually bound by TouchSocket.
6+
7+
**Architecture:** `Application.ServeAsync` should derive one display address after `HttpService.StartAsync()` from `HttpService.Monitors` and reuse it for all startup URLs. If no monitor endpoint can be read, keep the existing fallback shape based on `AppConfig.Get().Port`.
8+
9+
**Tech Stack:** C# 14, .NET 10, TouchSocket, xUnit protocol tests.
10+
11+
---
12+
13+
### Task 1: Lock Startup Log Address Source
14+
15+
**Files:**
16+
- Modify: `MCServerLauncher.ProtocolTests/DaemonInboundTransportPipelineTests.cs`
17+
- Modify: `MCServerLauncher.Daemon/Application.cs`
18+
19+
- [x] **Step 1: Write the failing test**
20+
21+
Update `ApplicationStartupLog_PointsToApifoxDocsOnly` so it requires startup logs to call `GetBoundRemoteAddress()` and forbids the old hard-coded `0.0.0.0:{0}` Apifox URL.
22+
23+
- [x] **Step 2: Run test to verify it fails**
24+
25+
Run: `dotnet test MCServerLauncher.ProtocolTests/MCServerLauncher.ProtocolTests.csproj --filter ApplicationStartupLog_PointsToApifoxDocsOnly /m:1`
26+
27+
Expected: FAIL because `Application.cs` still logs `http://0.0.0.0:{0}/apifox.json`.
28+
29+
- [x] **Step 3: Write minimal implementation**
30+
31+
Add a private helper in `Application.cs` that reads the first `IPEndPoint` from `HttpService.Monitors` and formats it as `host:port`, bracketing IPv6 hosts. Use the helper for the three startup log lines.
32+
33+
- [x] **Step 4: Run focused tests**
34+
35+
Run: `dotnet test MCServerLauncher.ProtocolTests/MCServerLauncher.ProtocolTests.csproj --filter ApplicationStartupLog_PointsToApifoxDocsOnly /m:1`
36+
37+
Expected: PASS.
38+
39+
- [x] **Step 5: Final verification**
40+
41+
Run:
42+
43+
```bash
44+
dotnet test MCServerLauncher.ProtocolTests/MCServerLauncher.ProtocolTests.csproj /m:1
45+
git diff --check
46+
git status --short --branch
47+
```
48+
49+
Result: `git diff --check` passed. The focused startup-log test passed. After the Apifox documentation resources were fixed, the full protocol test suite passed.
50+
51+
## Changelog
52+
53+
- Added a focused plan for daemon startup log bound-address reporting.
54+
- Changed daemon startup URL logs to use the actual TouchSocket bound endpoint when available.

0 commit comments

Comments
 (0)