Noticed while investigating #4650. This is an observation about the code, not a confirmed bug — I have not produced a repro where it causes wrong mapping output.
What happens
In TypeMapPlanBuilder.CheckForCycles (src/AutoMapper/Execution/TypeMapPlanBuilder.cs:102-160) the path set is maintained as:
typeMapsPath.Add(typeMap); // :104
foreach (var memberMap in MemberMaps())
{
...
if (memberMap.Inline && (memberTypeMap.PreserveReferences ||
typeMapsPath.Count == configuration.MaxExecutionPlanDepth)) // :113-114
...
CheckForCycles(configuration, memberTypeMap, typeMapsPath); // recursion
}
typeMapsPath.Remove(typeMap); // :159
The recursive call at the end of each iteration removes its own type map from the shared set before returning. When that recursion visits a map already on the path, the Remove takes it off while the outer frame is still walking that same map's members. Subsequent members in the outer loop then see a shorter typeMapsPath than they should.
Since inlining is decided by typeMapsPath.Count == configuration.MaxExecutionPlanDepth — an exact equality against a default of 2 — a member evaluated after the path has been shortened can miss the depth threshold entirely and stay inlined.
Observed effect
With Order -> Line -> Services where Line is self-referential, whether Line.Services ends up inlined depends on property declaration order on Line:
Parent declared first: recursion through Parent re-enters Line, removes it from the path, and Services is then evaluated with Count == 1, so it stays inlined.
Services declared first: it is evaluated with Count == 2 and inlining is disabled.
I confirmed the first case by inspecting MemberMap.Inline after a map (Services Inline=True, Parent Inline=False).
Inlining is a performance and plan-shape decision rather than a correctness one, so this may be harmless. But it makes plan shape depend on declaration order and on which root map compiles first, and #4650 showed that order-dependent plan shape is exactly the kind of thing that turns into a non-deterministic production failure when it interacts with something else.
If the intent is that each frame sees the full ancestor path, the recursion needs to restore the set rather than pop a map that an outer frame still owns — or the depth test should not depend on a set being mutated underneath it.
Noticed while investigating #4650. This is an observation about the code, not a confirmed bug — I have not produced a repro where it causes wrong mapping output.
What happens
In
TypeMapPlanBuilder.CheckForCycles(src/AutoMapper/Execution/TypeMapPlanBuilder.cs:102-160) the path set is maintained as:The recursive call at the end of each iteration removes its own type map from the shared set before returning. When that recursion visits a map already on the path, the
Removetakes it off while the outer frame is still walking that same map's members. Subsequent members in the outer loop then see a shortertypeMapsPaththan they should.Since inlining is decided by
typeMapsPath.Count == configuration.MaxExecutionPlanDepth— an exact equality against a default of 2 — a member evaluated after the path has been shortened can miss the depth threshold entirely and stay inlined.Observed effect
With
Order -> Line -> ServiceswhereLineis self-referential, whetherLine.Servicesends up inlined depends on property declaration order onLine:Parentdeclared first: recursion throughParentre-entersLine, removes it from the path, andServicesis then evaluated withCount == 1, so it stays inlined.Servicesdeclared first: it is evaluated withCount == 2and inlining is disabled.I confirmed the first case by inspecting
MemberMap.Inlineafter a map (Services Inline=True, Parent Inline=False).Inlining is a performance and plan-shape decision rather than a correctness one, so this may be harmless. But it makes plan shape depend on declaration order and on which root map compiles first, and #4650 showed that order-dependent plan shape is exactly the kind of thing that turns into a non-deterministic production failure when it interacts with something else.
If the intent is that each frame sees the full ancestor path, the recursion needs to restore the set rather than pop a map that an outer frame still owns — or the depth test should not depend on a set being mutated underneath it.