From 179a97b69a490740b5a2d1e43b90b1629003fc2e Mon Sep 17 00:00:00 2001 From: Jimmy Bogard Date: Thu, 3 Sep 2026 14:50:09 -0500 Subject: [PATCH] Emit the context check for the containing type map in CollectionMapper CollectionMapper emits the OverMaxDepth check from the containing type map (memberMap.TypeMap) but decided whether to emit CheckContext from the element type map. When the containing map has MaxDepth set and the element map is plain, the generated plan calls context.OverTypeDepth against the default context, which throws "Context.Items are only available when using a Map overload that takes Action!". Collection execution plans are cached per type pair and MapRequest equality ignores MemberMap, so the first member map to compile a given collection pair wins the cache for every other call site. Once a map with MaxDepth compiles the plan, unrelated callers reaching it under the default context fail, which makes the failure depend on warmup order rather than on configuration. This became reachable for many more configurations when self-referential type maps started defaulting to MaxDepth 64 in 0afaf1e9. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Wm2m61jXrAs4FheXR5TufH --- src/AutoMapper/Mappers/CollectionMapper.cs | 4 +- .../Bug/CollectionMaxDepthContext.cs | 44 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 src/UnitTests/Bug/CollectionMaxDepthContext.cs diff --git a/src/AutoMapper/Mappers/CollectionMapper.cs b/src/AutoMapper/Mappers/CollectionMapper.cs index 33b7bd4a65..b33434324b 100644 --- a/src/AutoMapper/Mappers/CollectionMapper.cs +++ b/src/AutoMapper/Mappers/CollectionMapper.cs @@ -141,7 +141,9 @@ void UseDestinationValue() Expression CheckContext() { var elementTypeMap = configuration.ResolveTypeMap(sourceElementType, destinationElementType); - return elementTypeMap == null ? null : ExpressionBuilder.CheckContext(elementTypeMap); + var checkElement = elementTypeMap == null ? null : ExpressionBuilder.CheckContext(elementTypeMap); + // OverMaxDepth uses the containing type map, so honor its context requirements too + return checkElement ?? (memberMap?.TypeMap is { } containingTypeMap ? ExpressionBuilder.CheckContext(containingTypeMap) : null); } } } diff --git a/src/UnitTests/Bug/CollectionMaxDepthContext.cs b/src/UnitTests/Bug/CollectionMaxDepthContext.cs new file mode 100644 index 0000000000..de78da6eb2 --- /dev/null +++ b/src/UnitTests/Bug/CollectionMaxDepthContext.cs @@ -0,0 +1,44 @@ +namespace AutoMapper.UnitTests.Bug; + +public class CollectionMaxDepthContext +{ + public class Service { public string Description { get; set; } } + public class ServiceDto { public string Description { get; set; } } + + // Self-referential, so CheckForCycles auto-enables PreserveReferences and MaxDepth + public class RecursiveLine { public RecursiveLine Parent { get; set; } public List Services { get; set; } } + public class RecursiveLineDto { public RecursiveLineDto Parent { get; set; } public IList Services { get; set; } } + + // Plain type mapping the same List -> IList pair + public class PlainLine { public List Services { get; set; } } + public class PlainLineDto { public IList Services { get; set; } } + + static IMapper CreateMapper() => new MapperConfiguration(cfg => + { + cfg.CreateMap(); + cfg.CreateMap().ForMember(d => d.Services, o => o.MapAtRuntime()); + cfg.CreateMap().ForMember(d => d.Services, o => o.MapAtRuntime()); + }).CreateMapper(); + + static RecursiveLine NewRecursiveLine() => new() { Services = [new Service { Description = "recursive" }] }; + static PlainLine NewPlainLine() => new() { Services = [new Service { Description = "plain" }] }; + + // The collection execution plan is cached per type pair, ignoring the member map it was built + // for. When the recursive line compiles it first, the plan carries a max depth check for the + // containing type map, which needs a non default context in every other caller too. + [Fact] + public void Should_map_the_plain_line_after_the_recursive_line() + { + var mapper = CreateMapper(); + mapper.Map(NewRecursiveLine()).Services.Count.ShouldBe(1); + mapper.Map(NewPlainLine()).Services.Count.ShouldBe(1); + } + + [Fact] + public void Should_map_the_recursive_line_after_the_plain_line() + { + var mapper = CreateMapper(); + mapper.Map(NewPlainLine()).Services.Count.ShouldBe(1); + mapper.Map(NewRecursiveLine()).Services.Count.ShouldBe(1); + } +}