Skip to content

fix: Stack overflow when transpiling type with self-referential property - #1233

Closed
BalassaMarton wants to merge 1 commit into
dotnet:mainfrom
BalassaMarton:fix-recursive-crd-generation
Closed

BalassaMarton wants to merge 1 commit into
dotnet:mainfrom
BalassaMarton:fix-recursive-crd-generation

Conversation

@BalassaMarton

Copy link
Copy Markdown
Contributor

The transpiler had a bug where using a self-referential property caused stack overflow (infinite recursion) even if the property was explicitly ignored. This PR simply adds the missing filtering to MapPrinterColumns.

Copilot AI lite review requested due to automatic review settings September 1, 2026 16:02

Copilot AI left a comment

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.

🟢 Approval recommended

Pull request overview

Fixes a CRD transpilation crash caused by MapPrinterColumns traversing into properties marked with [Ignore], which could trigger infinite recursion/stack overflow for self-referential models. This aligns printer-column discovery with the existing schema-mapping behavior that already respects [Ignore].

Changes:

  • Filter [Ignore]d properties out of the property-walk in MapPrinterColumns (both at the root and during nested traversal).
  • Add a regression test covering a self-referential [Ignore]d property to ensure transpilation completes without overflowing the stack.
File summaries
File Description
test/KubeOps.Transpiler.Test/Crds.Mlc.Test.cs Adds a regression test entity and test case for an ignored self-referential property.
src/KubeOps.Transpiler/Crds.cs Skips [Ignore]d properties during printer-column traversal to prevent recursion issues.
Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 0
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@kimpenhaus

kimpenhaus commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

@BalassaMarton thanks for the pull request. there is nothing wrong with adding the ignore handle to the mapping of the printer columns (which is currently a bug). but there shouldn't been a StackOverflowException.

if (prop.PropertyType.IsClass && !ancestors.Contains(prop.PropertyType))

so out of interest:

thanks, m.

@BalassaMarton
BalassaMarton force-pushed the fix-recursive-crd-generation branch from 2f4e735 to ae63715 Compare September 2, 2026 09:34
@BalassaMarton

Copy link
Copy Markdown
Contributor Author

Hi @kimpenhaus you're right, I forgot to update the package version in the tool manifest 🤦 The infinite loop is real though, so this PR is still valid.

@BalassaMarton

Copy link
Copy Markdown
Contributor Author

Now that I'm looking more closely, it shouldn't even go into an infinite loop because ot the ancestor check. I have no idea why it got stuck at CRD generation when I was testing it.

@kimpenhaus

Copy link
Copy Markdown
Collaborator

that's why I am asking - the Ignore attribute should just cover the generation of an invalid crd (which truely needs to get fixed) - but in general there shouldn't be any infinite loop nor an SOE. If you could attach an example reproducing that it would help lots :-) I just don't want to complete the PR yet - as it would hide if there is an issue under the hood somewhere

kimpenhaus added a commit that referenced this pull request Sep 17, 2026
The ancestor-based cycle detection only grew its set inside MapObjectType, so
a type that *is* a collection or dictionary of itself never passed through a
guarded frame: class Tree : List<Tree> and class Config : Dictionary<string,
Config> recursed until the stack overflowed. Record the current type in the
enumerable and dictionary branches as well, reporting the item or value type so
existing messages keep naming the type that holds the cycle.

Identity-based detection cannot see a recursively constructed generic such as
class Node<T> { Node<Node<T>> Child { get; set; } }, because every expansion
yields a previously unseen Type. Cap the length of a single path through the
type graph instead. A guard on the generic type definition was rejected because
it would reject legitimate nesting like List<List<string>>.

Printer-column discovery now skips properties annotated with [Ignore]. Such a
column referenced a JSON path that does not exist in the generated schema, and
walking an ignored member was the remaining way to reach the unbounded generic
expansion without the schema walk failing first.

Refs GH-1233
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
kimpenhaus added a commit that referenced this pull request Sep 17, 2026
The ancestor-based cycle detection only grew its set inside MapObjectType, so
a type that *is* a collection or dictionary of itself never passed through a
guarded frame: class Tree : List<Tree> and class Config : Dictionary<string,
Config> recursed until the stack overflowed. Record the current type in the
enumerable and dictionary branches as well, reporting the item or value type so
existing messages keep naming the type that holds the cycle.

Identity-based detection cannot see a recursively constructed generic such as
class Node<T> { Node<Node<T>> Child { get; set; } }, because every expansion
yields a previously unseen Type. Cap the length of a single path through the
type graph instead. A guard on the generic type definition was rejected because
it would reject legitimate nesting like List<List<string>>.

Printer-column discovery now skips properties annotated with [Ignore]. Such a
column referenced a JSON path that does not exist in the generated schema, and
walking an ignored member was the remaining way to reach the unbounded generic
expansion without the schema walk failing first.

Refs GH-1233
@kimpenhaus kimpenhaus closed this Sep 17, 2026
kimpenhaus added a commit that referenced this pull request Sep 17, 2026
## Problem

The CRD transpiler walks the type graph twice — once for the schema
(`Map`/`MapObjectType`) and once for printer columns
(`MapPrinterColumns`). Both terminate via a path-scoped `HashSet<Type>`
of ancestors. Two classes of input escape that guard:

**1. The ancestor set only grows inside `MapObjectType`.** The
enumerable and dictionary branches passed `ancestors` through unchanged,
so a type that *is* a collection or dictionary of itself never passed
through a guarded frame:

```csharp
public class Tree : List<Tree>;                     // Map -> MapEnumerationType -> Map -> ...
public class Config : Dictionary<string, Config>;   // Map -> Map -> ...
public class A : List<B>;  public class B : List<A>;
```

These recursed until the process died with `Stack overflow.` — not
catchable, so neither the `catch` in `Transpile` nor the
`[PreserveUnknownFields]` fallback could turn it into a diagnostic.

**2. Cycle detection compares `Type` identity.** A recursively
constructed generic produces an endless chain of *distinct* types, so
the guard can never match:

```csharp
public class Wrapper<T> { public Wrapper<Wrapper<T>>? Inner { get; set; } }
```

In the schema walk this overflowed the stack. In the printer-column walk
— which is iterative — it was a genuine infinite loop: a measured run
sat at 2 minutes 26 seconds and 2.8 GB RSS, still growing, and had to be
killed. That path was reachable even when the property was annotated
`[Ignore]`, because printer-column discovery did not evaluate
`[Ignore]`.

## Changes

- **Record the current type in the enumerable and dictionary branches**
before descending into the item/value type. The item or value type is
reported rather than the collection, so the message for the
already-covered case `class A { List<A> X; }` keeps naming `A`.
- **Cap the length of a single path through the type graph**
(`MaxTypeGraphDepth = 100`) in both walks. A guard on the generic type
definition was considered and rejected: it would reject legitimate
nesting such as `List<List<string>>`. The new exception is a
`CircularTypeReferenceException`, so existing catch clauses and the
`[PreserveUnknownFields]` degradation keep working.
- **Skip `[Ignore]` properties during printer-column discovery.** A
column below an ignored property referenced a JSON path that does not
exist in the generated schema.

## Verification

Each case run through `Transpile` before and after:

| Input | Before | After |
|---|---|---|
| `class Node { List<Node> Children; }` (control) |
`TranspilationFailedException … circular … 'Node'` | unchanged |
| `class Tree : List<Tree>` | `Stack overflow.` | `… circular … 'Tree'`
|
| `class Config : Dictionary<string, Config>` | `Stack overflow.` | `…
circular … 'Config'` |
| `Wrapper<Wrapper<T>>`, schema walk | `Stack overflow.` (1470 frames) |
`… exceeds the maximum nesting depth of 100 …` |
| `Wrapper<Wrapper<T>>` behind `[Ignore]` | infinite loop, 2m26s / 2.8
GB RSS | transpiles immediately |

`KubeOps.Transpiler.Test` 241/241, `KubeOps.Generator.Test` 61/61,
`KubeOps.Cli.Test` 31/31 (2 skipped). `dotnet build -c Release` with no
new warnings, `dotnet format --verify-no-changes` clean. Seven new tests
in `Crds.Mlc.CircularReference.Test.cs`, including a negative test
asserting the depth limit does not fire on `List<List<string>>`.

## Behaviour changes

- Type graphs that previously crashed or hung now fail with a
descriptive `TranspilationFailedException` during generation.
- An `[AdditionalPrinterColumn]` below an `[Ignore]` property is no
longer emitted. It previously produced a column whose `jsonPath` pointed
at a field absent from the schema.
- `[PreserveUnknownFields]` still opts a subtree out of the schema only,
not out of printer-column discovery. That inconsistency is left
untouched here.

## Relation to #1233

The `[Ignore]` filter in `MapPrinterColumns` is the same change as #1233
and will conflict textually. The stack overflow described in that PR's
title is the case `class Tree { List<Tree> Children; }`, which is
already handled by the cycle guards from #1141/#1201; what remains
reachable through an ignored member is the infinite loop measured above.

Docs updated in `docs/docs/operator/building-blocks/entities.mdx`.
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.

3 participants