#3555·neovide

macOS/Metal: key autorepeat rate intermittently halves while holding a key — repeats are coalesced by the OS when the event thread is busy in `nextDrawable()`

Author: vsndrgCreated Aug 24, 2026Updated Aug 24, 2026
Labelsbug

Describe the bug

While holding h/j/k/l in normal mode, cursor movement intermittently slows down to exactly half the configured key repeat rate. The inter-key interval, as observed inside Neovim, jumps from the normal 16.7 ms to a clean 33.4 ms — every second autorepeat is missing. The regime can flip mid-hold: one part of a single key hold runs at full rate, the rest at half rate. Frame rate is unaffected (profiler shows a steady 120 FPS during episodes); rendering stays smooth, only input is degraded.

I traced this down to the delivery layer with measurements at four points of the input pipeline (details below): the WindowServer generates and offers every repeat on time, but the events that reach NSApplication.sendEvent: are already thinned out. The loss happens system-side, and it correlates precisely with the app's main-thread event-dispatch latency: when latency stays under roughly one display frame (~8.3 ms at 120 Hz), all repeats arrive; once it crosses that threshold, macOS silently coalesces every second pending autorepeat (repeats are not queued up for a slow consumer — likely the same protection that prevents a frozen app from spewing buffered characters on recovery; I found no public documentation for this behavior).

With the Metal renderer that latency is structural: VSync::MacosMetal() provides no active pacing (wait_for_vsync() is a no-op, uses_winit_throttling() is false), so the only thing that throttles rendering is the blocking nextDrawable() call — executed on the winit event thread. Rendering plus that block keep the main thread busy ~6–10 ms of every 8.3 ms frame, so dispatch latency rides right at the OS threshold and slowly phase-drifts across it (the keyboard repeat clock and the display clock are independent), which is why the halving comes and goes unpredictably mid-hold.

This may be the root cause of #1336.

To Reproduce

Hard to reproduce deterministically — it depends on dispatch-latency phase drift. A fast key repeat is likely a precondition: I run defaults write -g KeyRepeat -int 1 (~16.7 ms effective interval; macOS quantizes autorepeat to 60 Hz ticks, so this is also what a terminal frontend measures). With the default, much slower repeat rate the pending-event window that triggers the coalescing may never occur. What worked for me: ProMotion display at adaptive 120 Hz, any Rust file with rust-analyzer attached (though LSP/CPU load turned out to be irrelevant), hold j/k for a few seconds repeatedly; episodes typically show up within minutes. Detection is easiest with a vim.on_key-based recorder measuring inter-key gaps (mine auto-captured a histogram whenever the p50 gap exceeded 1.7× the burst's own p10).

Expected behavior

Held keys repeat at the OS-configured rate (KeyRepeat=1, ~16.7 ms effective) regardless of what the renderer is doing, as they do in terminal frontends on the same machine.

Evidence (four-point bracket, one 2.4 s l-hold during an episode)

Observation point Events Intervals
CGEventTap at the WindowServer session (listen-only, post-Karabiner) 82 all 16.7 ms
NSApplication.sendEvent: (instrumented winit build) 47 10×16.7 + 35×33.4 ms
NSView.keyDown: → winit KeyboardInput 47 — identical timestamp set
Neovim (vim.on_key) 47 matches

Zero loss inside the app: the sendEvent: and keyDown: streams are diff-identical across whole sessions. The missing events are absent from the delivered stream entirely — the HID timestamps of the survivors are 33.4 ms apart, so the intermediate events were never delivered late; they were dropped/coalesced before the run-loop dequeue.

Dispatch latency, measured as (dequeue time − NSEvent HID timestamp, same mach clock): ~6.5 ms during clean stretches, ~9.5 ms during halved stretches — the regime flips exactly with that level. Additionally, every gap bin I ever captured (16.7 / 25 / 33.4 / 41.6 / 58.3 ms) is a multiple of the 8.3 ms frame, i.e. event dequeue is frame-quantized by the busy main thread.

Ruled out with measurements: Neovim config / plugin cost (key→CursorMoved p95 ≤ 0.5 ms during episodes), rust-analyzer / CPU load (episodes at load average 1.66), Karabiner-Elements (tap sits downstream of it and sees a full stream; also reproduced with Karabiner disabled), ProMotion refresh switching (120 FPS held steady during episodes), winit's IME state machine (every keyDown: that arrived was emitted, IME state Disabled throughout), cursor animation (keys are physically missing, not late-rendered).

Fix

PR: [link] — switch the Metal renderer's vsync from the passive VSync::MacosMetal() to the CVDisplayLink-driven VSync::MacosDisplayLink that the OpenGL path already uses on macOS. Rendering then starts right after the vsync tick, when a drawable has just been freed, so nextDrawable() returns immediately and the event thread stays responsive; displaySyncEnabled remains on, so presentation stays aligned with refresh. With the patch, dispatch latency stays well under the threshold and I could not reproduce a single episode in extended daily use (previously: several episodes per hour), with scrolling as smooth as before.

Note that --no-vsync is not a workaround: it swaps pacing to a software timer at g:neovide_refresh_rate (default 60), which makes scrolling visibly choppy on a 120 Hz display.

Desktop (please complete the following information):

  • OS: macOS 26.5.2 (25F84), MacBook Pro, ProMotion display (adaptive 24–120 Hz)
  • Neovide Version: 0.16.2 (Homebrew), reproduced identically on a source build; code path unchanged on current main
  • Neovim Version: 0.12.2

Please run neovide --log and paste the contents of the log file here if it exists

Not applicable — the standard log shows nothing relevant (the loss is upstream of Neovide's keyboard handling); the data above comes from an instrumented winit build and an external event tap.