Skip to content

Commit 5db8653

Browse files
fix(features): close cycle and ownership gaps
Preserve the public string dependency API, detect concurrent resolution cycles without blocking, and isolate cached feature metadata from caller mutation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1e4a1ca6-53f7-4158-af22-35d2448d0b13
1 parent 4d60766 commit 5db8653

10 files changed

Lines changed: 258 additions & 30 deletions

File tree

pkg/github/csv_output_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -366,7 +366,7 @@ type csvOutputTestDeps struct {
366366
csvOn bool
367367
}
368368

369-
func (d csvOutputTestDeps) IsFeatureEnabled(_ context.Context, flag inventory.FeatureFlag) bool {
369+
func (d csvOutputTestDeps) IsFeatureEnabled(_ context.Context, flag string) bool {
370370
return flag == FeatureFlagCSVOutput && d.csvOn
371371
}
372372

pkg/github/dependencies.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ type ToolDependencies interface {
9494
GetContentWindowSize() int
9595

9696
// IsFeatureEnabled checks if a feature flag is enabled.
97-
IsFeatureEnabled(ctx context.Context, flag inventory.FeatureFlag) bool
97+
IsFeatureEnabled(ctx context.Context, flag string) bool
9898

9999
// Logger returns the structured logger, optionally enriched with
100100
// request-scoped data from ctx. Integrators provide their own slog.Handler
@@ -206,8 +206,8 @@ func (d BaseDeps) GetRequestStateSealer() RequestStateSealer { return d.StateSea
206206
// IsFeatureEnabled checks if a feature flag is enabled. Request feature state
207207
// is authoritative when present; the dependency checker is a fallback for
208208
// direct handler invocation. Empty names and checker errors resolve false.
209-
func (d BaseDeps) IsFeatureEnabled(ctx context.Context, flag inventory.FeatureFlag) bool {
210-
return inventory.ResolveFeature(ctx, d.featureChecker, flag)
209+
func (d BaseDeps) IsFeatureEnabled(ctx context.Context, flag string) bool {
210+
return inventory.ResolveFeature(ctx, d.featureChecker, inventory.FeatureFlag(flag))
211211
}
212212

213213
// NewTool creates a ServerTool that retrieves ToolDependencies from context at call time.
@@ -486,6 +486,6 @@ func (d *RequestDeps) Metrics(ctx context.Context) metrics.Metrics {
486486
// IsFeatureEnabled checks if a feature flag is enabled. Request feature state
487487
// is authoritative when present; the dependency checker is a fallback for
488488
// direct handler invocation.
489-
func (d *RequestDeps) IsFeatureEnabled(ctx context.Context, flag inventory.FeatureFlag) bool {
490-
return inventory.ResolveFeature(ctx, d.featureChecker, flag)
489+
func (d *RequestDeps) IsFeatureEnabled(ctx context.Context, flag string) bool {
490+
return inventory.ResolveFeature(ctx, d.featureChecker, inventory.FeatureFlag(flag))
491491
}

pkg/github/feature_flags_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ func HelloWorldTool(t translations.TranslationHelperFunc) inventory.ServerTool {
4747

4848
// Check feature flag to determine greeting style
4949
greeting := "Hello, world!"
50-
if deps.IsFeatureEnabled(ctx, RemoteMCPEnthusiasticGreeting) {
50+
if deps.IsFeatureEnabled(ctx, string(RemoteMCPEnthusiasticGreeting)) {
5151
greeting += " Welcome to the future of MCP! 🎉"
5252
}
5353

pkg/github/server_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ func (s stubDeps) GetRepoAccessCache(_ context.Context) (*lockdown.RepoAccessCac
6565
func (s stubDeps) GetT() translations.TranslationHelperFunc { return s.t }
6666
func (s stubDeps) GetFlags(_ context.Context) FeatureFlags { return s.flags }
6767
func (s stubDeps) GetContentWindowSize() int { return s.contentWindowSize }
68-
func (s stubDeps) IsFeatureEnabled(_ context.Context, _ inventory.FeatureFlag) bool {
68+
func (s stubDeps) IsFeatureEnabled(_ context.Context, _ string) bool {
6969
return false
7070
}
7171
func (s stubDeps) Logger(_ context.Context) *slog.Logger {

pkg/inventory/builder.go

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ const mcpAppsFeatureFlag FeatureFlag = "remote_mcp_ui_apps"
2323
// Returns true if the tool should be included, false to exclude it.
2424
type ToolFilter func(ctx context.Context, tool *ServerTool) (bool, error)
2525

26-
// Builder builds a Registry with the specified configuration.
26+
// Builder builds a Registry with the specified configuration. SetTools,
27+
// SetResources, and SetPrompts copy the feature and metadata state retained by
28+
// the inventory.
2729
// Use NewBuilder to create a builder, chain configuration methods,
2830
// then call Build() to create the final inventory.
2931
//
@@ -65,22 +67,49 @@ func NewBuilder() *Builder {
6567

6668
// SetTools sets the tools for the inventory. Returns self for chaining.
6769
func (b *Builder) SetTools(tools []ServerTool) *Builder {
68-
b.tools = tools
70+
b.tools = slices.Clone(tools)
71+
for i := range b.tools {
72+
b.tools[i] = cloneServerTool(b.tools[i])
73+
}
6974
return b
7075
}
7176

7277
// SetResources sets the resource templates for the inventory. Returns self for chaining.
7378
func (b *Builder) SetResources(resources []ServerResourceTemplate) *Builder {
74-
b.resourceTemplates = resources
79+
b.resourceTemplates = slices.Clone(resources)
80+
for i := range b.resourceTemplates {
81+
b.resourceTemplates[i] = cloneResourceTemplate(b.resourceTemplates[i])
82+
}
7583
return b
7684
}
7785

7886
// SetPrompts sets the prompts for the inventory. Returns self for chaining.
7987
func (b *Builder) SetPrompts(prompts []ServerPrompt) *Builder {
80-
b.prompts = prompts
88+
b.prompts = slices.Clone(prompts)
89+
for i := range b.prompts {
90+
b.prompts[i] = clonePrompt(b.prompts[i])
91+
}
8192
return b
8293
}
8394

95+
func cloneServerTool(tool ServerTool) ServerTool {
96+
tool.Tool.Meta = maps.Clone(tool.Tool.Meta)
97+
tool.FeatureRule = tool.FeatureRule.clone()
98+
return tool
99+
}
100+
101+
func cloneResourceTemplate(resource ServerResourceTemplate) ServerResourceTemplate {
102+
resource.Template.Meta = maps.Clone(resource.Template.Meta)
103+
resource.FeatureRule = resource.FeatureRule.clone()
104+
return resource
105+
}
106+
107+
func clonePrompt(prompt ServerPrompt) ServerPrompt {
108+
prompt.Prompt.Meta = maps.Clone(prompt.Prompt.Meta)
109+
prompt.FeatureRule = prompt.FeatureRule.clone()
110+
return prompt
111+
}
112+
84113
// WithDeprecatedAliases adds deprecated tool name aliases that map to canonical names.
85114
// Returns self for chaining.
86115
func (b *Builder) WithDeprecatedAliases(aliases map[string]string) *Builder {

pkg/inventory/features.go

Lines changed: 90 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package inventory
33
import (
44
"context"
55
"fmt"
6+
"maps"
67
"os"
78
"slices"
89
"sync"
@@ -35,6 +36,14 @@ type FeatureRule struct {
3536
predicate FeaturePredicate
3637
}
3738

39+
func (r FeatureRule) clone() FeatureRule {
40+
return FeatureRule{
41+
features: slices.Clone(r.features),
42+
featureSet: maps.Clone(r.featureSet),
43+
predicate: r.predicate,
44+
}
45+
}
46+
3847
// NewFeatureRule creates an availability rule over the supplied feature flags.
3948
func NewFeatureRule(features []FeatureFlag, predicate FeaturePredicate) FeatureRule {
4049
declared := make([]FeatureFlag, 0, len(features))
@@ -124,22 +133,25 @@ type featureState struct {
124133

125134
mu sync.Mutex
126135
results map[FeatureFlag]*featureResult
136+
waiting map[FeatureFlag]map[FeatureFlag]int
127137
}
128138

129139
type featureResult struct {
130140
ready chan struct{}
131141
enabled bool
142+
failed bool
143+
done bool
132144
}
133145

134146
type resolvingFeature struct {
135-
flag FeatureFlag
136-
parent *resolvingFeature
147+
flag FeatureFlag
137148
}
138149

139150
func newFeatureState(checker FeatureFlagChecker) *featureState {
140151
return &featureState{
141152
checker: checker,
142153
results: make(map[FeatureFlag]*featureResult),
154+
waiting: make(map[FeatureFlag]map[FeatureFlag]int),
143155
}
144156
}
145157

@@ -148,21 +160,34 @@ func (s *featureState) enabled(ctx context.Context, feature FeatureFlag) bool {
148160
return false
149161
}
150162

151-
for current := resolvingFeatureFromContext(ctx); current != nil; current = current.parent {
152-
if current.flag == feature {
153-
fmt.Fprintf(os.Stderr, "Feature flag resolution cycle detected for %q\n", feature)
154-
return false
155-
}
156-
}
157-
163+
owner := resolvingFeatureFromContext(ctx)
158164
s.mu.Lock()
159165
result, found := s.results[feature]
166+
if found && result.done {
167+
enabled := result.enabled
168+
s.mu.Unlock()
169+
return enabled
170+
}
160171
if !found {
161172
result = &featureResult{ready: make(chan struct{})}
162173
s.results[feature] = result
163174
}
175+
if owner != nil {
176+
if path := s.pathLocked(feature, owner.flag, nil); path != nil {
177+
s.failCycleLocked(owner.flag, path)
178+
s.mu.Unlock()
179+
return false
180+
}
181+
if s.waiting[owner.flag] == nil {
182+
s.waiting[owner.flag] = make(map[FeatureFlag]int)
183+
}
184+
s.waiting[owner.flag][feature]++
185+
}
164186
s.mu.Unlock()
165187

188+
if owner != nil {
189+
defer s.clearWait(owner.flag, feature)
190+
}
166191
if found {
167192
select {
168193
case <-result.ready:
@@ -175,25 +200,77 @@ func (s *featureState) enabled(ctx context.Context, feature FeatureFlag) bool {
175200
completed := false
176201
defer func() {
177202
if !completed {
203+
s.mu.Lock()
204+
result.failed = true
205+
result.done = true
178206
close(result.ready)
207+
s.mu.Unlock()
179208
}
180209
}()
181210

182-
resolutionCtx := context.WithValue(ctx, resolvingFeatureContextKey{}, &resolvingFeature{
183-
flag: feature,
184-
parent: resolvingFeatureFromContext(ctx),
185-
})
211+
resolutionCtx := context.WithValue(ctx, resolvingFeatureContextKey{}, &resolvingFeature{flag: feature})
186212
enabled, err := s.checker(resolutionCtx, feature)
187213
if err != nil {
188214
fmt.Fprintf(os.Stderr, "Feature flag check error for %q: %v\n", feature, err)
189215
enabled = false
190216
}
217+
s.mu.Lock()
218+
if result.failed {
219+
enabled = false
220+
}
191221
result.enabled = enabled
222+
result.done = true
192223
completed = true
193224
close(result.ready)
225+
s.mu.Unlock()
194226
return enabled
195227
}
196228

229+
func (s *featureState) pathLocked(current, target FeatureFlag, seen map[FeatureFlag]bool) []FeatureFlag {
230+
if current == target {
231+
return []FeatureFlag{current}
232+
}
233+
if seen == nil {
234+
seen = make(map[FeatureFlag]bool)
235+
}
236+
if seen[current] {
237+
return nil
238+
}
239+
seen[current] = true
240+
for next := range s.waiting[current] {
241+
if path := s.pathLocked(next, target, seen); path != nil {
242+
return append([]FeatureFlag{current}, path...)
243+
}
244+
}
245+
return nil
246+
}
247+
248+
func (s *featureState) failCycleLocked(owner FeatureFlag, path []FeatureFlag) {
249+
if result := s.results[owner]; result != nil {
250+
result.failed = true
251+
}
252+
for _, feature := range path {
253+
if result := s.results[feature]; result != nil {
254+
result.failed = true
255+
}
256+
}
257+
fmt.Fprintf(os.Stderr, "Feature flag resolution cycle detected for %q\n", path[0])
258+
}
259+
260+
func (s *featureState) clearWait(owner, target FeatureFlag) {
261+
s.mu.Lock()
262+
if targets := s.waiting[owner]; targets != nil {
263+
targets[target]--
264+
if targets[target] == 0 {
265+
delete(targets, target)
266+
}
267+
}
268+
if len(s.waiting[owner]) == 0 {
269+
delete(s.waiting, owner)
270+
}
271+
s.mu.Unlock()
272+
}
273+
197274
func resolvingFeatureFromContext(ctx context.Context) *resolvingFeature {
198275
feature, _ := ctx.Value(resolvingFeatureContextKey{}).(*resolvingFeature)
199276
return feature

pkg/inventory/features_test.go

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ func TestFeatureResolutionIsReentrantAcrossFlags(t *testing.T) {
131131
assert.True(t, ResolveFeature(ctx, nil, "meta"))
132132
}
133133

134-
func TestFeatureResolutionCycleFailsClosed(t *testing.T) {
134+
func TestDirectFeatureResolutionCycleFailsClosed(t *testing.T) {
135135
var checker FeatureFlagChecker
136136
checker = func(ctx context.Context, flag FeatureFlag) (bool, error) {
137137
return ResolveFeature(ctx, checker, flag), nil
@@ -141,6 +141,34 @@ func TestFeatureResolutionCycleFailsClosed(t *testing.T) {
141141
assert.False(t, ResolveFeature(ctx, nil, "cycle"))
142142
}
143143

144+
func TestSelfNegatingFeatureResolutionCycleFailsClosed(t *testing.T) {
145+
var checker FeatureFlagChecker
146+
checker = func(ctx context.Context, flag FeatureFlag) (bool, error) {
147+
return !ResolveFeature(ctx, checker, flag), nil
148+
}
149+
150+
ctx := WithResolvedFeatures(context.Background(), checker, nil)
151+
assert.False(t, ResolveFeature(ctx, nil, "cycle"))
152+
}
153+
154+
func TestMutualFeatureResolutionCycleFailsClosed(t *testing.T) {
155+
var checker FeatureFlagChecker
156+
checker = func(ctx context.Context, flag FeatureFlag) (bool, error) {
157+
switch flag {
158+
case "a":
159+
return !ResolveFeature(ctx, checker, "b"), nil
160+
case "b":
161+
return !ResolveFeature(ctx, checker, "a"), nil
162+
default:
163+
return false, nil
164+
}
165+
}
166+
167+
ctx := WithResolvedFeatures(context.Background(), checker, nil)
168+
assert.False(t, ResolveFeature(ctx, nil, "a"))
169+
assert.False(t, ResolveFeature(ctx, nil, "b"))
170+
}
171+
144172
func TestConcurrentFeatureResolutionIsDeduplicated(t *testing.T) {
145173
var (
146174
calls int
@@ -177,6 +205,40 @@ func TestConcurrentFeatureResolutionIsDeduplicated(t *testing.T) {
177205
callsMu.Unlock()
178206
}
179207

208+
func TestConcurrentCrossFeatureCycleFailsClosed(t *testing.T) {
209+
startedA := make(chan struct{})
210+
startedB := make(chan struct{})
211+
var checker FeatureFlagChecker
212+
checker = func(ctx context.Context, flag FeatureFlag) (bool, error) {
213+
switch flag {
214+
case "a":
215+
close(startedA)
216+
<-startedB
217+
return !ResolveFeature(ctx, checker, "b"), nil
218+
case "b":
219+
close(startedB)
220+
<-startedA
221+
return !ResolveFeature(ctx, checker, "a"), nil
222+
default:
223+
return false, nil
224+
}
225+
}
226+
227+
ctx := WithResolvedFeatures(context.Background(), checker, nil)
228+
results := make(chan bool, 2)
229+
go func() { results <- ResolveFeature(ctx, nil, "a") }()
230+
go func() { results <- ResolveFeature(ctx, nil, "b") }()
231+
232+
for range 2 {
233+
select {
234+
case result := <-results:
235+
assert.False(t, result)
236+
case <-time.After(time.Second):
237+
t.Fatal("concurrent feature cycle deadlocked")
238+
}
239+
}
240+
}
241+
180242
func TestContextFeatureStateTakesPrecedence(t *testing.T) {
181243
var fallbackCalls int
182244
stateChecker := func(context.Context, FeatureFlag) (bool, error) {

0 commit comments

Comments
 (0)