Skip to content

Commit f151a66

Browse files
authored
fix(windows): dispatch keys off the low-level hook thread, as the other taps do (#1617)
1 parent 5788d47 commit f151a66

8 files changed

Lines changed: 372 additions & 45 deletions

File tree

internal/adapter/eventtap/adapter.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -202,9 +202,10 @@ func (a *Adapter) PostModifierEvent(modifier string, isDown bool) {
202202
// through SetModifierPassthrough, which takes mu. Holding mu across the wait
203203
// inverts the documented handler → adapter order, so a shutdown racing a focus
204204
// change deadlocked with neither side able to give way. The Linux tap's own
205-
// Destroy releases its lock before waiting for the same reason. Windows waits
206-
// too, but bounded: its hook join gives up after 250ms and reaps in the
207-
// background, for this same hazard one layer down.
205+
// Destroy releases its lock before waiting for the same reason, and the
206+
// Windows tap waits the same way since it gained a dispatcher
207+
// (dispatchWg.Wait() in eventtap/windows/tap.go); only the hook join beneath
208+
// it stays bounded at 250ms.
208209
//
209210
// What mu still covers is the state: the adapter marks itself destroyed and
210211
// disabled under it, in one hold, before letting go — so a caller racing the

internal/adapter/eventtap/adapter_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@ const blockedCallTimeout = 2 * time.Second
1717
// fakeTap stands in for a platform tap. Every method records that it was
1818
// reached, so a test can say what the adapter did and did not forward.
1919
//
20-
// onDestroy is what makes it useful here: both real backends spend their
21-
// Destroy waiting for the key dispatcher to drain — dispatchWg.Wait() on Linux,
22-
// stopDispatcher on macOS — and that wait runs whatever the dispatcher is in
20+
// onDestroy is what makes it useful here: all three real backends spend their
21+
// Destroy waiting for the key dispatcher to drain — dispatchWg.Wait() on Linux
22+
// and Windows, stopDispatcher on macOS — and that wait runs whatever the dispatcher is in
2323
// the middle of delivering. onDestroy is where a test puts that.
2424
type fakeTap struct {
2525
mu sync.Mutex

internal/adapter/eventtap/windows/tap.go

Lines changed: 112 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"context"
77
"strings"
88
"sync"
9+
"sync/atomic"
910

1011
"go.uber.org/zap"
1112

@@ -15,6 +16,19 @@ import (
1516
"github.com/y3owk1n/neru/internal/domain/keyvocab"
1617
)
1718

19+
// dispatchChBufferSize bounds how many events the hook thread can queue ahead
20+
// of the dispatcher. The Linux tap uses the same figure.
21+
const dispatchChBufferSize = 256
22+
23+
// dispatchEvent is one item on the dispatcher's queue: a key for the handler,
24+
// or a passthrough notification, which rides the same queue so it lands after
25+
// the keys that preceded it.
26+
type dispatchEvent struct {
27+
key string
28+
passthrough tap.PassthroughCallback
29+
epoch uint64
30+
}
31+
1832
// EventTap is a keyboard event interceptor on Windows.
1933
type EventTap struct {
2034
logger *zap.Logger
@@ -44,6 +58,26 @@ type EventTap struct {
4458
heldModifiers map[string]struct{}
4559

4660
hook *winplatform.KeyboardHook
61+
62+
// dispatchCh carries events from the hook thread to dispatchLoop, the one
63+
// goroutine that calls the handler. Windows silently removes a
64+
// WH_KEYBOARD_LL hook whose procedure overruns LowLevelHooksTimeout, and
65+
// the handler holds the mode lock across a click, a mode exit or a hint
66+
// refresh, so the hook procedure only classifies the event and enqueues.
67+
// The channel and loop live for the tap's lifetime, like the Linux tap's;
68+
// stopDispatch ends them from Destroy, and the channel is never closed,
69+
// so a key the hook delivers during teardown is dropped rather than sent
70+
// on a closed channel.
71+
dispatchCh chan dispatchEvent
72+
stopDispatch chan struct{}
73+
stopOnce sync.Once
74+
dispatchWg sync.WaitGroup
75+
76+
// dispatchEpoch is stamped on every event as it is queued and bumped on
77+
// every Disable. dispatchLoop drops an event stamped with an earlier
78+
// epoch, so a key the hook read while the mode was exiting is not
79+
// delivered to whatever mode comes next.
80+
dispatchEpoch atomic.Uint64
4781
}
4882

4983
// NewEventTap creates a new event tap.
@@ -52,10 +86,16 @@ func NewEventTap(callback tap.Callback, logger *zap.Logger) *EventTap {
5286
logger = zap.NewNop()
5387
}
5488

55-
return &EventTap{
56-
logger: logger.Named("eventtap"),
57-
callback: callback,
89+
eventTap := &EventTap{
90+
logger: logger.Named("eventtap"),
91+
callback: callback,
92+
dispatchCh: make(chan dispatchEvent, dispatchChBufferSize),
93+
stopDispatch: make(chan struct{}),
5894
}
95+
96+
eventTap.dispatchWg.Go(eventTap.dispatchLoop)
97+
98+
return eventTap
5999
}
60100

61101
// Enable enables the event tap.
@@ -104,11 +144,33 @@ func (et *EventTap) Disable() {
104144
if hook != nil {
105145
hook.Stop()
106146
}
147+
148+
// Anything still queued was read by the hook before it stopped and belongs
149+
// to the mode that just exited. The bump covers an event dispatchLoop has
150+
// already taken off the channel; the drain empties the rest. Disable runs
151+
// on the dispatcher itself when a key exits the mode, which is why neither
152+
// step may wait on it.
153+
et.dispatchEpoch.Add(1)
154+
155+
for {
156+
select {
157+
case <-et.dispatchCh:
158+
default:
159+
return
160+
}
161+
}
107162
}
108163

109-
// Destroy destroys the event tap.
164+
// Destroy disables the tap and stops its dispatcher, waiting for a key that
165+
// is being delivered to finish. Safe to call more than once.
110166
func (et *EventTap) Destroy() {
111167
et.Disable()
168+
169+
et.stopOnce.Do(func() {
170+
close(et.stopDispatch)
171+
})
172+
173+
et.dispatchWg.Wait()
112174
}
113175

114176
// SetHotkeys sets the hotkeys. These are the global [hotkeys] chords the
@@ -414,27 +476,66 @@ func (et *EventTap) shouldPassthroughChord(chord string) bool {
414476
return true
415477
}
416478

417-
// firePassthroughCallback invokes the registered passthrough callback (if any)
418-
// on its own goroutine and without holding et.mu. The hook procedure runs on
419-
// the hook thread, where the mode handler's lock must not be taken: mode exit
420-
// stops the hook while holding it (see KeyboardHook.Stop).
479+
// firePassthroughCallback queues the registered passthrough callback (if any)
480+
// for the dispatcher, so it runs off the hook thread and after the keys that
481+
// preceded it.
421482
func (et *EventTap) firePassthroughCallback() {
422483
et.mu.RLock()
423484
callback := et.passthroughCallback
424485
et.mu.RUnlock()
425486

426487
if callback != nil {
427-
go callback()
488+
et.enqueue(dispatchEvent{passthrough: callback})
428489
}
429490
}
430491

492+
// dispatchKey queues a key for the dispatcher. It never blocks: the hook
493+
// procedure calls it on the hook thread, which has to return before Windows
494+
// gives up on it.
431495
func (et *EventTap) dispatchKey(key string) {
496+
if key != "" {
497+
et.enqueue(dispatchEvent{key: key})
498+
}
499+
}
500+
501+
func (et *EventTap) enqueue(event dispatchEvent) {
502+
event.epoch = et.dispatchEpoch.Load()
503+
504+
select {
505+
case <-et.stopDispatch:
506+
case et.dispatchCh <- event:
507+
default:
508+
et.logger.Warn("Dispatch queue full, dropping event")
509+
}
510+
}
511+
512+
// dispatchLoop delivers queued events to the handler, in order, on this one
513+
// goroutine.
514+
func (et *EventTap) dispatchLoop() {
515+
for {
516+
select {
517+
case <-et.stopDispatch:
518+
return
519+
case event := <-et.dispatchCh:
520+
et.deliver(event)
521+
}
522+
}
523+
}
524+
525+
func (et *EventTap) deliver(event dispatchEvent) {
526+
if et.dispatchEpoch.Load() != event.epoch {
527+
return
528+
}
529+
432530
et.mu.RLock()
433531
callback := et.callback
434532
et.mu.RUnlock()
435533

436-
if callback != nil && key != "" {
437-
callback(key)
534+
switch {
535+
case event.passthrough != nil:
536+
event.passthrough()
537+
case callback != nil:
538+
callback(event.key)
438539
}
439540
}
440541

internal/adapter/eventtap/windows/tap_hotkey_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -75,13 +75,13 @@ func TestEventTap_SetHotkeys_ReplacesTheList(t *testing.T) {
7575
func TestEventTap_HandleKey_StickyModifierDoesNotFireGlobalHotkey(t *testing.T) {
7676
t.Parallel()
7777

78-
var dispatched []string
79-
80-
tap := NewEventTap(func(key string) { dispatched = append(dispatched, key) }, nil)
78+
tap := newTestTap(t)
8179
tap.SetHotkeys([]string{"Ctrl+Shift+J"})
8280
tap.notePostedModifier("shift", true)
8381

84-
if !tap.handleKey("Ctrl+Shift+J", false) {
82+
dispatched, consumed := routedKey(t, tap, "Ctrl+Shift+J")
83+
84+
if !consumed {
8585
t.Fatal("Ctrl+J with sticky Shift was handed to RegisterHotKey instead of consumed")
8686
}
8787

internal/adapter/eventtap/windows/tap_passthrough_test.go

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,22 +16,42 @@ import (
1616
const chordCtrlC = "ctrl+c"
1717

1818
// routedKey drives handleKey for one key-down and reports where it went.
19+
// Delivery is asynchronous, so a sentinel is queued behind the key and the
20+
// keys that arrive ahead of it are the ones the chord dispatched: the
21+
// dispatcher delivers in order, which is what makes the sentinel a fence.
1922
func routedKey(t *testing.T, eventTap *EventTap, key string) ([]string, bool) {
2023
t.Helper()
2124

22-
var dispatched []string
25+
const sentinel = "routedKey-sentinel"
26+
27+
arrived := make(chan string, 8)
2328

24-
eventTap.SetHandler(func(key string) { dispatched = append(dispatched, key) })
29+
eventTap.SetHandler(func(key string) { arrived <- key })
2530

2631
consumed := eventTap.handleKey(key, false)
2732

28-
return dispatched, consumed
33+
eventTap.dispatchKey(sentinel)
34+
35+
var dispatched []string
36+
37+
for {
38+
select {
39+
case delivered := <-arrived:
40+
if delivered == sentinel {
41+
return dispatched, consumed
42+
}
43+
44+
dispatched = append(dispatched, delivered)
45+
case <-time.After(time.Second):
46+
t.Fatal("the dispatcher never delivered the sentinel queued behind the key")
47+
}
48+
}
2949
}
3050

3151
func TestEventTap_HandleKey_ConsumesAnUnboundChordWithPassthroughOff(t *testing.T) {
3252
t.Parallel()
3353

34-
eventTap := NewEventTap(nil, nil)
54+
eventTap := newTestTap(t)
3555
eventTap.SetModifierPassthrough(false, nil)
3656

3757
dispatched, consumed := routedKey(t, eventTap, chordCtrlC)
@@ -48,7 +68,7 @@ func TestEventTap_HandleKey_ConsumesAnUnboundChordWithPassthroughOff(t *testing.
4868
func TestEventTap_HandleKey_PassesAnUnboundChordThrough(t *testing.T) {
4969
t.Parallel()
5070

51-
eventTap := NewEventTap(nil, nil)
71+
eventTap := newTestTap(t)
5272
eventTap.SetModifierPassthrough(true, nil)
5373

5474
fired := make(chan struct{}, 1)
@@ -97,7 +117,7 @@ func TestEventTap_HandleKey_KeepsAChordTheModeBinds(t *testing.T) {
97117
t.Run(testCase.name, func(t *testing.T) {
98118
t.Parallel()
99119

100-
eventTap := NewEventTap(nil, nil)
120+
eventTap := newTestTap(t)
101121
testCase.setup(eventTap)
102122

103123
dispatched, consumed := routedKey(t, eventTap, "ctrl+shift+c")
@@ -118,7 +138,7 @@ func TestEventTap_HandleKey_KeepsAChordTheModeBinds(t *testing.T) {
118138
func TestEventTap_HandleKey_ConsumesShiftOnlyChordsWithPassthroughOn(t *testing.T) {
119139
t.Parallel()
120140

121-
eventTap := NewEventTap(nil, nil)
141+
eventTap := newTestTap(t)
122142
eventTap.SetModifierPassthrough(true, nil)
123143

124144
for _, key := range []string{"Shift+A", "a", "Return"} {
@@ -133,7 +153,7 @@ func TestEventTap_HandleKey_ConsumesShiftOnlyChordsWithPassthroughOn(t *testing.
133153
func TestEventTap_HandleKey_HandsARegisteredHotkeyBack(t *testing.T) {
134154
t.Parallel()
135155

136-
eventTap := NewEventTap(nil, nil)
156+
eventTap := newTestTap(t)
137157
eventTap.SetHotkeys([]string{"Ctrl+G"})
138158
eventTap.SetModifierPassthrough(false, nil)
139159

0 commit comments

Comments
 (0)