Skip to content

fix(capture): throttle WaitingForAck Enter re-sends to one per 50ms#437

Open
jondkinney wants to merge 2 commits intofeschber:mainfrom
jondkinney:fix/ack-throttle
Open

fix(capture): throttle WaitingForAck Enter re-sends to one per 50ms#437
jondkinney wants to merge 2 commits intofeschber:mainfrom
jondkinney:fix/ack-throttle

Conversation

@jondkinney
Copy link
Copy Markdown
Contributor

@jondkinney jondkinney commented May 7, 2026

Fixes a sub-millisecond protocol pile-up that produces visible cursor stutter on cross-machine handoffs.

Root cause

When a cross begins, capture transitions to State::WaitingForAck and sends an Enter packet. The original logic also converted every subsequent CaptureEvent::Input (motion event) arriving in that window into a repeat Enter — intended as a reliability hedge against a dropped first Enter.

On a typical LAN the round-trip-to-Ack is 0.4–0.7 ms and the user's mouse fires at 1000 Hz, so each cross was sending 2–10 Enter packets in the gap. The receiver's Enter handler runs once per packet — re-running ReleaseNotify, re-acking, re-warping the cursor on the entry edge. The user sees that cluster of warps as a visible stutter and reads it as "the cross got rejected."

Confirmed via instrumentation: client 0 ACK in 0.7ms after 2 Enter event(s) was a typical line, with the receiver seeing the second Enter Δ 0.1ms since last Enter.

Fix

Throttle re-sends to at most one per 50 ms:

  • The first Enter (on Begin) always fires immediately
  • Subsequent motion events in the same 50 ms window are dropped
  • If the cross stalls past 50 ms with no Ack, the next motion re-sends Enter

50 ms is short enough to be well inside human reaction time on a stalled handshake and long enough that a healthy LAN never re-sends at all.

Note: fix is per-sender

The throttle runs on the sending side, so the user-visible stutter only fully resolves once both peers carry this change. A mixed pair (one upgraded, one not) still sees the stutter when crossing from the unpatched peer. This is worth calling out for anyone shipping or backporting in stages.

Verification (Linux ↔ macOS, real LAN, ~50 crosses each direction)

Captured with diagnostic instrumentation (sender logs ACK latency + Enter count per cross; receiver logs inter-Enter Δ per peer). Diagnostics not included in this PR.

Linux → macOS (sender): 120/120 ACKs after exactly 1 Enter event, latency 0.4–1.0 ms. Zero multi-Enter handshakes.

macOS → Linux (receiver): post-fix sessions show minimum inter-Enter Δ of 470 ms across all crosses — well above the 50 ms throttle window. Pre-fix sessions on the same hardware showed Δ as low as 0.1 ms (the original bug signature, exactly matching the root-cause analysis).

Cursor stutter no longer reproducible after both peers upgraded.

Test plan

  • Local build clean
  • Cross between two LAN peers (Linux ↔ macOS); cursor lands cleanly with no visible stutter
  • Bidirectional verification with sender/receiver instrumentation (numbers above)
  • Pull a network cable mid-cross; cursor recovers within 50 ms once link returns
  • No regression on cross-direction (cursor returns from peer back to host)

🤖 Generated with Claude Code


Related PRs

This PR is part of an effort to split #418 into focused, independently-reviewable pieces.

The split stack (each builds on the previous; review in order):

  1. feat(capture): wall-press auto-release + Bounds protocol foundation #420 — wall-press auto-release + Bounds protocol foundation
  2. feat(cursor-sync): CursorPos protocol + cursor warps without prior Bounds #429 — CursorPos protocol + cursor warps without prior Bounds
  3. feat(capture): suppress cross-machine crossings while host screen is locked #430 — host-lock crossing suppression
  4. feat: peer version exchange via Hello proto event #431 — peer version exchange
  5. fix: hostname resolution via OS resolver + multi-homed DTLS listener #432 — multi-homed DTLS listener + OS-resolver DNS
  6. feat(discovery): mDNS-SD primary-IP hints for service-order-aware dialing #433 — mDNS-SD primary-IP hints
  7. macOS: QoL bundle (LSUIElement, TCC flow, quit-unfreezable, display wake) + UI polish #434 — macOS QoL bundle + UI polish
  8. feat: per-pair scroll & mouse-sensitivity controls + scroll/version fixes #435 — per-pair scroll/sensitivity + scroll & version-exchange fixes
  9. feat(gui): cross-platform GUI singleton via dedicated socket #436 — cross-platform GUI singleton

Plus:

jondkinney and others added 2 commits May 6, 2026 19:50
Originally every `CaptureEvent::Input` arriving while the cross was
in `State::WaitingForAck` got converted into a repeat `Enter`
packet. The intent was reliability — if the first `Enter` was
dropped, the user's next motion would re-trigger it. In practice
on a LAN the round-trip-to-Ack is sub-millisecond and the user's
mouse fires at 1000 Hz, so each cross sent 2–10 `Enter` packets
inside the gap. Each one made the receiver run its 'Enter'
handler again — re-running `ReleaseNotify` and (with the
CursorPos work) re-warping the cursor on its entry edge. The
user sees that cluster of warps as a visible stutter and reads
it as "the cross got rejected."

Throttle re-sends to at most one per 50 ms: the first `Enter`
always fires immediately, subsequent motion events inside that
window are dropped, and a stalled handshake recovers on the
next motion event after 50 ms. 50 ms is short enough to be well
inside human reaction time and long enough that a healthy LAN
never re-sends at all.

No protocol change. The Ack path additionally clears the
re-send timer so subsequent crosses start fresh.
Windows clippy flags `loop { let Some(...) = get_msg() else { break } }`
as while-let-loop. Rewrite to `while let Some(msg) = get_msg() { … }`.
The inner `break` for `RequestType::Exit` still breaks the surrounding
while-let, so semantics are unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant