Skip to content

Commit 875a1f6

Browse files
fix(features): fail nested checker resolution closed
Disallow recursive ResolveFeature calls from feature checkers so direct, negating, multi-node, and concurrent cycles cannot cache enabled results or wait on one another. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1e4a1ca6-53f7-4158-af22-35d2448d0b13
1 parent ac54ad6 commit 875a1f6

3 files changed

Lines changed: 38 additions & 14 deletions

File tree

‎docs/feature-flags.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ branch.
7070
The inventory's string-based checker owns request feature state. Once installed,
7171
that state is authoritative; a checker stored on tool dependencies is used only
7272
as a fallback when handlers are invoked directly without request state.
73+
Feature checkers must not call `ResolveFeature`; nested resolution fails the
74+
owning check closed.
7375

7476
---
7577

‎pkg/inventory/features.go‎

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,8 @@ const maxFeatureRuleFlags = 16
1313
// FeatureFlag identifies a feature consistently across inventory consumers.
1414
type FeatureFlag string
1515

16-
// FeatureFlagChecker resolves one feature flag for the current request. Every
17-
// context value needed for availability checks must be installed before the
18-
// inventory is resolved. Handler-only checks receive the live tool-call context.
16+
// FeatureFlagChecker resolves one feature flag for the current request. Checkers
17+
// must not call ResolveFeature; nested resolution fails the owning check closed.
1918
type FeatureFlagChecker func(ctx context.Context, flag string) (bool, error)
2019

2120
// FeatureResolver returns the resolved value of a feature flag.
@@ -154,6 +153,16 @@ func (s *featureState) enabled(ctx context.Context, feature FeatureFlag) bool {
154153
}
155154

156155
owner := resolvingFeatureFromContext(ctx)
156+
if owner != nil {
157+
s.mu.Lock()
158+
if ownerResult := s.results[owner.flag]; ownerResult != nil {
159+
ownerResult.failed = true
160+
}
161+
s.mu.Unlock()
162+
fmt.Fprintf(os.Stderr, "Feature flag checker attempted nested resolution of %q\n", feature)
163+
return false
164+
}
165+
157166
s.mu.Lock()
158167
if s.results == nil {
159168
s.results = make(map[FeatureFlag]*featureResult)
@@ -165,15 +174,6 @@ func (s *featureState) enabled(ctx context.Context, feature FeatureFlag) bool {
165174
s.mu.Unlock()
166175
return enabled
167176
}
168-
if owner != nil {
169-
result.failed = true
170-
if ownerResult := s.results[owner.flag]; ownerResult != nil {
171-
ownerResult.failed = true
172-
}
173-
s.mu.Unlock()
174-
fmt.Fprintf(os.Stderr, "Feature flag resolution cycle detected for %q\n", feature)
175-
return false
176-
}
177177
for !result.done {
178178
s.cond.Wait()
179179
}

‎pkg/inventory/features_test.go‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ func TestLazyFeatureResolutionUsesLiveContext(t *testing.T) {
146146
assert.True(t, ResolveFeature(ctx, checker, "handler_only"))
147147
}
148148

149-
func TestFeatureResolutionIsReentrantAcrossFlags(t *testing.T) {
149+
func TestNestedFeatureResolutionFailsClosed(t *testing.T) {
150150
var checker FeatureFlagChecker
151151
checker = func(ctx context.Context, flag string) (bool, error) {
152152
if flag == "meta" {
@@ -156,7 +156,8 @@ func TestFeatureResolutionIsReentrantAcrossFlags(t *testing.T) {
156156
}
157157

158158
ctx := WithFeatureState(context.Background(), checker)
159-
assert.True(t, ResolveFeature(ctx, nil, "meta"))
159+
assert.False(t, ResolveFeature(ctx, nil, "meta"))
160+
assert.True(t, ResolveFeature(ctx, nil, "base"))
160161
}
161162

162163
func TestDirectFeatureResolutionCycleFailsClosed(t *testing.T) {
@@ -197,6 +198,27 @@ func TestMutualFeatureResolutionCycleFailsClosed(t *testing.T) {
197198
assert.False(t, ResolveFeature(ctx, nil, "b"))
198199
}
199200

201+
func TestThreeNodeFeatureResolutionCycleFailsClosed(t *testing.T) {
202+
var checker FeatureFlagChecker
203+
checker = func(ctx context.Context, flag string) (bool, error) {
204+
switch flag {
205+
case "a":
206+
return !ResolveFeature(ctx, checker, "b"), nil
207+
case "b":
208+
return !ResolveFeature(ctx, checker, "c"), nil
209+
case "c":
210+
return !ResolveFeature(ctx, checker, "a"), nil
211+
default:
212+
return false, nil
213+
}
214+
}
215+
216+
ctx := WithFeatureState(context.Background(), checker)
217+
assert.False(t, ResolveFeature(ctx, nil, "a"))
218+
assert.False(t, ResolveFeature(ctx, nil, "b"))
219+
assert.False(t, ResolveFeature(ctx, nil, "c"))
220+
}
221+
200222
func TestConcurrentFeatureResolutionIsDeduplicated(t *testing.T) {
201223
var (
202224
calls int

0 commit comments

Comments
 (0)