Skip to content

VisionPoseLatencySmokeTest.kt

src/jvmMain/kotlin/afield/examples/VisionPoseLatencySmokeTest.kt · 163 lines

package afield.examples

import afield.graphics.visionos.VISION_POSE_CHANNEL
import afield.graphics.visionos.VISION_POSE_VERSION
import afield.graphics.visionos.VisionPoseLatency
import afield.graphics.visionos.VisionPoseStream
import afield.graphics.visionos.installVisionPoseHandler
import afield.graphics.visionos.visionPoseRawHandler
import kotlin.math.abs

FIELD_TEST id: visionpose.latency runner: jvm-main entrypoint: afield.examples.VisionPoseLatencySmokeTest tags: [smoke, visionos, pose, latency] timeoutSec: 90 expect: exitCode: 0 stdoutContains: - "VisionPoseLatencySmokeTest ok"

object VisionPoseLatencySmokeTest

Guards the clock-offset arithmetic, which is the part of the latency measurement that cannot be checked by looking at it.

A wrong offset does not fail — it produces a plausible latency, off by whatever the two machines' clocks happen to differ by. The number would then be wrong in the one direction nobody double-checks: believable. So the estimator is driven against a synthetic head whose true offset and true one-way delays are known, and the recovered offset is compared with the truth.

Also pinned: that the estimator prefers the least-contended sample rather than the most recent, and that "no pong yet" stays distinguishable from "the offset is zero".

object VisionPoseLatencySmokeTest {

The truth the synthetic head is built from. Recovered, not assumed.

    private const val TRUE_OFFSET_NANOS = 1_234_567_890_000L
    private const val MILLIS = 1_000_000L

fun main(args: Array<String>)

body — 89 lines, from line 42
    @JvmStatic
    fun main(args: Array<String>) {
        installVisionPoseHandler()
        val handler = visionPoseRawHandler() ?: error("no handler registered")

        VisionPoseLatency.reset()
        VisionPoseStream.reset()

        // ---- nothing measured yet ---------------------------------------------------------------
        require(!VisionPoseLatency.offsetKnown) { "the offset must start unknown" }
        require(VisionPoseLatency.clockOffsetNanos == 0L) { "unknown must not masquerade as an offset" }
        require(VisionPoseLatency.report().contains("UNKNOWN")) {
            "a report with no pong must say so rather than print a zero offset as fact"
        }

        // ---- a symmetric round trip recovers the offset ------------------------------------------
        // Several samples, not one, because that is how the estimator is meant to be used: the
        // *first* round trip through this path pays cold class-loading and JSON-parser init inside
        // the measured window, and lands ~200 ms late. That is not a flaw in the estimator, it is the
        // reason it keeps the minimum — but it does mean a single-sample assertion here would be
        // testing the JVM's warm-up, and would have been "fixed" by loosening the tolerance until it
        // no longer checked anything.
        repeat(6) { pong(handler, oneWayNanos = 5 * MILLIS, holdNanos = 2 * MILLIS) }
        require(VisionPoseLatency.offsetKnown) { "a pong must establish the offset" }
        val error = abs(VisionPoseLatency.clockOffsetNanos - TRUE_OFFSET_NANOS)
        // 20 ms is loose against the 5 ms path but tight against every structural error this can have:
        // a sign slip is out by twice the offset (~41 minutes), a missing halving by the offset itself,
        // and swapping the head's two instants by the hold. Host scheduling noise is what is left.
        require(error < 20 * MILLIS) {
            "recovered offset is off by ${error / 1e6} ms — expected ~0 on a symmetric path"
        }

        // The head's own hold must not be charged to the network.
        val rtt = VisionPoseLatency.bestRoundTripNanos
        require(abs(rtt - 10 * MILLIS) < 12 * MILLIS) {
            "round trip should be ~10 ms (two 5 ms crossings, hold excluded), got ${rtt / 1e6} ms"
        }

        // ---- a congested sample must not displace a clean one -----------------------------------
        val cleanOffset = VisionPoseLatency.clockOffsetNanos
        pong(handler, oneWayNanos = 150 * MILLIS, holdNanos = 1 * MILLIS)
        require(VisionPoseLatency.clockOffsetNanos == cleanOffset) {
            "a slower round trip replaced the offset: the estimator must keep the minimum-RTT sample, " +
                "or a contended link estimates the contention rather than the offset"
        }

        // ---- refusals ---------------------------------------------------------------------------
        val before = VisionPoseLatency.bestRoundTripNanos
        handler(pongJson(hostSend = 42L, headRecv = 1L, headSend = 2L))
        require(VisionPoseLatency.bestRoundTripNanos == before) {
            "an unsolicited pong must be ignored — otherwise anything on the bus can move the clock"
        }

        // ---- the device's half round-trips through the wire --------------------------------------
        handler(latencyJson())
        val spans = VisionPoseLatency.deviceSpans
        require(spans.size == 2) { "expected two device spans, got ${spans.keys}" }
        val jitter = spans["head: jitter buffer hold (deliberate)"]
            ?: error("the jitter-buffer span did not survive the wire: ${spans.keys}")
        require(abs(jitter.p50Millis - 33.4) < 0.01) { "p50 lost: ${jitter.p50Millis}" }
        require(jitter.n == 900) { "sample count lost: ${jitter.n}" }

        // ---- host spans are recorded and summarised ----------------------------------------------
        VisionPoseLatency.record(VisionPoseLatency.LATCH_TO_SUBMIT, 4 * MILLIS)
        VisionPoseLatency.record(VisionPoseLatency.LATCH_TO_SUBMIT, 8 * MILLIS)
        VisionPoseLatency.record(VisionPoseLatency.SUBMIT_TO_ENCODED, 11 * MILLIS)
        // A negative span is a mis-attribution, and must be dropped rather than counted as zero —
        // counting it would quietly pull every percentile down.
        VisionPoseLatency.record(VisionPoseLatency.SUBMIT_TO_ENCODED, -5 * MILLIS)

        // ---- the readout names every stage, including what it cannot see -------------------------
        val report = VisionPoseLatency.report()
        listOf(
            VisionPoseLatency.POSE_AGE_AT_LATCH,
            VisionPoseLatency.LATCH_TO_SUBMIT,
            VisionPoseLatency.SUBMIT_TO_ENCODED,
            "head: jitter buffer hold (deliberate)",
            "compositor frame",
        ).forEach { needle ->
            require(report.contains(needle)) { "the report omits '$needle':\n$report" }
        }
        require(!report.contains("UNKNOWN")) { "the offset is known by now" }
        require(Regex("""submit to encoded[^\n]*n=1""").containsMatchIn(report)) {
            "the negative span was counted; it must be dropped:\n$report"
        }

        println(report)
        println("VisionPoseLatencySmokeTest ok")
    }

Drive one full round trip against a synthetic head with a known offset.

The host's send instant is read back out of the ping it just built, so this exercises the real ping()/acceptPong() pair rather than a re-implementation of the protocol. The delay is actually slept, because the host stamps arrival from its own clock — fabricating that instant would only test the arithmetic against itself.

    private fun pong(handler: (String) -> Any?, oneWayNanos: Long, holdNanos: Long) {
        val ping = VisionPoseLatency.ping()
        val hostSend = Regex(""""hostSendNanos":(\d+)""").find(ping)?.groupValues?.get(1)?.toLong()
            ?: error("ping() did not carry a host send timestamp: $ping")
        val headRecv = hostSend + TRUE_OFFSET_NANOS + oneWayNanos
        val headSend = headRecv + holdNanos
        val total = oneWayNanos * 2 + holdNanos
        Thread.sleep(total / 1_000_000, (total % 1_000_000).toInt())
        handler(pongJson(hostSend, headRecv, headSend))
    }

    private fun pongJson(hostSend: Long, headRecv: Long, headSend: Long) =
        """{"channel":"$VISION_POSE_CHANNEL","type":"${VisionPoseLatency.PONG}",""" +
            """"version":$VISION_POSE_VERSION,"hostSendNanos":$hostSend,""" +
            """"headRecvNanos":$headRecv,"headSendNanos":$headSend}"""

    private fun latencyJson() = """
        {"channel":"$VISION_POSE_CHANNEL","type":"${VisionPoseLatency.REPORT}",
         "version":$VISION_POSE_VERSION,"device":"test-head",
         "spans":{"head: jitter buffer hold (deliberate)":{"p50Millis":33.4,"p95Millis":50.1,"maxMillis":66.8,"n":900},
                  "head: POSE to texture (motion-to-photon lower bound)":{"p50Millis":72.5,"p95Millis":98.2,"maxMillis":140.0,"n":900}}}
    """.trimIndent()
}