diff --git a/common/src/commonMain/kotlin/com/aether/mofe/messaging/MeshMessagingService.kt b/common/src/commonMain/kotlin/com/aether/mofe/messaging/MeshMessagingService.kt index 9f57c29..9ee351e 100644 --- a/common/src/commonMain/kotlin/com/aether/mofe/messaging/MeshMessagingService.kt +++ b/common/src/commonMain/kotlin/com/aether/mofe/messaging/MeshMessagingService.kt @@ -45,6 +45,13 @@ data class MessagingConfig( /** A peer this node fans data-plane traffic out to. Maintained from the registry. */ data class PeerEndpoint(val nodeId: String, val datagram: NodeAddress) +/** + * One completed clock-sync round-trip: [t1Micros] local send, [t2Micros] leader + * receive, [t3Micros] leader send, [t4Micros] local receive — the four stamps + * `MeshClock.observe` folds into an offset/RTT sample. + */ +data class TimeSyncSample(val t1Micros: Long, val t2Micros: Long, val t3Micros: Long, val t4Micros: Long) + /** * Message hub: assigns sequence numbers + epoch on the way out, applies the * StalenessGate on the way in, and demultiplexes payload types into typed @@ -87,6 +94,7 @@ class MeshMessagingService( private val _groupKeys = MutableSharedFlow(extraBufferCapacity = 4) private val _eventClaims = MutableSharedFlow>(extraBufferCapacity = 32) private val _eventVerdicts = MutableSharedFlow(extraBufferCapacity = 32) + private val _timeSyncResponses = MutableSharedFlow(extraBufferCapacity = 16) val rangingBatches: SharedFlow> = _rangingBatches.asSharedFlow() val fusedSamples: SharedFlow> = _fusedSamples.asSharedFlow() @@ -106,6 +114,8 @@ class MeshMessagingService( /** Inbound netcode claims (leader arbitrates) and verdicts (all devices apply). */ val eventClaims: SharedFlow> = _eventClaims.asSharedFlow() val eventVerdicts: SharedFlow = _eventVerdicts.asSharedFlow() + /** Completed clock-sync round-trips (t1..t4). Sole consumer: the mesh clock. */ + val timeSyncResponses: SharedFlow = _timeSyncResponses.asSharedFlow() val currentEpoch: Long get() = gate.localEpoch fun advanceEpoch(epoch: Long) = gate.advanceEpoch(epoch) @@ -146,6 +156,11 @@ class MeshMessagingService( link.sendFrame(MeshCodec.encode(envelope(MeshChannel.CONTROL, payload))) } + /** Member side: start one clock-sync round-trip to the leader (stamps t1 now). + * The leader stamps t2/t3 and replies; the completed sample surfaces on + * [timeSyncResponses]. Drive this from the member's periodic probe loop. */ + suspend fun probeTimeSync() = sendControl(TimeSync(t1Micros = clock.now().microseconds)) + /** Control plane, leader side: reliable frame to one member. */ suspend fun sendControlTo(nodeId: String, payload: MeshPayload) { links[nodeId]?.sendFrame(MeshCodec.encode(envelope(MeshChannel.CONTROL, payload))) @@ -248,7 +263,12 @@ class MeshMessagingService( ), ), ) + } else { + // Completed round-trip: t1/t2/t3 rode the response; t4 is now. Surface + // it for the mesh clock to fold into an offset/RTT estimate. + _timeSyncResponses.tryEmit( + TimeSyncSample(p.t1Micros, p.t2Micros, p.t3Micros, clock.now().microseconds), + ) } - // responses are consumed by the ClockOffsetEstimator } } \ No newline at end of file diff --git a/common/src/commonTest/kotlin/com/aether/mofe/messaging/ClockSyncSeamTest.kt b/common/src/commonTest/kotlin/com/aether/mofe/messaging/ClockSyncSeamTest.kt new file mode 100644 index 0000000..7707a59 --- /dev/null +++ b/common/src/commonTest/kotlin/com/aether/mofe/messaging/ClockSyncSeamTest.kt @@ -0,0 +1,64 @@ +package com.aether.mofe.messaging + +import com.aether.mofe.engine.netcode.MeshClock +import com.aether.mofe.messaging.support.SkewController +import com.aether.mofe.messaging.support.VirtualControlBus +import com.aether.mofe.messaging.support.VirtualEther +import com.aether.mofe.model.DeviceId +import com.aether.mofe.model.messaging.NodeHello +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * G2 clock seam. A member's `TimeSync` round-trip to the leader used to die on + * arrival ("consumed by the ClockOffsetEstimator" that never existed); now the + * completed four-stamp exchange surfaces on [MeshMessagingService.timeSyncResponses] + * and `MeshClock` recovers the leader−member offset from it. + * + * Per-node skewed clocks (via [SkewController]) make the recovered offset a real, + * non-zero value — proving the wiring, not a tautology. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ClockSyncSeamTest { + + private fun hello(id: String, port: Int) = NodeHello( + nodeId = id, deviceType = "CLIENT", capabilities = emptyList(), + datagramHost = id, datagramPort = port, appliedRevision = 0L, + ) + + @Test + fun memberRecoversLeaderOffsetFromCompletedRoundTrip() = runTest { + val skew = SkewController(startMicros = 1_000_000) + val leaderClock = skew.clock(offsetMicros = +37_000) // leader 37ms ahead of the reference + val memberClock = skew.clock(offsetMicros = -12_000) // member 12ms behind + val bus = VirtualControlBus() + val ether = VirtualEther() + + val leader = MeshMessagingService(DeviceId("L"), ether.endpoint("L", 8100), bus.transportFor("L"), leaderClock, backgroundScope) + val member = MeshMessagingService(DeviceId("m"), ether.endpoint("m", 8101), bus.transportFor("m"), memberClock, backgroundScope) + + leader.startAsLeader(); runCurrent() + member.startAsMember(NodeAddress("L", 0), hello("m", 8101)); runCurrent() + + val meshClock = MeshClock() + backgroundScope.launch { + member.timeSyncResponses.collect { meshClock.observe(it.t1Micros, it.t2Micros, it.t3Micros, it.t4Micros) } + } + runCurrent() + + repeat(4) { member.probeTimeSync(); runCurrent() } + + assertTrue(meshClock.synced, "the round-trip completed and fed the clock (it used to be dropped)") + assertEquals(49_000L, meshClock.offsetMicros, "offset = leader(+37ms) − member(−12ms) = 49ms") + assertEquals( + leaderClock.now().microseconds, + meshClock.toMeshTime(memberClock.now()).microseconds, + "member local + offset resolves to the leader's reading of the same instant", + ) + } +}