@@ -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.
1420type 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.
3457func (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.
4576func (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.
83127func (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.
109165func (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.
117183func (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.
129222func (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.
150269var (
0 commit comments