User Story #58 » 0005-feat-mesh-NetcodeBringUp-kill-switch-default-OFF-to-.patch
| common/src/commonMain/kotlin/com/aether/mofe/messaging/MeshCoordinator.kt | ||
|---|---|---|
|
* [EventNotifier]. Non-arbitrable events map to no claims.
|
||
|
*/
|
||
|
fun emitClaims(event: MeshEvent) {
|
||
|
if (!config.netcodeBringUp) return // netcode disabled → no claims on the control plane
|
||
|
for (claim in MofeNetcodeBridge.claimsFor(event, selfId)) {
|
||
|
claimTracker.track(claim)
|
||
|
val wire = claim.toWire()
|
||
| ... | ... | |
|
scope.launch {
|
||
|
registry.deltas.collect { delta -> applyLocalSideEffects(delta) }
|
||
|
}
|
||
|
// Fold every peer's fused sample (stamped in mesh
|
||
|
// time by the sender) into its history buffer. Runs for both roles;
|
||
|
// the leader also arbitrates against these buffers.
|
||
|
scope.launch {
|
||
|
messaging.fusedSamples.collect { netcodeAdapter.onFusedSample(it.second) }
|
||
|
// Netcode bring-up (kill-switch, default OFF — see MessagingConfig.netcodeBringUp).
|
||
|
// Gated as one unit so a bring-up defect stays fully isolated from the mesh:
|
||
|
// when OFF, none of the netcode collectors/transport run in the mesh scope, so
|
||
|
// ranging, the solve, and Raft proceed exactly as they did before go-live.
|
||
|
if (config.netcodeBringUp) {
|
||
|
// Fold every peer's fused sample (stamped in mesh
|
||
|
// time by the sender) into its history buffer. Runs for both roles;
|
||
|
// the leader also arbitrates against these buffers.
|
||
|
scope.launch {
|
||
|
messaging.fusedSamples.collect { netcodeAdapter.onFusedSample(it.second) }
|
||
|
}
|
||
|
// G4b: the leader arbitrates each inbound claim against its rewound history and
|
||
|
// broadcasts the verdict; every device applies inbound verdicts to its predictions.
|
||
|
netcodeTransport.start(
|
||
|
scope = scope,
|
||
|
eventClaims = messaging.eventClaims,
|
||
|
eventVerdicts = messaging.eventVerdicts,
|
||
|
isLeader = { isLeader },
|
||
|
nowMesh = { netcode.toMeshTime(clock.now()) },
|
||
|
broadcastControl = { messaging.broadcastControl(it) },
|
||
|
)
|
||
|
}
|
||
|
// G4b: the leader arbitrates each inbound claim against its rewound history and
|
||
|
// broadcasts the verdict; every device applies inbound verdicts to its predictions.
|
||
|
netcodeTransport.start(
|
||
|
scope = scope,
|
||
|
eventClaims = messaging.eventClaims,
|
||
|
eventVerdicts = messaging.eventVerdicts,
|
||
|
isLeader = { isLeader },
|
||
|
nowMesh = { netcode.toMeshTime(clock.now()) },
|
||
|
broadcastControl = { messaging.broadcastControl(it) },
|
||
|
)
|
||
|
}
|
||
|
|
||
|
/** Whether the netcode seam is live on this node (see [MessagingConfig.netcodeBringUp]).
|
||
|
* Exposed so the host/diagnostics can surface the bring-up mode; when false the
|
||
|
* [netcode] session is constructed but inert. */
|
||
|
val netcodeBringUpEnabled: Boolean get() = config.netcodeBringUp
|
||
|
|
||
|
// ═══════════════════════════ VOTER (anchor) ═══════════════════════════
|
||
|
|
||
|
/**
|
||
| common/src/commonMain/kotlin/com/aether/mofe/messaging/MeshMessagingService.kt | ||
|---|---|---|
|
val rangingPublishHz: Int = 10,
|
||
|
val fusedSamplePublishHz: Int = 10,
|
||
|
val timeSyncIntervalMillis: Long = 5_000,
|
||
|
/**
|
||
|
* Netcode bring-up kill-switch. Default **OFF**: the coordinator still
|
||
|
* *constructs* the [com.aether.mofe.engine.netcode.NetcodeSession] (so the
|
||
|
* render / aim-tracking APIs the UI reads stay non-null and simply return
|
||
|
* empty), but it does NOT fold the live fused-state plane into it, start the
|
||
|
* claim/verdict transport, or emit claims onto the control plane. This keeps
|
||
|
* the netcode seam fully inert so a bring-up defect cannot regress ranging,
|
||
|
* the solve, or Raft. Flip ON (via the [MeshNode] descriptor's config) to
|
||
|
* exercise go-live on-device; flip back OFF to revert without a rebuild.
|
||
|
*/
|
||
|
val netcodeBringUp: Boolean = false,
|
||
|
)
|
||
|
|
||
|
/** A peer this node fans data-plane traffic out to. Maintained from the registry. */
|
||
| common/src/commonTest/kotlin/com/aether/mofe/messaging/NetcodeArbitrationTest.kt | ||
|---|---|---|
|
@Test
|
||
|
fun memberClaimConfirmedByLeaderRewindArbitration() = runTest {
|
||
|
val h = ClusterHarness(this)
|
||
|
val h = ClusterHarness(this, netcodeBringUp = true) // exercising the netcode go-live path
|
||
|
val leader = h.bootstrapLeader("root", 9921); runCurrent()
|
||
|
val member = h.joinAsMember("client", 9922, leaderHost = "root")
|
||
|
h.pump(40) // membership
|
||
| ... | ... | |
|
@Test
|
||
|
fun leaderArbitratesItsOwnDetectedClaim() = runTest {
|
||
|
val h = ClusterHarness(this)
|
||
|
val h = ClusterHarness(this, netcodeBringUp = true) // exercising the netcode go-live path
|
||
|
val leader = h.bootstrapLeader("root", 9931)
|
||
|
h.pump(10)
|
||
|
leader.registerPredicateMeshWide(zone())
|
||
| common/src/commonTest/kotlin/com/aether/mofe/messaging/NetcodeAssemblyTest.kt | ||
|---|---|---|
|
@Test
|
||
|
fun coordinatorBuffersPeerFusedSamplesIntoTheNetcodeSession() = runTest {
|
||
|
val h = ClusterHarness(this)
|
||
|
val h = ClusterHarness(this, netcodeBringUp = true) // exercising the netcode go-live path
|
||
|
h.bootstrapLeader("root", 9901)
|
||
|
h.pump(5)
|
||
|
val svc = h.services["root"]!!
|
||
| common/src/commonTest/kotlin/com/aether/mofe/messaging/NetcodeBringUpGateTest.kt | ||
|---|---|---|
|
package com.aether.mofe.messaging
|
||
|
import com.aether.mofe.messaging.support.ClusterHarness
|
||
|
import com.aether.mofe.messaging.support.TestFactories
|
||
|
import com.aether.mofe.model.DeviceId
|
||
|
import com.aether.mofe.model.Timestamp
|
||
|
import com.aether.mofe.model.Vector3D
|
||
|
import com.aether.mofe.model.messaging.FusedStateSample
|
||
|
import com.aether.mofe.model.messaging.MeshChannel
|
||
|
import com.aether.mofe.model.messaging.MessageEnvelope
|
||
|
import com.aether.mofe.model.messaging.WireFusedSample
|
||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||
|
import kotlinx.coroutines.test.runTest
|
||
|
import kotlin.test.Test
|
||
|
import kotlin.test.assertEquals
|
||
|
import kotlin.test.assertNotNull
|
||
|
import kotlin.test.assertNull
|
||
|
/**
|
||
|
* The NetcodeBringUp kill-switch ([MessagingConfig.netcodeBringUp], default OFF).
|
||
|
*
|
||
|
* Safeguard for netcode go-live: with the switch OFF the coordinator still owns a
|
||
|
* [com.aether.mofe.engine.netcode.NetcodeSession], but the live seam is inert — no
|
||
|
* peer fused samples are folded in, the claim/verdict transport never starts, and
|
||
|
* [MeshCoordinator.emitClaims] puts nothing on the control plane. So a netcode
|
||
|
* bring-up defect cannot regress ranging, the solve, or Raft. Flipping it ON
|
||
|
* restores full go-live behaviour (covered end to end by NetcodeAssemblyTest /
|
||
|
* NetcodeClaimEmissionTest / NetcodeArbitrationTest).
|
||
|
*
|
||
|
* Both cases deliver the SAME peer sample under opposite flag states and assert
|
||
|
* opposite outcomes, so the gate — not some incidental wiring — is what makes
|
||
|
* netcode live.
|
||
|
*/
|
||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||
|
class NetcodeBringUpGateTest {
|
||
|
private val meshT = 50_000_000L
|
||
|
/** A peer's mesh-time-stamped fused sample arriving on [leaderHost]'s data plane. */
|
||
|
private suspend fun ClusterHarness.deliverPeerSample(leaderHost: String, leaderPort: Int) {
|
||
|
val svc = services[leaderHost]!!
|
||
|
val env = MessageEnvelope(
|
||
|
messageId = "m1", sourceNodeId = "peerX", channel = MeshChannel.RANGING,
|
||
|
sequence = 1L, sentAtMicros = meshT, meshEpoch = svc.currentEpoch,
|
||
|
payload = FusedStateSample(listOf(WireFusedSample(
|
||
|
targetId = "peerX", timestampMicros = meshT,
|
||
|
position = Vector3D(3.0, 4.0, 0.0), velocity = Vector3D(1.0, 0.0, 0.0),
|
||
|
positionSigma = Vector3D(0.1, 0.1, 0.1),
|
||
|
))),
|
||
|
)
|
||
|
ether.endpoint("peerX", 5555).send(NodeAddress(leaderHost, leaderPort), MeshCodec.encode(env))
|
||
|
}
|
||
|
@Test
|
||
|
fun default_off_keeps_the_netcode_seam_inert() = runTest {
|
||
|
val h = ClusterHarness(this) // default: netcodeBringUp = false
|
||
|
val leader = h.bootstrapLeader("root", 9941)
|
||
|
h.pump(5)
|
||
|
h.deliverPeerSample("root", 9941)
|
||
|
h.pump(5)
|
||
|
// The fused-sample collector never launched, so nothing reached the session.
|
||
|
assertNull(
|
||
|
h.coords["root"]!!.netcode.poseAt(DeviceId("peerX"), Timestamp(meshT)),
|
||
|
"with bring-up OFF the peer's sample is not folded into the netcode session",
|
||
|
)
|
||
|
assertEquals(false, leader.netcodeBringUpEnabled)
|
||
|
// And emitClaims puts nothing on the control plane / tracks nothing.
|
||
|
leader.emitClaims(TestFactories.regionEntry("zone-1", "target-7", meshT))
|
||
|
h.pump(5)
|
||
|
assertEquals(0, leader.claimTracker.pendingCount(), "with bring-up OFF no claim is emitted or tracked")
|
||
|
}
|
||
|
@Test
|
||
|
fun bring_up_on_makes_the_same_sample_live() = runTest {
|
||
|
val h = ClusterHarness(this, netcodeBringUp = true)
|
||
|
val leader = h.bootstrapLeader("root", 9942)
|
||
|
h.pump(5)
|
||
|
h.deliverPeerSample("root", 9942)
|
||
|
h.pump(5)
|
||
|
val pose = assertNotNull(
|
||
|
h.coords["root"]!!.netcode.poseAt(DeviceId("peerX"), Timestamp(meshT)),
|
||
|
"with bring-up ON the same peer sample is folded into the session",
|
||
|
)
|
||
|
assertEquals(3.0, pose.position.x, 1e-9)
|
||
|
assertEquals(4.0, pose.position.y, 1e-9)
|
||
|
assertEquals(true, leader.netcodeBringUpEnabled)
|
||
|
}
|
||
|
}
|
||
| common/src/commonTest/kotlin/com/aether/mofe/messaging/NetcodeClaimEmissionTest.kt | ||
|---|---|---|
|
@Test
|
||
|
fun memberEmitsClaimToLeaderAndTracksItPending() = runTest {
|
||
|
val h = ClusterHarness(this)
|
||
|
val h = ClusterHarness(this, netcodeBringUp = true) // exercising the netcode go-live path
|
||
|
h.bootstrapLeader("root", 9911); runCurrent()
|
||
|
val member = h.joinAsMember("client", 9912, leaderHost = "root")
|
||
|
h.pump(40) // membership handshake
|
||
| common/src/commonTest/kotlin/com/aether/mofe/messaging/support/ClusterHarness.kt | ||
|---|---|---|
|
import com.aether.mofe.messaging.MeshCoordinator
|
||
|
import com.aether.mofe.messaging.MeshMessagingService
|
||
|
import com.aether.mofe.messaging.MeshStateRegistry
|
||
|
import com.aether.mofe.messaging.MessagingConfig
|
||
|
import com.aether.mofe.messaging.NodeAddress
|
||
|
import com.aether.mofe.messaging.raft.InMemoryRaftPersistence
|
||
|
import com.aether.mofe.messaging.security.GroupKeyManager
|
||
| ... | ... | |
|
class ClusterHarness(
|
||
|
private val test: TestScope,
|
||
|
startMicros: Long = 1_000L,
|
||
|
/** Enable the netcode seam on every node built by this harness (default OFF,
|
||
|
* matching production). Go-live tests opt in; mesh/Raft scenarios leave it off. */
|
||
|
private val netcodeBringUp: Boolean = false,
|
||
|
) {
|
||
|
val clock = VirtualClock(startMicros)
|
||
|
val bus = VirtualControlBus()
|
||
| ... | ... | |
|
val coord = MeshCoordinator(
|
||
|
selfId = DeviceId(id), engine = engine(), messaging = messaging, registry = registry,
|
||
|
clock = clock, scope = nodeScope, persistence = InMemoryRaftPersistence(), groupKeys = groupKeys,
|
||
|
config = MessagingConfig(netcodeBringUp = netcodeBringUp),
|
||
|
)
|
||
|
coords[id] = coord; registries[id] = registry; services[id] = messaging
|
||
|
scopes[id] = nodeScope; ports[id] = port
|
||