Skip to content

Commit 9a835e1

Browse files
authored
fix(eventtap): stop the daemon hanging on quit during an app switch (#1514)
1 parent c34e3cd commit 9a835e1

5 files changed

Lines changed: 498 additions & 11 deletions

File tree

internal/adapter/eventtap/adapter.go

Lines changed: 129 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,30 @@ import (
1111
)
1212

1313
// Adapter implements ports.EventTapPort by wrapping the existing EventTap.
14+
//
15+
// Every method that drives the tap holds mu, and mu sits below the mode
16+
// handler's own lock: a focus change pushes the passthrough lists down that
17+
// edge (`internal/app/modes/AGENTS.md`). So nothing holding mu may wait on
18+
// anything that takes the handler's lock — which is the whole reason Destroy
19+
// tears the tap down outside it, and the reason destroyed exists.
1420
type Adapter struct {
15-
tap tap.Tap
16-
logger *zap.Logger
17-
mu sync.RWMutex
18-
enabled bool
21+
tap tap.Tap
22+
logger *zap.Logger
23+
mu sync.RWMutex
24+
// destroyed is set by Destroy before it lets go of mu. It is what keeps a
25+
// caller racing a shutdown out of a tap that is being torn down, now that
26+
// the lock no longer serializes them: every method that takes mu to drive
27+
// the tap returns early on it. Enable and Disable say so at debug as well,
28+
// because they answer their caller with a nil error and would otherwise
29+
// report a success that did not happen; the setters answer nothing, so
30+
// there is nothing to correct.
31+
destroyed bool
32+
// teardownDone is closed once the tap teardown has returned. It is what a
33+
// second Destroy waits on, so the method keeps its postcondition — the tap
34+
// is down when it returns — for a caller that raced the first one, which
35+
// the lock used to give for free.
36+
teardownDone chan struct{}
37+
enabled bool
1938
}
2039

2140
// NewAdapter creates a new event tap adapter.
@@ -31,21 +50,39 @@ func NewAdapter(tap tap.Tap, logger *zap.Logger) *Adapter {
3150
}
3251

3352
// Enable enables the event tap.
53+
//
54+
// After Destroy it is a no-op rather than an error: a mode exiting into a
55+
// teardown is a race the shutdown already won, not a failure the user needs
56+
// told about, and its callers log an error for anything that comes back.
3457
func (a *Adapter) Enable(_ context.Context) error {
3558
a.mu.Lock()
3659
defer a.mu.Unlock()
3760

61+
if a.destroyed {
62+
a.logger.Debug("Enable ignored: the event tap has been destroyed")
63+
64+
return nil
65+
}
66+
3867
a.tap.Enable()
3968
a.enabled = true
4069

4170
return nil
4271
}
4372

4473
// Disable disables the event tap.
74+
//
75+
// After Destroy it is a no-op, for the reason Enable gives.
4576
func (a *Adapter) Disable(_ context.Context) error {
4677
a.mu.Lock()
4778
defer a.mu.Unlock()
4879

80+
if a.destroyed {
81+
a.logger.Debug("Disable ignored: the event tap has been destroyed")
82+
83+
return nil
84+
}
85+
4986
a.tap.Disable()
5087
a.enabled = false
5188

@@ -71,6 +108,10 @@ func (a *Adapter) SetHotkeys(hotkeys []string) {
71108
a.mu.Lock()
72109
defer a.mu.Unlock()
73110

111+
if a.destroyed {
112+
return
113+
}
114+
74115
if len(hotkeys) == 0 {
75116
a.logger.Debug("SetHotkeys called with empty slice — no hotkeys will be monitored")
76117
}
@@ -80,10 +121,17 @@ func (a *Adapter) SetHotkeys(hotkeys []string) {
80121

81122
// SetModifierPassthrough configures whether unbound modifier shortcuts should
82123
// pass through to macOS and which shortcuts remain blacklisted.
124+
//
125+
// This is the push a focus change makes, and the one that races a shutdown:
126+
// see the Destroy comment.
83127
func (a *Adapter) SetModifierPassthrough(enabled bool, blacklist []string) {
84128
a.mu.Lock()
85129
defer a.mu.Unlock()
86130

131+
if a.destroyed {
132+
return
133+
}
134+
87135
a.tap.SetModifierPassthrough(enabled, blacklist)
88136
}
89137

@@ -93,27 +141,45 @@ func (a *Adapter) SetInterceptedModifierKeys(keys []string) {
93141
a.mu.Lock()
94142
defer a.mu.Unlock()
95143

144+
if a.destroyed {
145+
return
146+
}
147+
96148
a.tap.SetInterceptedModifierKeys(keys)
97149
}
98150

99151
// SetPassthroughCallback registers a function to call when a modifier shortcut
100152
// passes through to macOS.
101-
func (a *Adapter) SetPassthroughCallback(cb func()) {
153+
func (a *Adapter) SetPassthroughCallback(callback func()) {
102154
a.mu.Lock()
103155
defer a.mu.Unlock()
104156

105-
a.tap.SetPassthroughCallback(cb)
157+
if a.destroyed {
158+
return
159+
}
160+
161+
a.tap.SetPassthroughCallback(callback)
106162
}
107163

108164
// SetStickyModifierToggle enables or disables sticky modifier toggle detection.
109165
func (a *Adapter) SetStickyModifierToggle(enabled bool) {
110166
a.mu.Lock()
111167
defer a.mu.Unlock()
112168

169+
if a.destroyed {
170+
return
171+
}
172+
113173
a.tap.SetStickyModifierToggle(enabled)
114174
}
115175

116176
// SetKeyboardLayout configures the reference keyboard layout used by key translation.
177+
//
178+
// Like PostModifierEvent it takes no lock and carries no destroyed guard,
179+
// because no backend routes it through the tap handle: macOS resolves a
180+
// process-wide input source, and Linux and Windows answer true without
181+
// touching anything. So it is neither a caller of the tap being torn down nor
182+
// something a shutdown has to keep out.
117183
func (a *Adapter) SetKeyboardLayout(layoutID string) bool {
118184
return a.tap.SetKeyboardLayout(layoutID)
119185
}
@@ -125,13 +191,45 @@ func (a *Adapter) PostModifierEvent(modifier string, isDown bool) {
125191
a.tap.PostModifierEvent(modifier, isDown)
126192
}
127193

128-
// Destroy cleans up the event tap resources.
194+
// Destroy cleans up the event tap resources. It is safe to call twice, and
195+
// safe from the startup unwind as well as the ordinary shutdown.
196+
//
197+
// The tap teardown runs **outside** mu, and that is the point of the method's
198+
// shape. The macOS and Linux taps spend it waiting for the key dispatcher to
199+
// drain — stopDispatcher there, dispatchWg.Wait() here — and the dispatcher
200+
// they wait for delivers keys into modes.Handler.HandleKeyPress, which takes
201+
// the handler's lock and pushes the passthrough lists straight back out
202+
// through SetModifierPassthrough, which takes mu. Holding mu across the wait
203+
// inverts the documented handler → adapter order, so a shutdown racing a focus
204+
// 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.
208+
//
209+
// What mu still covers is the state: the adapter marks itself destroyed and
210+
// disabled under it, in one hold, before letting go — so a caller racing the
211+
// teardown finds an adapter that has already stopped answering for the tap
212+
// instead of one whose tap is being freed underneath it. A second caller waits
213+
// on the first one's teardown rather than returning early, because the method
214+
// promises a tap that is down when it returns and the app closes the rest of
215+
// its infrastructure on the strength of that.
216+
//
217+
// Nothing here unlocks mid-method: the state change is claimTeardown's whole
218+
// body, released by its own defer, and this method runs what it was handed
219+
// after that returns. That is the "settle it under the lock, act after the
220+
// release" idiom the handler's guide states, and the reason it is two methods
221+
// rather than one with an explicit Unlock in the middle.
129222
func (a *Adapter) Destroy() {
130-
a.mu.Lock()
131-
defer a.mu.Unlock()
223+
teardownDone, ours := a.claimTeardown()
224+
if !ours {
225+
<-teardownDone
226+
227+
return
228+
}
229+
230+
defer close(teardownDone)
132231

133232
a.tap.Destroy()
134-
a.enabled = false
135233
}
136234

137235
// AllowsOverlayKeyboardPassthrough reports whether an indicator overlay can
@@ -145,6 +243,27 @@ func (a *Adapter) AllowsOverlayKeyboardPassthrough() bool {
145243
return overlayKeyboardPassthroughAllowed()
146244
}
147245

246+
// claimTeardown settles who is tearing the tap down and marks the adapter
247+
// destroyed and disabled in the same hold.
248+
//
249+
// It answers the channel that closes when the teardown is finished, and
250+
// whether this caller is the one that has to run it: false means someone else
251+
// already claimed it and the channel is theirs to wait on.
252+
func (a *Adapter) claimTeardown() (chan struct{}, bool) {
253+
a.mu.Lock()
254+
defer a.mu.Unlock()
255+
256+
if a.destroyed {
257+
return a.teardownDone, false
258+
}
259+
260+
a.destroyed = true
261+
a.enabled = false
262+
a.teardownDone = make(chan struct{})
263+
264+
return a.teardownDone, true
265+
}
266+
148267
// Ensure Adapter implements ports.EventTapPort and the optional overlay
149268
// passthrough extension.
150269
var (

0 commit comments

Comments
 (0)