User Story #47 » phase2-g2-clock-sync-seam.patch
| common/src/commonMain/kotlin/com/aether/mofe/messaging/MeshMessagingService.kt | ||
|---|---|---|
|
/** 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
|
||
| ... | ... | |
|
private val _groupKeys = MutableSharedFlow<GroupKeyDistribution>(extraBufferCapacity = 4)
|
||
|
private val _eventClaims = MutableSharedFlow<Pair<String, WireEventClaim>>(extraBufferCapacity = 32)
|
||
|
private val _eventVerdicts = MutableSharedFlow<WireEventVerdict>(extraBufferCapacity = 32)
|
||
|
private val _timeSyncResponses = MutableSharedFlow<TimeSyncSample>(extraBufferCapacity = 16)
|
||
|
|
||
|
val rangingBatches: SharedFlow<Pair<String, RangingBatch>> = _rangingBatches.asSharedFlow()
|
||
|
val fusedSamples: SharedFlow<Pair<String, FusedStateSample>> = _fusedSamples.asSharedFlow()
|
||
| ... | ... | |
|
/** Inbound netcode claims (leader arbitrates) and verdicts (all devices apply). */
|
||
|
val eventClaims: SharedFlow<Pair<String, WireEventClaim>> = _eventClaims.asSharedFlow()
|
||
|
val eventVerdicts: SharedFlow<WireEventVerdict> = _eventVerdicts.asSharedFlow()
|
||
|
/** Completed clock-sync round-trips (t1..t4). Sole consumer: the mesh clock. */
|
||
|
val timeSyncResponses: SharedFlow<TimeSyncSample> = _timeSyncResponses.asSharedFlow()
|
||
|
|
||
|
val currentEpoch: Long get() = gate.localEpoch
|
||
|
fun advanceEpoch(epoch: Long) = gate.advanceEpoch(epoch)
|
||
| ... | ... | |
|
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)))
|
||
| ... | ... | |
|
),
|
||
|
),
|
||
|
)
|
||
|
} 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
|
||
|
}
|
||
|
}
|
||
| common/src/commonTest/kotlin/com/aether/mofe/messaging/ClockSyncSeamTest.kt | ||
|---|---|---|
|
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",
|
||
|
)
|
||
|
}
|
||
|
}
|
||
- « Previous
- 1
- 2
- 3
- 4
- Next »