Repository navigation
Track per-hop mobile keystroke latency and pacer rate in the existing Axiom window #14270
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
236bc9e
Carry Mac stage stamps and pacer samples on render-grid frames
azooz2003-bit dfa220b
Track per-hop keystroke latency and pacer rate in the existing window…
azooz2003-bit a2c9bed
Merge remote-tracking branch 'origin/main' into feat-mobile-latency-s…
azooz2003-bit 86772ec
Drop duration comparison flagged by the test-determinism guard
azooz2003-bit File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
142 changes: 142 additions & 0 deletions
142
Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalHostTiming.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,142 @@ | ||
| /// Mac-side timestamps and pacing state carried on a render-grid frame so the | ||
| /// phone can split keystroke latency into per-hop stages without the Mac | ||
| /// emitting any telemetry of its own. | ||
| /// | ||
| /// Attached sparingly to keep wire cost negligible: input stamps only on the | ||
| /// first frame that carries a newly accepted input marker, and a pacer sample | ||
| /// at most about once per second per surface. Every clock value is the Mac's | ||
| /// monotonic uptime in microseconds; the phone never compares these to its own | ||
| /// clock directly (see ``MobileTerminalClockOffsetEstimator``). | ||
| public struct MobileTerminalHostTiming: Codable, Equatable, Sendable { | ||
| /// When the Mac's input lane first held the input, before the hop to the | ||
| /// main actor that accepts it. | ||
| public var inputReceivedMicros: UInt64? | ||
| /// When the terminal accepted the input. | ||
| public var inputAcceptedMicros: UInt64? | ||
| /// When the Mac captured the render-grid frame carrying the echo. | ||
| public var frameCapturedMicros: UInt64? | ||
| /// When the encoded frame was handed to the connection queues. Time spent | ||
| /// in the Mac's send queues after this point is counted as downlink. | ||
| public var frameDispatchedMicros: UInt64? | ||
| /// The emission pacer's state for this surface since the previous sample. | ||
| public var pacer: MobileTerminalPacerSample? | ||
|
|
||
| public init( | ||
| inputReceivedMicros: UInt64? = nil, | ||
| inputAcceptedMicros: UInt64? = nil, | ||
| frameCapturedMicros: UInt64? = nil, | ||
| frameDispatchedMicros: UInt64? = nil, | ||
| pacer: MobileTerminalPacerSample? = nil | ||
| ) { | ||
| self.inputReceivedMicros = inputReceivedMicros | ||
| self.inputAcceptedMicros = inputAcceptedMicros | ||
| self.frameCapturedMicros = frameCapturedMicros | ||
| self.frameDispatchedMicros = frameDispatchedMicros | ||
| self.pacer = pacer | ||
| } | ||
|
|
||
| enum CodingKeys: String, CodingKey { | ||
| case inputReceivedMicros = "input_received_us" | ||
| case inputAcceptedMicros = "input_accepted_us" | ||
| case frameCapturedMicros = "frame_captured_us" | ||
| case frameDispatchedMicros = "frame_dispatched_us" | ||
| case pacer | ||
| } | ||
|
|
||
| /// Whether the input stamps describe one complete, ordered Mac pass. | ||
| public var hasCompleteInputStamps: Bool { | ||
| guard let received = inputReceivedMicros, | ||
| let accepted = inputAcceptedMicros, | ||
| let captured = frameCapturedMicros, | ||
| let dispatched = frameDispatchedMicros else { return false } | ||
| return received <= accepted && accepted <= captured && captured <= dispatched | ||
| } | ||
| } | ||
|
|
||
| /// The emission pacer's state for one surface over one sampling interval. | ||
| public struct MobileTerminalPacerSample: Codable, Equatable, Sendable { | ||
| /// The pacing period in effect when sampled, in milliseconds. | ||
| public var periodMillis: Int | ||
| /// Frames emitted since the previous sample. | ||
| public var emitted: Int | ||
| /// Updates coalesced (not captured) since the previous sample. | ||
| public var coalesced: Int | ||
| /// Transport shed events that widened the period since the previous sample. | ||
| public var sheds: Int | ||
|
|
||
| public init(periodMillis: Int, emitted: Int, coalesced: Int, sheds: Int) { | ||
| self.periodMillis = periodMillis | ||
| self.emitted = emitted | ||
| self.coalesced = coalesced | ||
| self.sheds = sheds | ||
| } | ||
|
|
||
| enum CodingKeys: String, CodingKey { | ||
| case periodMillis = "period_ms" | ||
| case emitted | ||
| case coalesced | ||
| case sheds | ||
| } | ||
| } | ||
|
|
||
| /// Splits a keystroke's network time into uplink and downlink across two | ||
| /// unsynchronized monotonic clocks. | ||
| /// | ||
| /// One sample is the four NTP timestamps: phone send `t1`, Mac receive `t2`, | ||
| /// Mac dispatch `t3`, phone receive `t4`. The round trip spent on the network | ||
| /// is exact without any clock sync: `(t4 - t1) - (t3 - t2)`. Splitting it | ||
| /// needs the clock offset, which is estimated from the sample with the | ||
| /// smallest network round trip seen so far: queueing inflates delay, so the | ||
| /// quickest exchange is the one whose paths were closest to symmetric. The | ||
| /// split is an estimate; the round trip is not. | ||
| public struct MobileTerminalClockOffsetEstimator: Sendable { | ||
| /// Estimated (Mac clock - phone clock), in nanoseconds. | ||
| public private(set) var offsetNanos: Int64? | ||
| private var bestRoundTripNanos: UInt64? | ||
|
|
||
| public init() {} | ||
|
|
||
| /// Network time for one exchange, in nanoseconds. | ||
| public struct Split: Equatable, Sendable { | ||
| public let roundTripNanos: UInt64 | ||
| public let uplinkNanos: UInt64 | ||
| public let downlinkNanos: UInt64 | ||
| } | ||
|
|
||
| /// Records one exchange and returns its network split, or nil when the | ||
| /// timestamps are inconsistent. Phone times are nanoseconds; Mac times are | ||
| /// microseconds, as carried on the wire. | ||
| public mutating func observe( | ||
| phoneSendNanos t1: UInt64, | ||
| macReceiveMicros: UInt64, | ||
| macDispatchMicros: UInt64, | ||
| phoneReceiveNanos t4: UInt64 | ||
| ) -> Split? { | ||
| let t2 = macReceiveMicros &* 1_000 | ||
| let t3 = macDispatchMicros &* 1_000 | ||
| guard t4 >= t1, t3 >= t2 else { return nil } | ||
| let total = t4 - t1 | ||
| let hostTime = t3 - t2 | ||
| guard total >= hostTime else { return nil } | ||
| let roundTrip = total - hostTime | ||
| let sampleOffset = (Int64(bitPattern: t2 &- t1) &+ Int64(bitPattern: t3 &- t4)) / 2 | ||
| if bestRoundTripNanos.map({ roundTrip < $0 }) ?? true { | ||
| bestRoundTripNanos = roundTrip | ||
| offsetNanos = sampleOffset | ||
| } | ||
| let offset = offsetNanos ?? sampleOffset | ||
| let uplink = Int64(bitPattern: t2 &- t1) &- offset | ||
| let clampedUplink = UInt64(max(0, min(Int64(roundTrip), uplink))) | ||
| return Split( | ||
| roundTripNanos: roundTrip, | ||
| uplinkNanos: clampedUplink, | ||
| downlinkNanos: roundTrip - clampedUplink | ||
| ) | ||
| } | ||
|
|
||
| /// Forget the offset, for a new connection or Mac. | ||
| public mutating func reset() { | ||
| offsetNanos = nil | ||
| bestRoundTripNanos = nil | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
106 changes: 106 additions & 0 deletions
106
Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/MobileTerminalHostTimingTests.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,106 @@ | ||
| import Foundation | ||
| import Testing | ||
| @testable import CMUXMobileCore | ||
|
|
||
| private func frame(hostTiming: MobileTerminalHostTiming? = nil) throws -> MobileTerminalRenderGridFrame { | ||
| var frame = try MobileTerminalRenderGridFrame( | ||
| surfaceID: "terminal-a", | ||
| stateSeq: 1, | ||
| renderEpoch: "epoch-1", | ||
| renderRevision: 1, | ||
| columns: 8, | ||
| rows: 2, | ||
| full: true, | ||
| clearedRows: [], | ||
| rowSpans: [.init(row: 0, column: 0, text: "row")] | ||
| ) | ||
| frame.hostTiming = hostTiming | ||
| return frame | ||
| } | ||
|
|
||
| @Test func hostTimingRoundTripsOnTheFrame() throws { | ||
| let timing = MobileTerminalHostTiming( | ||
| inputReceivedMicros: 1_000, | ||
| inputAcceptedMicros: 1_200, | ||
| frameCapturedMicros: 5_000, | ||
| frameDispatchedMicros: 5_400, | ||
| pacer: MobileTerminalPacerSample(periodMillis: 90, emitted: 11, coalesced: 16, sheds: 0) | ||
| ) | ||
| let data = try JSONEncoder().encode(try frame(hostTiming: timing)) | ||
| let decoded = try JSONDecoder().decode(MobileTerminalRenderGridFrame.self, from: data) | ||
| #expect(decoded.hostTiming == timing) | ||
| let json = try #require(String(data: data, encoding: .utf8)) | ||
| #expect(json.contains("\"host_timing\"")) | ||
| #expect(json.contains("\"period_ms\":90")) | ||
| } | ||
|
|
||
| @Test func framesWithoutTimingDecodeAndOmitTheKey() throws { | ||
| let data = try JSONEncoder().encode(try frame()) | ||
| let json = try #require(String(data: data, encoding: .utf8)) | ||
| // Most frames carry no timing; the key must not cost bytes on them. | ||
| #expect(!json.contains("host_timing")) | ||
| let decoded = try JSONDecoder().decode(MobileTerminalRenderGridFrame.self, from: data) | ||
| #expect(decoded.hostTiming == nil) | ||
| } | ||
|
|
||
| @Test func completeInputStampsRequireOrderedMacPass() { | ||
| var timing = MobileTerminalHostTiming( | ||
| inputReceivedMicros: 10, inputAcceptedMicros: 20, | ||
| frameCapturedMicros: 30, frameDispatchedMicros: 40 | ||
| ) | ||
| #expect(timing.hasCompleteInputStamps) | ||
| timing.frameCapturedMicros = 15 | ||
| #expect(!timing.hasCompleteInputStamps) | ||
| timing.frameCapturedMicros = nil | ||
| #expect(!timing.hasCompleteInputStamps) | ||
| } | ||
|
|
||
| @Test func estimatorRoundTripIsExactWithoutClockSync() { | ||
| var estimator = MobileTerminalClockOffsetEstimator() | ||
| // Mac clock runs 5s ahead. Uplink 40ms, Mac work 10ms, downlink 60ms. | ||
| let skew: UInt64 = 5_000_000_000 | ||
| let t1: UInt64 = 1_000_000_000 | ||
| let split = estimator.observe( | ||
| phoneSendNanos: t1, | ||
| macReceiveMicros: (t1 + skew + 40_000_000) / 1_000, | ||
| macDispatchMicros: (t1 + skew + 50_000_000) / 1_000, | ||
| phoneReceiveNanos: t1 + 110_000_000 | ||
| ) | ||
| #expect(split?.roundTripNanos == 100_000_000) | ||
| #expect((split?.uplinkNanos ?? 0) + (split?.downlinkNanos ?? 0) == 100_000_000) | ||
| } | ||
|
|
||
| @Test func estimatorSplitsUsingTheQuickestExchangesOffset() { | ||
| var estimator = MobileTerminalClockOffsetEstimator() | ||
| let skew: UInt64 = 5_000_000_000 | ||
| // Quick, symmetric exchange: 20ms each way. Fixes the offset. | ||
| var t1: UInt64 = 1_000_000_000 | ||
| _ = estimator.observe( | ||
| phoneSendNanos: t1, | ||
| macReceiveMicros: (t1 + skew + 20_000_000) / 1_000, | ||
| macDispatchMicros: (t1 + skew + 30_000_000) / 1_000, | ||
| phoneReceiveNanos: t1 + 50_000_000 | ||
| ) | ||
| // A later exchange whose uplink stalled for 900ms (radio wake-up). | ||
| t1 = 2_000_000_000 | ||
| let stalled = estimator.observe( | ||
| phoneSendNanos: t1, | ||
| macReceiveMicros: (t1 + skew + 920_000_000) / 1_000, | ||
| macDispatchMicros: (t1 + skew + 930_000_000) / 1_000, | ||
| phoneReceiveNanos: t1 + 950_000_000 | ||
| ) | ||
| // The stall is attributed to the uplink, not smeared across both paths. | ||
| #expect(stalled?.uplinkNanos == 920_000_000) | ||
| #expect(stalled?.downlinkNanos == 20_000_000) | ||
| } | ||
|
|
||
| @Test func estimatorRejectsInconsistentTimestamps() { | ||
| var estimator = MobileTerminalClockOffsetEstimator() | ||
| // Mac work longer than the whole phone-observed exchange is impossible. | ||
| #expect(estimator.observe( | ||
| phoneSendNanos: 1_000_000_000, | ||
| macReceiveMicros: 0, | ||
| macDispatchMicros: 500_000, | ||
| phoneReceiveNanos: 1_100_000_000 | ||
| ) == nil) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: manaflow-ai/cmux
Length of output: 3565
🏁 Script executed:
Repository: manaflow-ai/cmux
Length of output: 42058
🏁 Script executed:
Repository: manaflow-ai/cmux
Length of output: 41955
🏁 Script executed:
Repository: manaflow-ai/cmux
Length of output: 15589
Reset and age the per-surface clock offset.
SurfaceStateretainsclockOffsetwhen the app backgrounds or terminal output resets. The estimator keeps the smallest round trip indefinitely. PhoneDispatchTime.uptimeNanosecondspauses during sleep, while Mac timestamps continue. After wake, the stored Mac-minus-phone offset can therefore be stale. New samples keep using it unless they have a smaller round trip.The round-trip value remains correct, but the split can be wrong. If the sleep-induced offset exceeds one network leg, clamping can assign the sample entirely to the other leg. A long foreground session can also accumulate clock drift because the best sample does not expire.
Reset the estimator at both lifecycle boundaries. Also expire the best sample using phone time so continuous sessions can adopt a newer sample. These fixes address different cases: phone-time aging does not detect a sleep gap because that clock pauses.
Suggested fix
// setForeground(false): for surface in states.values { surface.inputStarts.removeAll(keepingCapacity: true) surface.presentationStarts.removeAll(keepingCapacity: true) surface.consecutiveSlowFrames = 0 surface.firstReceivedAt = nil + surface.clockOffset.reset() } public func outputDropped(surfaceID: String) { guard isForeground, let surface = states[surfaceID] else { return } surface.window.droppedCount += 1 surface.inputStarts.removeAll(keepingCapacity: true) surface.presentationStarts.removeAll(keepingCapacity: true) + surface.clockOffset.reset() }🤖 Prompt for AI Agents