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);
+ }
}