diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Properties/CompositionResources.Designer.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/Properties/CompositionResources.Designer.cs index d34e4766422..52a29be6614 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/Properties/CompositionResources.Designer.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Properties/CompositionResources.Designer.cs @@ -1501,6 +1501,15 @@ internal static string SatisfiabilityValidator_CycleDetected { } } + /// + /// Looks up a localized string similar to Satisfiability validation reached the maximum recursion depth ({0}) while visiting type '{1}'. Validation of deeply nested fields may be incomplete.. + /// + internal static string SatisfiabilityValidator_MaxRecursionDepthReached { + get { + return ResourceManager.GetString("SatisfiabilityValidator_MaxRecursionDepthReached", resourceCulture); + } + } + /// /// Looks up a localized string similar to Type '{0}' implements the 'Node' interface, but no source schema provides a non-internal 'Query.node<Node>' lookup field for this type.. /// diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/Properties/CompositionResources.resx b/src/HotChocolate/Fusion/src/Fusion.Composition/Properties/CompositionResources.resx index 206ffcea0d6..8748bfaafc9 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/Properties/CompositionResources.resx +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/Properties/CompositionResources.resx @@ -495,6 +495,9 @@ Cycle detected: {0} -> {1}. + + Satisfiability validation reached the maximum recursion depth ({0}) while visiting type '{1}'. Validation of deeply nested fields may be incomplete. + Type '{0}' implements the 'Node' interface, but no source schema provides a non-internal 'Query.node<Node>' lookup field for this type. diff --git a/src/HotChocolate/Fusion/src/Fusion.Composition/SatisfiabilityValidator.cs b/src/HotChocolate/Fusion/src/Fusion.Composition/SatisfiabilityValidator.cs index 4f5f0b4ee46..1327b454440 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Composition/SatisfiabilityValidator.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Composition/SatisfiabilityValidator.cs @@ -18,6 +18,8 @@ namespace HotChocolate.Fusion; internal sealed class SatisfiabilityValidator { + private const int MaxRecursionDepth = 500; + private readonly SatisfiabilityOptions _options; private readonly RequirementsValidator _requirementsValidator; private readonly MutableSchemaDefinition _schema; @@ -58,6 +60,27 @@ private void VisitObjectType( MutableObjectTypeDefinition objectType, SatisfiabilityValidatorContext context) { + if (context.Depth >= MaxRecursionDepth) + { + if (!context.DepthLimitReached) + { + context.DepthLimitReached = true; + + _log.Write( + LogEntryBuilder.New() + .SetMessage( + SatisfiabilityValidator_MaxRecursionDepthReached, + MaxRecursionDepth, + objectType.Name) + .SetCode(LogEntryCodes.Unsatisfiable) + .SetSeverity(LogSeverity.Warning) + .Build()); + } + + return; + } + + context.Depth++; context.TypeContext.Push(objectType); foreach (var field in objectType.Fields) @@ -90,6 +113,7 @@ private void VisitObjectType( } context.TypeContext.Pop(); + context.Depth--; } private void VisitOutputField( @@ -427,4 +451,8 @@ internal sealed class SatisfiabilityValidatorContext public SatisfiabilityPath CycleDetectionPath { get; } = []; public HashSet FieldAccessCache { get; } = []; + + public int Depth { get; set; } + + public bool DepthLimitReached { get; set; } } diff --git a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SatisfiabilityValidatorTests.cs b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SatisfiabilityValidatorTests.cs index fa480cc2f87..449784350a5 100644 --- a/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SatisfiabilityValidatorTests.cs +++ b/src/HotChocolate/Fusion/test/Fusion.Composition.Tests/SatisfiabilityValidatorTests.cs @@ -1,3 +1,4 @@ +using System.Text; using HotChocolate.Fusion.Logging; using HotChocolate.Fusion.Options; using static HotChocolate.Fusion.CompositionTestHelper; @@ -2899,4 +2900,62 @@ type Cat implements Node { } }; } + + [Fact] + public void LargeTypeCycle_DoesNotCauseStackOverflow() + { + // arrange + // Creates a long type cycle (T1 → T2 → … → T500 → T1) across 5 identical schemas. + // Each schema provides every type and field. Without the recursion depth limit + // in VisitObjectType, the recursion depth is O(types × schemas) = 2500+ frames, + // which overflows the default 1 MB stack. + const int typeCount = 500; + const int schemaCount = 5; + var schemas = new string[schemaCount]; + + var sb = new StringBuilder(); + sb.AppendLine("type Query {"); + + for (var t = 1; t <= typeCount; t++) + { + sb.AppendLine($" t{t}ById(id: ID!): T{t} @lookup"); + } + + sb.AppendLine("}"); + + for (var t = 1; t <= typeCount; t++) + { + var next = (t % typeCount) + 1; + sb.AppendLine( + $"type T{t} @key(fields: \"id\") {{ id: ID! @shareable next: T{next} @shareable }}"); + } + + var sdl = sb.ToString(); + + for (var i = 0; i < schemaCount; i++) + { + schemas[i] = sdl; + } + + var merger = new SourceSchemaMerger( + CreateSchemaDefinitions(schemas), + new SourceSchemaMergerOptions { AddFusionDefinitions = false }); + + var schema = merger.Merge().Value; + var log = new CompositionLog(); + var satisfiabilityValidator = new SatisfiabilityValidator(schema, log); + + // act + var result = satisfiabilityValidator.Validate(); + + // assert + Assert.True(result.IsSuccess); + var logEntry = Assert.Single(log); + Assert.Equal(LogSeverity.Warning, logEntry.Severity); + Assert.Equal(LogEntryCodes.Unsatisfiable, logEntry.Code); + Assert.Equal( + "Satisfiability validation reached the maximum recursion depth (500) " + + "while visiting type 'T500'. Validation of deeply nested fields may be incomplete.", + logEntry.Message); + } }