Add mesh diagnostics: /ping, /trace, and topology map

- New protocol types ping=0x26 / pong=0x27 (9-byte payload: 8-byte nonce
  + origin TTL) with per-peer inbound rate limiting (5 per 10s)
- /ping @name reports RTT and hop count, 10s timeout
- /trace @name prints the estimated path from gossiped directNeighbors
- Topology map sheet (circular Canvas layout) reachable from App Info
- Ping/pong ride the deterministic directed-relay path like DMs
- Tests: payload round-trip, hop-count math, command output, edge
  normalization, layout

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-07-06 20:00:55 +02:00
co-authored by Claude Fable 5
parent 7341696280
commit 6056d20a13
21 changed files with 1147 additions and 10 deletions
+5
View File
@@ -435,6 +435,7 @@ private final class MockCommandContextProvider: CommandContextProvider {
private(set) var sentPublicRawMessages: [String] = []
private(set) var localPrivateSystemMessages: [(content: String, peerID: PeerID)] = []
private(set) var publicSystemMessages: [String] = []
private(set) var commandOutputs: [String] = []
private(set) var toggledFavorites: [PeerID] = []
private(set) var favoriteNotifications: [(peerID: PeerID, isFavorite: Bool)] = []
@@ -485,6 +486,10 @@ private final class MockCommandContextProvider: CommandContextProvider {
publicSystemMessages.append(content)
}
func addCommandOutput(_ content: String) {
commandOutputs.append(content)
}
func toggleFavorite(peerID: PeerID) {
toggledFavorites.append(peerID)
}
+21
View File
@@ -196,6 +196,27 @@ final class MockTransport: Transport {
return courierSendResult
}
// MARK: - Mesh Diagnostics
private(set) var sentMeshPings: [PeerID] = []
var meshPingResult: MeshPingResult?
var meshPaths: [PeerID: [PeerID]] = [:]
var meshTopologySnapshot: MeshTopologySnapshot?
func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void) {
sentMeshPings.append(peerID)
let result = meshPingResult
Task { @MainActor in completion(result) }
}
func computeMeshPath(to peerID: PeerID) -> [PeerID]? {
meshPaths[peerID]
}
func currentMeshTopology() -> MeshTopologySnapshot? {
meshTopologySnapshot
}
// MARK: - Test Helpers
/// Clears all recorded method calls for fresh assertions
@@ -0,0 +1,247 @@
//
// MeshDiagnosticsTests.swift
// bitchatTests
//
// Tests for /ping and /trace command handling and the topology snapshot
// types backing the mesh topology map.
// This is free and unencumbered software released into the public domain.
//
import Foundation
import Testing
import BitFoundation
@testable import bitchat
@Suite(.serialized)
struct MeshDiagnosticsTests {
// MARK: - Helpers
@MainActor
private func makeProcessor(
context: DiagnosticsMockContext,
transport: MockTransport
) -> CommandProcessor {
CommandProcessor(
contextProvider: context,
meshService: transport,
identityManager: MockIdentityManager(MockKeychain())
)
}
/// Waits for the async ping completion (MainActor hop) to land.
@MainActor
private func waitForCommandOutput(_ context: DiagnosticsMockContext) async {
for _ in 0..<100 {
if !context.commandOutputs.isEmpty { return }
await Task.yield()
try? await Task.sleep(nanoseconds: 5_000_000)
}
}
// MARK: - /ping
@MainActor
@Test func pingWithoutArgumentShowsUsage() {
let context = DiagnosticsMockContext()
let processor = makeProcessor(context: context, transport: MockTransport())
let result = processor.process("/ping")
switch result {
case .error(let message):
#expect(message == "usage: /ping <nickname>")
default:
Issue.record("Expected usage error")
}
}
@MainActor
@Test func pingUnknownPeerFails() {
let context = DiagnosticsMockContext()
let processor = makeProcessor(context: context, transport: MockTransport())
let result = processor.process("/ping @ghost")
switch result {
case .error(let message):
#expect(message == "cannot ping ghost: not found on mesh")
default:
Issue.record("Expected error result")
}
}
@MainActor
@Test func pingGeoDMPeerIsRejected() {
let context = DiagnosticsMockContext()
context.nicknameToPeerID["alice"] = PeerID(nostr_: "aabbccddeeff00112233445566778899")
let processor = makeProcessor(context: context, transport: MockTransport())
let result = processor.process("/ping @alice")
switch result {
case .error(let message):
#expect(message == "cannot ping alice: not found on mesh")
default:
Issue.record("Expected error for geo peer")
}
}
@MainActor
@Test func pingSuccessReportsRttAndHops() async {
let context = DiagnosticsMockContext()
let peerID = PeerID(str: "abcd1234abcd1234")
context.nicknameToPeerID["alice"] = peerID
let transport = MockTransport()
transport.meshPingResult = MeshPingResult(rttMs: 42, hops: 2)
let processor = makeProcessor(context: context, transport: transport)
let result = processor.process("/ping @alice")
switch result {
case .success(let message):
#expect(message == "pinging alice…")
default:
Issue.record("Expected immediate 'pinging' feedback")
}
#expect(transport.sentMeshPings == [peerID])
await waitForCommandOutput(context)
#expect(context.commandOutputs == ["pong from alice: 42 ms · 2 hops"])
}
@MainActor
@Test func pingDirectPeerReportsSingleHop() async {
let context = DiagnosticsMockContext()
context.nicknameToPeerID["alice"] = PeerID(str: "abcd1234abcd1234")
let transport = MockTransport()
transport.meshPingResult = MeshPingResult(rttMs: 8, hops: 1)
let processor = makeProcessor(context: context, transport: transport)
_ = processor.process("/ping alice")
await waitForCommandOutput(context)
#expect(context.commandOutputs == ["pong from alice: 8 ms · direct (1 hop)"])
}
@MainActor
@Test func pingTimeoutReportsNoReply() async {
let context = DiagnosticsMockContext()
context.nicknameToPeerID["alice"] = PeerID(str: "abcd1234abcd1234")
let transport = MockTransport()
transport.meshPingResult = nil
let processor = makeProcessor(context: context, transport: transport)
_ = processor.process("/ping @alice")
await waitForCommandOutput(context)
#expect(context.commandOutputs == ["no reply from alice"])
}
// MARK: - /trace
@MainActor
@Test func traceDirectPeerShowsOneHop() {
let context = DiagnosticsMockContext()
let bob = PeerID(str: "b0b0b0b0b0b0b0b0")
context.nicknameToPeerID["bob"] = bob
let transport = MockTransport()
transport.meshPaths[bob] = []
let processor = makeProcessor(context: context, transport: transport)
let result = processor.process("/trace @bob")
switch result {
case .success(let message):
#expect(message == "estimated path: you → bob (1 hop)")
default:
Issue.record("Expected success result")
}
}
@MainActor
@Test func traceMultiHopUsesNicknamesWithShortIDFallback() {
let context = DiagnosticsMockContext()
let bob = PeerID(str: "b0b0b0b0b0b0b0b0")
let alice = PeerID(str: "a11cea11cea11cea")
let unknown = PeerID(str: "dead00beef001234")
context.nicknameToPeerID["bob"] = bob
let transport = MockTransport()
transport.peerNicknames = [alice: "alice"]
transport.meshPaths[bob] = [alice, unknown]
let processor = makeProcessor(context: context, transport: transport)
let result = processor.process("/trace bob")
switch result {
case .success(let message):
#expect(message == "estimated path: you → alice → dead00be… → bob (3 hops)")
default:
Issue.record("Expected success result")
}
}
@MainActor
@Test func traceWithoutPathReportsNoKnownPath() {
let context = DiagnosticsMockContext()
context.nicknameToPeerID["bob"] = PeerID(str: "b0b0b0b0b0b0b0b0")
let processor = makeProcessor(context: context, transport: MockTransport())
let result = processor.process("/trace @bob")
switch result {
case .success(let message):
#expect(message == "no known path to bob")
default:
Issue.record("Expected success result")
}
}
// MARK: - Topology snapshot types
@Test func topologyEdgeNormalizesEndpointOrder() {
let a = PeerID(str: "aaaa000000000000")
let b = PeerID(str: "bbbb000000000000")
#expect(MeshTopologyEdge(a, b) == MeshTopologyEdge(b, a))
#expect(Set([MeshTopologyEdge(a, b), MeshTopologyEdge(b, a)]).count == 1)
}
@Test func topologyLayoutPlacesSelfInCenter() {
let nodes: [MeshTopologyDisplayModel.Node] = [
.init(id: "self", label: "me", isSelf: true),
.init(id: "a", label: "alice", isSelf: false),
.init(id: "b", label: "bob", isSelf: false)
]
let size = CGSize(width: 200, height: 200)
let positions = MeshTopologyView.layout(nodes: nodes, in: size)
#expect(positions["self"] == CGPoint(x: 100, y: 100))
#expect(positions.count == 3)
// Ring nodes sit on the same radius around the center.
let radiusA = hypot((positions["a"]?.x ?? 0) - 100, (positions["a"]?.y ?? 0) - 100)
let radiusB = hypot((positions["b"]?.x ?? 0) - 100, (positions["b"]?.y ?? 0) - 100)
#expect(abs(radiusA - radiusB) < 0.001)
#expect(radiusA > 0)
}
}
/// Minimal CommandContextProvider for diagnostics tests; records deferred
/// command output so async /ping results can be asserted.
@MainActor
private final class DiagnosticsMockContext: CommandContextProvider {
var nickname: String = "tester"
var activeChannel: ChannelID = .mesh
var selectedPrivateChatPeer: PeerID?
var blockedUsers: Set<String> = []
let idBridge = NostrIdentityBridge(keychain: MockKeychain())
var nicknameToPeerID: [String: PeerID] = [:]
private(set) var commandOutputs: [String] = []
func getPeerIDForNickname(_ nickname: String) -> PeerID? {
nicknameToPeerID[nickname]
}
func getVisibleGeoParticipants() -> [CommandGeoParticipant] { [] }
func nostrPubkeyForDisplayName(_ displayName: String) -> String? { nil }
func startPrivateChat(with peerID: PeerID) {}
func sendPrivateMessage(_ content: String, to peerID: PeerID) {}
func clearCurrentPublicTimeline() {}
func clearPrivateChat(_ peerID: PeerID) {}
func sendPublicRaw(_ content: String) {}
func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) {}
func addPublicSystemMessage(_ content: String) {}
func toggleFavorite(peerID: PeerID) {}
func addCommandOutput(_ content: String) {
commandOutputs.append(content)
}
}