Skip to content

Commit 9733a3e

Browse files
authored
fix(windows): keep the daemon alive across thousands of mode activations (#1526)
1 parent c20a10b commit 9733a3e

3 files changed

Lines changed: 348 additions & 64 deletions

File tree

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
//go:build integration && windows
2+
3+
package windows
4+
5+
import (
6+
"syscall"
7+
"testing"
8+
)
9+
10+
// Real Win32 tests for the process-wide callback registrations.
11+
//
12+
// Go's runtime keys registered callbacks on the function value, never frees a
13+
// slot, and has a fixed cb_max = 2000 of them (runtime/zcallback_windows.go).
14+
// A path that registers one per call therefore ends the process on "too many
15+
// callback functions" — a runtime throw, unrecoverable — after enough mode
16+
// activations. These tests count the slots a workload consumes and require the
17+
// answer to be zero.
18+
//
19+
// They are in-package because the enumeration path they drive is unexported,
20+
// and integration-tagged because they call the live Win32 APIs.
21+
22+
// callbackWorkloadRuns is how many times a workload is repeated inside a
23+
// measurement. The count only has to be large enough that "one per run" is
24+
// unmistakable next to "one for the process"; the measurement is exact, so
25+
// there is no need to approach the table's size and no reason to install two
26+
// thousand real keyboard hooks to prove it.
27+
const callbackWorkloadRuns = 20
28+
29+
// newCallbackProbe registers one throwaway callback and returns its address.
30+
//
31+
// The closure captures tag so that each probe is a distinct function value:
32+
// the runtime returns the existing slot for a function value it has already
33+
// seen, and two identical non-capturing literals may share one static value.
34+
func newCallbackProbe(tag int) uintptr {
35+
return syscall.NewCallback(func() uintptr {
36+
return uintptr(tag)
37+
})
38+
}
39+
40+
// countCallbackRegistrations reports how many callback slots work consumed.
41+
//
42+
// It reads the one property the runtime exposes without an API: a registered
43+
// callback's address is its slot index into a single contiguous table, so
44+
// consecutive registrations sit a fixed stride apart. Two probes before the
45+
// workload measure that stride, a third after it measures the gap, and the
46+
// slots work took are the ones in between.
47+
//
48+
// A runtime that lays callbacks out some other way is skipped rather than
49+
// guessed at — a check that cannot measure must say so instead of passing.
50+
func countCallbackRegistrations(t *testing.T, work func()) int {
51+
t.Helper()
52+
53+
first := newCallbackProbe(1)
54+
second := newCallbackProbe(2)
55+
56+
if second <= first {
57+
t.Skipf(
58+
"skipping: callback addresses are not ascending (%#x then %#x), so this "+
59+
"runtime's callback slots cannot be counted this way",
60+
first, second,
61+
)
62+
}
63+
64+
stride := second - first
65+
66+
work()
67+
68+
third := newCallbackProbe(3)
69+
70+
if third <= second {
71+
t.Fatalf(
72+
"callback addresses stopped ascending (%#x then %#x); the slot count "+
73+
"below would be meaningless",
74+
second, third,
75+
)
76+
}
77+
78+
gap := third - second
79+
if gap%stride != 0 {
80+
t.Fatalf(
81+
"callback addresses are %d apart after a stride of %d; the table is not "+
82+
"the uniform layout this count assumes",
83+
gap, stride,
84+
)
85+
}
86+
87+
return int(gap/stride) - 1
88+
}
89+
90+
// TestEnumerateMonitors_RegistersOneCallbackForTheProcess holds the monitor
91+
// enumeration path to a single callback registration.
92+
//
93+
// enumerateMonitors is on the mode-activation path — activeScreenBounds,
94+
// screenBoundsByName, screenNames and NewOverlayWindow all reach it — so a
95+
// registration per pass was the shortest countdown of the two.
96+
//
97+
// The first pass outside the measurement is what registers that one callback.
98+
// The enumeration results are deliberately dropped: a headless or session-0
99+
// runner legitimately reports no monitors, and what is being counted is the
100+
// same either way.
101+
func TestEnumerateMonitors_RegistersOneCallbackForTheProcess(t *testing.T) {
102+
_, _ = enumerateMonitors()
103+
104+
registered := countCallbackRegistrations(t, func() {
105+
for range callbackWorkloadRuns {
106+
_, _ = enumerateMonitors()
107+
}
108+
})
109+
110+
if registered != 0 {
111+
t.Fatalf(
112+
"%d enumeration passes registered %d callbacks; each one is a slot the "+
113+
"process never gets back",
114+
callbackWorkloadRuns, registered,
115+
)
116+
}
117+
}
118+
119+
// TestEnumerateMonitors_DoesNotCarryStateBetweenPasses pins the cost of the
120+
// single callback: what it appends to is a package variable rather than a
121+
// per-call capture, so a pass that failed to install or clear it would hand the
122+
// caller the previous pass's monitors as well as its own.
123+
//
124+
// Two passes over an unchanged display therefore have to report the same count.
125+
func TestEnumerateMonitors_DoesNotCarryStateBetweenPasses(t *testing.T) {
126+
first, err := enumerateMonitors()
127+
if err != nil {
128+
t.Skipf("skipping: no monitors to enumerate (%v)", err)
129+
}
130+
131+
second, err := enumerateMonitors()
132+
if err != nil {
133+
t.Fatalf("second pass after a first that found %d monitors: %v", len(first), err)
134+
}
135+
136+
if len(second) != len(first) {
137+
t.Fatalf(
138+
"consecutive passes reported %d then %d monitors; the shared collector is "+
139+
"carrying state across passes",
140+
len(first), len(second),
141+
)
142+
}
143+
}
144+
145+
// TestStartKeyboardHook_RegistersOneCallbackForTheProcess holds the keyboard
146+
// hook to a single callback registration across install cycles.
147+
//
148+
// EventTap.Enable calls StartKeyboardHook on every enable cycle, so this is the
149+
// same countdown on the input path. Installing real hooks is what makes this
150+
// test worth having: it counts what the code path actually registers rather
151+
// than what the accessor returns.
152+
//
153+
// The hook's key callback returns false throughout, so every key seen while the
154+
// test runs is passed straight on to the rest of the system.
155+
func TestStartKeyboardHook_RegistersOneCallbackForTheProcess(t *testing.T) {
156+
passThrough := func(string, bool) bool { return false }
157+
158+
warmUp, err := StartKeyboardHook(passThrough)
159+
if err != nil {
160+
t.Skipf("skipping: cannot install a keyboard hook here (%v)", err)
161+
}
162+
163+
warmUp.Stop()
164+
165+
var installErr error
166+
167+
registered := countCallbackRegistrations(t, func() {
168+
for range callbackWorkloadRuns {
169+
hook, err := StartKeyboardHook(passThrough)
170+
if err != nil {
171+
installErr = err
172+
173+
return
174+
}
175+
176+
hook.Stop()
177+
}
178+
})
179+
180+
if installErr != nil {
181+
t.Fatalf("installing a keyboard hook after a successful first install: %v", installErr)
182+
}
183+
184+
if registered != 0 {
185+
t.Fatalf(
186+
"%d install cycles registered %d callbacks; each one is a slot the process "+
187+
"never gets back",
188+
callbackWorkloadRuns, registered,
189+
)
190+
}
191+
}

internal/adapter/platform/windows/keyboard_hook.go

Lines changed: 62 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package windows
55
import (
66
"errors"
77
"sync"
8+
"sync/atomic"
89
"syscall"
910
"time"
1011
"unsafe"
@@ -72,7 +73,24 @@ var (
7273
procPostThreadMessageW = user32.NewProc("PostThreadMessageW")
7374
procGetCurrentThreadID = kernel32.NewProc("GetCurrentThreadId")
7475

75-
activeKeyboardHook *KeyboardHook
76+
// activeKeyboardHook is the hook keyboardHookProc dispatches to. It is
77+
// atomic because the two sides never share a lock: run stores it under h.mu
78+
// on the hook goroutine, and the hook procedure loads it on whichever
79+
// thread Windows delivers the key event on.
80+
activeKeyboardHook atomic.Pointer[KeyboardHook]
81+
82+
// keyboardHookProcPtr is the WH_KEYBOARD_LL procedure pointer
83+
// SetWindowsHookExW is handed, allocated on first use and never again —
84+
// for the reason monitorEnumProcPtr in win32.go carries in full: a callback
85+
// slot is never freed and the process gets a fixed 2000 of them.
86+
//
87+
// StartKeyboardHook runs on every EventTap.Enable cycle, so a callback
88+
// allocated there was spent on every mode activation. One procedure serves
89+
// every hook because it carries no per-hook state: it reads
90+
// activeKeyboardHook, which is whichever hook is currently installed.
91+
keyboardHookProcPtr = sync.OnceValue(func() uintptr {
92+
return syscall.NewCallback(keyboardHookProc)
93+
})
7694
)
7795

7896
var (
@@ -169,55 +187,58 @@ func (h *KeyboardHook) Stop() {
169187
}
170188
}
171189

172-
func (h *KeyboardHook) run() {
173-
defer close(h.doneCh)
190+
// keyboardHookProc is the WH_KEYBOARD_LL procedure Windows calls for every key
191+
// event, for whichever hook is currently installed.
192+
//
193+
// lParam is typed unsafe.Pointer (not uintptr) so the KBDLLHOOKSTRUCT
194+
// dereference is a Pointer->*T conversion, which keeps go vet's unsafeptr
195+
// check happy. syscall.NewCallback accepts pointer-kind parameters.
196+
func keyboardHookProc(code int, wParam uintptr, lParam unsafe.Pointer) uintptr {
197+
if code < 0 {
198+
ret, _, _ := procCallNextHookEx.Call(0, uintptr(code), wParam, uintptr(lParam))
174199

175-
// lParam is typed unsafe.Pointer (not uintptr) so the KBDLLHOOKSTRUCT
176-
// dereference is a Pointer->*T conversion, which keeps go vet's unsafeptr
177-
// check happy. syscall.NewCallback accepts pointer-kind parameters.
178-
hookProc := syscall.NewCallback(func(code int, wParam uintptr, lParam unsafe.Pointer) uintptr {
179-
if code < 0 {
180-
ret, _, _ := procCallNextHookEx.Call(0, uintptr(code), wParam, uintptr(lParam))
200+
return ret
201+
}
181202

182-
return ret
183-
}
203+
current := activeKeyboardHook.Load()
204+
if current == nil || current.callback == nil {
205+
ret, _, _ := procCallNextHookEx.Call(0, uintptr(code), wParam, uintptr(lParam))
184206

185-
current := activeKeyboardHook
186-
if current == nil || current.callback == nil {
187-
ret, _, _ := procCallNextHookEx.Call(0, uintptr(code), wParam, uintptr(lParam))
207+
return ret
208+
}
188209

189-
return ret
190-
}
210+
kbd := (*kbdLLHookStruct)(lParam)
191211

192-
kbd := (*kbdLLHookStruct)(lParam)
212+
// Keys this process injected come back through this hook. Handing one
213+
// to the callback would re-enter the mode handler from the hook
214+
// thread, and the down/up pair a modified scroll holds reads as the
215+
// user tapping that modifier — latching a sticky modifier nobody
216+
// pressed. Only Neru's own injection is skipped (neruInjectedTag);
217+
// another tool's synthetic input is still seen.
218+
if kbd.dwExtraInfo == neruInjectedTag {
219+
ret, _, _ := procCallNextHookEx.Call(0, uintptr(code), wParam, uintptr(lParam))
193220

194-
// Keys this process injected come back through this hook. Handing one
195-
// to the callback would re-enter the mode handler from the hook
196-
// thread, and the down/up pair a modified scroll holds reads as the
197-
// user tapping that modifier — latching a sticky modifier nobody
198-
// pressed. Only Neru's own injection is skipped (neruInjectedTag);
199-
// another tool's synthetic input is still seen.
200-
if kbd.dwExtraInfo == neruInjectedTag {
201-
ret, _, _ := procCallNextHookEx.Call(0, uintptr(code), wParam, uintptr(lParam))
221+
return ret
222+
}
202223

203-
return ret
204-
}
224+
isUp := wParam == wmKeyUp || wParam == wmSysKeyUp || kbd.flags&llkhfUp != 0
205225

206-
isUp := wParam == wmKeyUp || wParam == wmSysKeyUp || kbd.flags&llkhfUp != 0
226+
key := hookKeyName(kbd.vkCode, isUp)
227+
if key != "" && current.callback(key, isUp) {
228+
return 1
229+
}
207230

208-
key := hookKeyName(kbd.vkCode, isUp)
209-
if key != "" && current.callback(key, isUp) {
210-
return 1
211-
}
231+
ret, _, _ := procCallNextHookEx.Call(0, uintptr(code), wParam, uintptr(lParam))
212232

213-
ret, _, _ := procCallNextHookEx.Call(0, uintptr(code), wParam, uintptr(lParam))
233+
return ret
234+
}
214235

215-
return ret
216-
})
236+
func (h *KeyboardHook) run() {
237+
defer close(h.doneCh)
217238

218239
handle, _, _ := procSetWindowsHookExW.Call(
219240
whKeyboardLL,
220-
hookProc,
241+
keyboardHookProcPtr(),
221242
moduleHandle(),
222243
0,
223244
)
@@ -233,7 +254,7 @@ func (h *KeyboardHook) run() {
233254
h.hook = handle
234255
threadID, _, _ := procGetCurrentThreadID.Call()
235256
h.threadID = uint32(threadID)
236-
activeKeyboardHook = h
257+
activeKeyboardHook.Store(h)
237258
h.mu.Unlock()
238259

239260
// Signal successful install so StartKeyboardHook can return the hook.
@@ -246,9 +267,10 @@ func (h *KeyboardHook) run() {
246267
h.hook = 0
247268
}
248269

249-
if activeKeyboardHook == h {
250-
activeKeyboardHook = nil
251-
}
270+
// Only this hook's own registration is cleared: a later hook may
271+
// already have installed itself, and clearing unconditionally would
272+
// leave that one receiving key events with nowhere to deliver them.
273+
activeKeyboardHook.CompareAndSwap(h, nil)
252274
h.mu.Unlock()
253275
}()
254276

0 commit comments

Comments
 (0)