on main help sign in
perf(permission): release requestMu before the human wait

Set up the permission request under requestMu, then release it before blocking on the user's response. Holding it across the wait serialized every prompt process-wide, so parallel sub-agents queued behind one another. Per-request resolution is already scoped by the pendingRequests map, so the wait needs no global lock. Also guard event.baseProps with a mutex: the Set* mutators reassign it during startup while send() reads it concurrently (data race). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

commitd88bfb9a62a014e9a93e51818780bfaacf96a9cc authoriko <mail@iko.soy> dateThu 2 Jul 2026, 03:17 (3 months ago) parentb10b0e1
2 files changed+85−59
internal/event/event.go+15−2 internal/permission/permission.go+70−57
internal/event/event.go+15−2
@@ -7,6 +7,7 @@ import (
77 "path/filepath"
88 "reflect"
99 "runtime"
10+ "sync"
1011 "time"
1112
1213 "github.com/charmbracelet/crush/internal/version"
@@ -25,7 +26,10 @@ const (
2526var (
2627 client posthog.Client
2728
28− baseProps = posthog.NewProperties().
29+ // basePropsMu guards baseProps: the Set* mutators reassign it during
30+ // startup while send() reads it concurrently from event goroutines.
31+ basePropsMu sync.RWMutex
32+ baseProps = posthog.NewProperties().
2933 Set("GOOS", runtime.GOOS).
3034 Set("GOARCH", runtime.GOARCH).
3135 Set("TERM", os.Getenv("TERM")).
@@ -36,14 +40,20 @@ var (
3640)
3741
3842func SetNonInteractive(nonInteractive bool) {
43+ basePropsMu.Lock()
44+ defer basePropsMu.Unlock()
3945 baseProps = baseProps.Set(nonInteractiveAttrName, nonInteractive)
4046}
4147
4248func SetContinueBySessionID(continueBySessionID bool) {
49+ basePropsMu.Lock()
50+ defer basePropsMu.Unlock()
4351 baseProps = baseProps.Set(continueSessionByIDAttrName, continueBySessionID)
4452}
4553
4654func SetContinueLastSession(continueLastSession bool) {
55+ basePropsMu.Lock()
56+ defer basePropsMu.Unlock()
4757 baseProps = baseProps.Set(continueLastSessionAttrName, continueLastSession)
4858}
4959
@@ -81,10 +91,13 @@ func send(event string, props ...any) {
8191 if client == nil {
8292 return
8393 }
94+ basePropsMu.RLock()
95+ properties := pairsToProps(props...).Merge(baseProps)
96+ basePropsMu.RUnlock()
8497 err := client.Enqueue(posthog.Capture{
8598 DistinctId: distinctId,
8699 Event: event,
87− Properties: pairsToProps(props...).Merge(baseProps),
100+ Properties: properties,
88101 })
89102 if err != nil {
90103 slog.Error("Failed to enqueue PostHog event", "event", event, "props", props, "error", err)
internal/permission/permission.go+70−57
@@ -201,73 +201,86 @@ func (s *permissionService) Request(ctx context.Context, opts CreatePermissionRe
201201 return true, nil
202202 }
203203
204− s.requestMu.Lock()
205− defer s.requestMu.Unlock()
206−
207− // tell the UI that a permission was requested
208− s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
209− ToolCallID: opts.ToolCallID,
210− })
211−
212− s.autoApproveSessionsMu.RLock()
213− autoApprove := s.autoApproveSessions[opts.SessionID]
214− s.autoApproveSessionsMu.RUnlock()
215−
216− if autoApprove {
204+ // Set up the request under requestMu, then release it *before*
205+ // blocking on the human response. Holding requestMu across the wait
206+ // would serialize every permission prompt process-wide, so parallel
207+ // sub-agents each needing approval would queue behind one another.
208+ // Per-request resolution is scoped by the pendingRequests map (keyed
209+ // by request ID), so the wait needs no global lock.
210+ respCh, requestID, resolved, granted := func() (chan bool, string, bool, bool) {
211+ s.requestMu.Lock()
212+ defer s.requestMu.Unlock()
213+
214+ // tell the UI that a permission was requested
217215 s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
218216 ToolCallID: opts.ToolCallID,
219− Granted: true,
220217 })
221− return true, nil
222− }
223218
224− fileInfo, err := os.Stat(opts.Path)
225− dir := opts.Path
226− if err == nil {
227− if fileInfo.IsDir() {
228− dir = opts.Path
229− } else {
230− dir = filepath.Dir(opts.Path)
219+ s.autoApproveSessionsMu.RLock()
220+ autoApprove := s.autoApproveSessions[opts.SessionID]
221+ s.autoApproveSessionsMu.RUnlock()
222+
223+ if autoApprove {
224+ s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
225+ ToolCallID: opts.ToolCallID,
226+ Granted: true,
227+ })
228+ return nil, "", true, true
231229 }
232− }
233230
234− if dir == "." {
235− dir = s.workingDir
236− }
237− permission := PermissionRequest{
238− ID: uuid.New().String(),
239− Path: dir,
240− SessionID: opts.SessionID,
241− ToolCallID: opts.ToolCallID,
242− ToolName: opts.ToolName,
243− Description: opts.Description,
244− Action: opts.Action,
245− Params: opts.Params,
246− }
231+ fileInfo, err := os.Stat(opts.Path)
232+ dir := opts.Path
233+ if err == nil {
234+ if fileInfo.IsDir() {
235+ dir = opts.Path
236+ } else {
237+ dir = filepath.Dir(opts.Path)
238+ }
239+ }
247240
248− if _, ok := s.sessionPermissions.Get(PermissionKey{
249− SessionID: permission.SessionID,
250− ToolName: permission.ToolName,
251− Action: permission.Action,
252− Path: permission.Path,
253− }); ok {
254− s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
255− ToolCallID: opts.ToolCallID,
256− Granted: true,
257− })
258− return true, nil
259− }
241+ if dir == "." {
242+ dir = s.workingDir
243+ }
244+ permission := PermissionRequest{
245+ ID: uuid.New().String(),
246+ Path: dir,
247+ SessionID: opts.SessionID,
248+ ToolCallID: opts.ToolCallID,
249+ ToolName: opts.ToolName,
250+ Description: opts.Description,
251+ Action: opts.Action,
252+ Params: opts.Params,
253+ }
260254
261− s.activeRequestMu.Lock()
262− s.activeRequest = &permission
263− s.activeRequestMu.Unlock()
255+ if _, ok := s.sessionPermissions.Get(PermissionKey{
256+ SessionID: permission.SessionID,
257+ ToolName: permission.ToolName,
258+ Action: permission.Action,
259+ Path: permission.Path,
260+ }); ok {
261+ s.notificationBroker.Publish(pubsub.CreatedEvent, PermissionNotification{
262+ ToolCallID: opts.ToolCallID,
263+ Granted: true,
264+ })
265+ return nil, "", true, true
266+ }
264267
265− respCh := make(chan bool, 1)
266− s.pendingRequests.Set(permission.ID, respCh)
267− defer s.pendingRequests.Del(permission.ID)
268+ s.activeRequestMu.Lock()
269+ s.activeRequest = &permission
270+ s.activeRequestMu.Unlock()
268271
269− // Publish the request
270− s.Publish(pubsub.CreatedEvent, permission)
272+ respCh := make(chan bool, 1)
273+ s.pendingRequests.Set(permission.ID, respCh)
274+
275+ // Publish the request
276+ s.Publish(pubsub.CreatedEvent, permission)
277+ return respCh, permission.ID, false, false
278+ }()
279+
280+ if resolved {
281+ return granted, nil
282+ }
283+ defer s.pendingRequests.Del(requestID)
271284
272285 select {
273286 case <-ctx.Done():