Skip to content

Refactor pkg mutex sites to use deferred unlocks consistently#33038

Merged
pelikhan merged 7 commits into
mainfrom
copilot/refactor-defer-mu-unlock
May 18, 2026
Merged

Refactor pkg mutex sites to use deferred unlocks consistently#33038
pelikhan merged 7 commits into
mainfrom
copilot/refactor-defer-mu-unlock

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented May 18, 2026

pkg/ mostly follows the defer mu.Unlock() / defer mu.RUnlock() pattern, but a small set of cache and logger paths still used manual unlocks. Those sites were correct today, but inconsistent lock scopes made future early-return refactors easier to get wrong.

  • Cache getters now use deferred unlocks consistently

    • Updated pkg/cli/repo.go:GetCurrentRepoSlug and pkg/workflow/repository_features_validation.go:getCurrentRepository to defer the mutex unlock immediately after locking.
    • This makes the cached read/write path match the dominant pattern already used elsewhere in pkg/.
  • Logger timestamp updates follow the same lock pattern

    • Updated pkg/logger/logger.go in (*Logger).Printf and (*Logger).Print to defer unlocks around the lastLog timestamp update.
    • The formatting and write path remains outside the critical section.
  • MCP server cache lock scopes were normalized

    • Updated pkg/cli/mcp_server_cache.go so GetPermission, SetPermission, and SetRepo use deferred unlocks like GetRepo already did.
    • GetPermission was split into small lock-scoped helpers so the read-lock and eviction write-lock each have a single clear deferred unlock site.

Example of the pattern applied in the touched sites:

func GetCurrentRepoSlug() (string, error) {
	currentRepoSlugCache.mu.Lock()
	defer currentRepoSlugCache.mu.Unlock()

	if !currentRepoSlugCache.done {
		currentRepoSlugCache.result, currentRepoSlugCache.err = getCurrentRepoSlugUncached()
		currentRepoSlugCache.done = true
	}

	return currentRepoSlugCache.result, currentRepoSlugCache.err
}

Copilot AI and others added 2 commits May 18, 2026 13:31
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Copilot AI changed the title [WIP] Refactor to apply defer mu.Unlock consistently across mutex sites Refactor pkg mutex sites to use deferred unlocks consistently May 18, 2026
Copilot AI requested a review from pelikhan May 18, 2026 13:37
@github-actions github-actions Bot mentioned this pull request May 18, 2026
@pelikhan pelikhan marked this pull request as ready for review May 18, 2026 13:48
Copilot AI review requested due to automatic review settings May 18, 2026 13:48
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Refactors mutex unlock patterns in cache and logger code to use deferred unlocks more consistently.

Changes:

  • Updated repository cache getters to defer mutex unlocks.
  • Updated logger timestamp locking in Print/Printf.
  • Split MCP permission cache access into small lock-scoped helpers and deferred unlocks in setters.
Show a summary per file
File Description
pkg/workflow/repository_features_validation.go Defers unlock in current repository cache getter.
pkg/logger/logger.go Defers logger mutex unlocks around timestamp updates.
pkg/cli/repo.go Defers unlock in current repo slug cache getter.
pkg/cli/mcp_server_cache.go Normalizes MCP cache locking helpers and setter unlocks.

Copilot's findings

Tip

Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comments suppressed due to low confidence (1)

pkg/logger/logger.go:133

  • This deferred unlock has the same issue as Printf: the logger mutex remains held while fmt.Sprint formats arbitrary values and while stderr is written. That can deadlock if an argument's String method logs through this logger, and it serializes potentially blocking I/O while also skewing lastLog timing for concurrent callers. Limit the locked section to the lastLog update.
	defer l.mu.Unlock()
  • Files reviewed: 4/4 changed files
  • Comments generated: 4

Comment thread pkg/logger/logger.go Outdated
return
}
l.mu.Lock()
defer l.mu.Unlock()
Comment thread pkg/cli/mcp_server_cache.go Outdated
c.mu.Lock()
delete(c.permissions, cacheKey)
c.mu.Unlock()
c.deletePermissionEntry(cacheKey)
Comment thread pkg/cli/repo.go Outdated
Comment on lines 95 to 101
defer currentRepoSlugCache.mu.Unlock()
if !currentRepoSlugCache.done {
currentRepoSlugCache.result, currentRepoSlugCache.err = getCurrentRepoSlugUncached()
currentRepoSlugCache.done = true
}
result := currentRepoSlugCache.result
err := currentRepoSlugCache.err
Comment on lines 160 to 167
currentRepositoryCache.mu.Lock()
defer currentRepositoryCache.mu.Unlock()
if !currentRepositoryCache.done {
currentRepositoryCache.result, currentRepositoryCache.err = getCurrentRepositoryUncached()
currentRepositoryCache.done = true
}
result := currentRepositoryCache.result
err := currentRepositoryCache.err
Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skills-Based Review 🧠

Applied /zoom-out and /improve-codebase-architecture — this is a consistency refactor, so the key questions are whether every site is semantically equivalent after the change and whether the new helpers earn their complexity.

Key Themes

  1. Lock-scope widening in logger.go (blocking): The defer in Printf and Print enlarges the critical section to include fmt.Sprintf and lipgloss.Fprintf. The original code deliberately released the lock before formatting and I/O. This is a silent behaviour change — all concurrent log calls on the same logger are now serialised through the write. Needs either a fix to restore the original scope or a comment explaining the widened scope is intentional.

  2. Redundant local variable copies (minor): result and err temporaries in repo.go and repository_features_validation.go were there to safely capture values before the early manual unlock. With defer holding the lock through the full function, those copies add noise without value and should be removed.

  3. TOCTOU pattern made less visible (worth noting): getPermissionEntry + deletePermissionEntry splits a two-phase read-then-delete across two separate lock acquisitions. The race was pre-existing, but splitting into helpers hides the relationship between the two lock sites; inline code at least kept them adjacent. A comment documenting the accepted race would help future readers.

Positive Highlights

  • SetPermission, SetRepo, and the cache getter patterns are straightforwardly improved — defer is the right tool there
  • ✅ The PR description is accurate and includes a good before/after example
  • ✅ Scope is narrow and well-targeted

Verdict

Requesting changes on the logger.go lock-scope issue, which silently changes concurrent behaviour. The other two findings are minor and can be addressed in follow-up if preferred.

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · ● 5.6M

Comment thread pkg/logger/logger.go Outdated
return
}
l.mu.Lock()
defer l.mu.Unlock()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/zoom-out] The defer widens the critical section beyond what the original code intended. Before this change, the mutex was released before fmt.Sprintf and lipgloss.Fprintf — formatting and I/O were intentionally outside the lock. Now they run under the mutex, serialising all concurrent log calls on the same logger for the duration of the write.

This is a silent behaviour change. If holding the lock through I/O is intentional (to avoid interleaved output lines), that should be documented with a comment. If the original narrow scope was intentional, consider:

func (l *Logger) Printf(format string, args ...any) {
	if !l.enabled {
		return
	}
	now := time.Now()
	var diff time.Duration
	l.mu.Lock()
	diff = now.Sub(l.lastLog)
	l.lastLog = now
	l.mu.Unlock()

	message := fmt.Sprintf(format, args...)
	lipgloss.Fprintf(os.Stderr, "%s %s +%s\n", l.label, message, timeutil.FormatDuration(diff))
}

This keeps the original minimal-lock behaviour without reverting to manual Unlock() calls.

Comment thread pkg/cli/repo.go Outdated
currentRepoSlugCache.result, currentRepoSlugCache.err = getCurrentRepoSlugUncached()
currentRepoSlugCache.done = true
}
result := currentRepoSlugCache.result
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/improve-codebase-architecture] The local copies result and err on lines 100–101 are now redundant. With the original manual unlock, these copies were needed to capture values before releasing the lock before further processing. With defer, the lock is held throughout the function, so the struct fields can be returned directly:

func GetCurrentRepoSlug() (string, error) {
	currentRepoSlugCache.mu.Lock()
	defer currentRepoSlugCache.mu.Unlock()
	if !currentRepoSlugCache.done {
		currentRepoSlugCache.result, currentRepoSlugCache.err = getCurrentRepoSlugUncached()
		currentRepoSlugCache.done = true
	}
	if currentRepoSlugCache.err != nil {
		return "", currentRepoSlugCache.err
	}
	repoLog.Printf("Using cached repository slug: %s", currentRepoSlugCache.result)
	return currentRepoSlugCache.result, nil
}

Same applies to the parallel pattern in pkg/workflow/repository_features_validation.go.

delete(c.permissions, cacheKey)
}

// GetPermission returns the cached permission for the given actor and repo, or ("", false) on cache miss.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/improve-codebase-architecture] The getPermissionEntry / deletePermissionEntry helpers introduce a two-lock TOCTOU pattern: read under RLock, check expiry outside any lock, then conditionally delete under a new Lock. Between the two lock acquisitions, another goroutine could call SetPermission, inserting a fresh valid entry — which deletePermissionEntry would then remove.

This race existed before the refactor, but the helper split makes the two-phase pattern less visible. The original inline code at least co-located the two lock sites. Consider documenting the accepted race with a comment, or using sync.Map / a single write-lock for GetPermission to close it definitively.

@pelikhan
Copy link
Copy Markdown
Collaborator

@copilot review all comments and reviews

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Copy link
Copy Markdown
Contributor Author

Copilot AI commented May 18, 2026

@copilot review all comments and reviews

Addressed the actionable review comments in d7147b7: logger lock scope is reduced again, the repository cache getters now use scoped deferred unlocks so logging happens after unlock, and MCP permission eviction only removes the same expired entry with regression coverage added.

@pelikhan
Copy link
Copy Markdown
Collaborator

@copilot merge main and recompile

…r-mu-unlock

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Copy link
Copy Markdown
Contributor Author

Copilot AI commented May 18, 2026

@copilot merge main and recompile

Done in 15c8a1e. Merged main, ran make recompile, and validated with go test ./pkg/cli ./pkg/logger ./pkg/workflow plus go vet ./pkg/cli ./pkg/logger ./pkg/workflow.

@pelikhan
Copy link
Copy Markdown
Collaborator

@copilot merge main and recompile

…r-mu-unlock

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Copy link
Copy Markdown
Contributor Author

Copilot AI commented May 18, 2026

@copilot merge main and recompile

Done in 535cef0. I merged the latest main, ran make recompile, and revalidated with go test ./pkg/cli ./pkg/logger ./pkg/workflow plus go vet ./pkg/cli ./pkg/logger ./pkg/workflow.

@pelikhan pelikhan merged commit 938a1b4 into main May 18, 2026
9 of 12 checks passed
@pelikhan pelikhan deleted the copilot/refactor-defer-mu-unlock branch May 18, 2026 16:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Refactor: apply defer mu.Unlock() consistently across mutex sites in pkg/

3 participants