Fatal flaw with geta-notfoundhandler

First of all, I’d like to make this a farewell post to this blog. The VM that is hosting this blog – which is generously sponsored by my employer, is being decommissioned. I have looked at some alternatives to move my blog to, but none is interesting in term of time/effort/cost. With all the things going on in my life, setting up a new blog is not of priority (that’d be, surprise, surprise, tomatoes these days). We had a good run, and I hope this blog has been useful to you, one way for another. And I hope to see you again, some days.

Back to business. The topic of today is a flaw that quite fatal of geta-notfoundhandler, that is opened source at GitHub – Geta/geta-notfoundhandler: The popular NotFound handler for ASP.NET Core and Optimizely, enabling better control over your 404 page in addition to allowing redirects for old URLs that no longer works.. We have cases when a customer’s instance hit very high CPU, essentially hanging, and the only course of action is to restart the instance. Memory dumps taken at the time all point out to the notfoundhandler, specifically, CustomRedirectCollection.This issue has been brought to me by a colleague a couple of months ago. As he treated it as lowkey, we took a quick look into it, had some good ideas, but we never really got to the bottom of it. I let it slip because it was not critically urgent/important (and you know that is when something will not be addressed).

Another colleague brought up it again recently, and while I was suffering a bad headache from a prolonged flu, I decided to get this over with. Before jumping to the conclusion and the fix, let’s start with the symptoms and analysis.

As mentioned above, in the memory dumps taken, we have seen one or two threads stuck in this stacktrace

00007E69D57E67F0 00007e6c3bded5c3 Geta.NotFoundHandler.Core.Redirects.CustomRedirectCollection.CreateSubSegmentRedirect(System.ReadOnlySpan`1, System.ReadOnlySpan`1, Geta.NotFoundHandler.Core.Redirects.CustomRedirect, System.ReadOnlySpan`1)
00007E69D57E6880 00007e6c3a47ea43 Geta.NotFoundHandler.Core.Redirects.CustomRedirectCollection.FindInternal(System.String)
00007E69D57E6920 00007e6c3c1e2be5 Geta.NotFoundHandler.Core.Redirects.CustomRedirectCollection.Find(System.Uri)
00007E69D57E6950 00007e6c3c1e2a19 Geta.NotFoundHandler.Core.RequestHandler.HandleRequest(System.Uri, System.Uri, Geta.NotFoundHandler.Core.Redirects.CustomRedirect ByRef)
00007E69D57E6990 00007e6c3bd326f7 Geta.NotFoundHandler.Core.RequestHandler.Handle(Microsoft.AspNetCore.Http.HttpContext)
00007E69D57E69E0 00007e6c3bc9f9ba Geta.NotFoundHandler.Infrastructure.Initialization.NotFoundHandlerMiddleware+d__2.MoveNext()
00007E69D57E6A40 00007e6c3bce9418 System.Runtime.CompilerServices.AsyncMethodBuilderCore.Start[[Geta.NotFoundHandler.Infrastructure.Initialization.NotFoundHandlerMiddleware+d__2, Geta.NotFoundHandler]](d__2 ByRef) [/_/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncMethodBuilderCore.cs @ 38]
00007E69D57E6A90 00007e6c3bce9360 Geta.NotFoundHandler.Infrastructure.Initialization.NotFoundHandlerMiddleware.InvokeAsync(Microsoft.AspNetCore.Http.HttpContext, Geta.NotFoundHandler.Core.RequestHandler)

if there are a lot of threads stuck in same stacktrace, you can think about lock contention – i.e. a lot of threads just waiting for some lock to be released. But if there are only a few threads in a case of high CPU, that suggests an endless loop, some do while or while code that never exits properly.

One quite infamous case of the endless loop is that when you mess with Dictionary in a concurrent scenario – adding to it while other threads are reading. That’s when your foreach will never end.

But where?

Luckily for us the library is open source so it’s much easier to try some dry code reading and guess where the problem is. And of course we found a while loop

        // Note: Guard against infinite buildup of redirects
        while (appendSegment.UrlPathMatch(oldPath))
        {
            appendSegment = appendSegment[oldPath.Length..];
        }

The only remaining question is what value of oldPath would trigger that endless loop. Lazy as I am, I turned to CoPilot to have it analyze the code to see what values that can be potentially the culprit. Blah blah, CoPilot says something like /abc/abc/abc/ could. But it does not. (We are still a long way from losing our jobs to AI, folks)

That’s the stop of my first engagement. I almost forgot about it, until another colleague asked me the same question, this time more serious. I was not very productive anyway due to headache, so let’s fix this problem once and for all.

So with a new memory dump I dived again into the problem. After identifying the offendeing thread, let’s run !clrstack -p to see if we can figure out the url that triggers the issue. This really raised my eyebrow

0:026> !do 0x00007e69dcd8bb60
Name:        Geta.NotFoundHandler.Core.Redirects.CustomRedirect
MethodTable: 00007e6c35be3ff8
EEClass:     00007e6c35bd30f0
Tracked Type: false
Size:        72(0x48) bytes
File:        /app/Geta.NotFoundHandler.dll
Fields:
              MT    Field   Offset                 Type VT     Attr            Value Name
00007e6c2ff0bac0  4000059       20       System.Boolean  1 instance                0 <WildCardSkipAppend>k__BackingField
00007e6c2ffbd2e0  400005a        8        System.String  0 instance 00007e6ad7fff3a0 _oldUrl
00007e6c2ffbd2e0  400005b       10        System.String  0 instance 00007e6ad7fff3a0 <NewUrl>k__BackingField
00007e6c2ffa9018  400005c       18         System.Int32  1 instance                0 <State>k__BackingField
00007e6c35bcf8f0  400005d       1c         System.Int32  1 instance              302 <RedirectType>k__BackingField
00007e6c3372f578  400005e       28 ...Private.CoreLib]]  1 instance 00007e69dcd8bb88 <Id>k__BackingField
0:026> !DumpObj /d 00007e6ad7fff3a0
Name:        System.String
MethodTable: 00007e6c2ffbd2e0
EEClass:     00007e6c2ff97b10
Tracked Type: false
Size:        22(0x16) bytes
File:        /usr/share/dotnet/shared/Microsoft.NETCore.App/6.0.36/System.Private.CoreLib.dll
String:      
Fields:
              MT    Field   Offset                 Type VT     Attr            Value Name
00007e6c2ffa9018  40002ba        8         System.Int32  1 instance                0 _stringLength
00007e6c2ff0e5a8  40002bb        c          System.Char  1 instance                0 _firstChar
00007e6c2ffbd2e0  40002b9       d0        System.String  0   static 00007e6ad7fff3a0 Empty

The oldUrl is empty here. And to my surprise, passing an empty value to while loop above, really tricks it to never exit. A simple program to demonstrate the issue

var appendSegment = ReadOnlySpan<char>.Empty; 
var oldPath = "".AsSpan();
while (appendSegment.UrlPathMatch(oldPath))
{
    appendSegment = appendSegment[oldPath.Length..];
}

internal static class SpanExtensions
{
    public static ReadOnlySpan<char> RemoveTrailingSlash(this ReadOnlySpan<char> chars)
    {
        if (chars.EndsWith("/"))
            return chars[..^1];

        return chars;
    }

    public static bool UrlPathMatch(this ReadOnlySpan<char> path, ReadOnlySpan<char> otherPath)
    {
        otherPath = RemoveTrailingSlash(otherPath);

        if (path.Length < otherPath.Length)
            return false;

        for (var i = 0; i < otherPath.Length; i++)
        {
            var currentChar = char.ToLowerInvariant(path[i]);
            var otherChar = char.ToLowerInvariant(otherPath[i]);

            if (!currentChar.Equals(otherChar))
                return false;
        }

        if (path.Length == otherPath.Length)
            return true;

        return path[otherPath.Length] == '/';
    }
}

The problem is that there was a safeguard for null value, but not Empty. So, to trigger the issue, you will need to have 1. a custom redirect rule that has empty oldUrl, 2. a url that does not match any other rules defined.

Once you reached the empty rule, it’s the death sentence. The while loop never exits and it will eat all CPU resource until the instance is restart.

The fix in this case is to check the oldUrl for both null and Empty. If you are using the package in your project, this is the change you need

geta-notfoundhandler/src/Geta.NotFoundHandler/Core/Redirects/CustomRedirectCollection.cs at master · quanmaiepi/geta-notfoundhandler

Maybe someone can take this and contribute to the main repo for everyone, when I’m busy attending my tomatoes

Best deals on Amazon.se week 16/2025

History of the World Map by Map Hardcover 

Reduced to kr180.02. Great book about history

https://amzn.to/3YvouVS

Anker 335 67W Charger, High Performance Power Supply with PIQ 3.0, 3-Port Fast Charger, PD Charger for iPhone 14, MacBook Pro, iPad, Galaxy, Pixel and More (USB-C to Cable
Reduced to kr299

https://amzn.to/42f7uFU

TERRAOLIVE – extra virgin olive oil, olive variety, mild body, from Spain, Montes de Toledo, recycled PET container – 5L

Reduced to 350kr

https://amzn.to/4jh7ICk

Shorten your cache keys, please

In a recent customer engagement, I have looked into a customer where their memory usage is abnormally high. Among other findings, I think one was not very well known. But as they say, small details can make a big difference – and that small detail in today’s post is the cache key.

Let’s put it to test. This is what I asked Copilot to scaffold it, and then just some small adjustments. The test is to add 10.000 items to cache, and then read each entry 10 times. One test with a very short prefix, and one with a long (but not very long) one.

using System;
using System.Runtime.Caching;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

namespace MemoryCacheBenchmarkDemo
{
    // Use MemoryDiagnoser to capture memory allocation metrics during benchmarking.
    [MemoryDiagnoser]
    public class MemoryCacheBenchmark
    {
        private const int Iterations = 10_000;
        private const int ReadIterations = 10;

        [Benchmark(Description = "MemoryCache with Short Keys")]
        public void ShortKeysBenchmark()
        {
            using (var cache = new MemoryCache("ShortKeysCache"))
            {
                const string Prefix = "K";
                // Insertion phase using short keys (e.g., "K0", "K1", ...)
                for (int i = 0; i < Iterations; i++)
                {
                    string key = Prefix + i;
                    cache.Add(key, i, DateTimeOffset.UtcNow.AddMinutes(5));
                }

                // Retrieval phase for short keys.
                
                for (int j = 0; j < Iterations; j++)
                {
                    int sum = 0;
                    for (int i = 0; i < ReadIterations; i++)
                    {
                        string key = Prefix + i;
                        if (cache.Get(key) is int value)
                        {
                            sum += value;
                        }
                    }
                    // Use the result to prevent dead code elimination.
                    if (sum == 0)
                    {
                        throw new Exception("Unexpected sum for short keys.");
                    }
                }
            }
        }

        [Benchmark(Description = "MemoryCache with Long Keys")]
        public void LongKeysBenchmark()
        {
            using (var cache = new MemoryCache("LongKeysCache"))
            {
                const string Prefix = "ThisIsAVeryLongCacheKeyPrefix_WhichAddsExtraCharacters_IsThisLongEnoughIAmNotSure";
                // Insertion phase using long keys.
                // Example: "ThisIsAVeryLongCacheKeyPrefix_WhichAddsExtraCharacters_0", etc.
                for (int i = 0; i < Iterations; i++)
                {
                    string key = Prefix + i;
                    cache.Add(key, i, DateTimeOffset.UtcNow.AddMinutes(5));
                }

                // Retrieval phase for long keys.
                for (int j = 0; j < Iterations; j++)
                {
                    int sum = 0;
                    for (int i = 0; i < ReadIterations; i++)
                    {
                        string key = Prefix + i;
                        if (cache.Get(key) is int value)
                        {
                            sum += value;
                        }
                    }
                    // Use the result to prevent dead code elimination.
                    if (sum == 0)
                    {
                        throw new Exception("Unexpected sum for short keys.");
                    }
                }
            }
        }
    }

    public class Program
    {
        public static void Main(string[] args)
        {
            // Executes all benchmarks in MemoryCacheBenchmark.
            BenchmarkRunner.Run<MemoryCacheBenchmark>();
        }
    }
}

And the difference. Not only the short key is faster, you end up with considerably less allocations (and therefore, Garbage collection)

The only requirement for cache key is that it’s unique. It might make sense to code it in a way that if you have to ever look into memory dumps (I wish you never have to, but it’s painful yet fun experience) – you know what cache entry is this. For example in Commerce we have this prefix for order cache key:

EP:EC:OS:

And that’s it. EP is short hand for EPiServer (so we know it’s us), EC is for eCommerce, and OS is for Order System. (I know, it’s been that way for a very long time for historical reasons, and nobody bothers to change it)

So next time you adding some cache to your class, make sure to use the shortest cache key as possible. It’s not micro optimization. If you know it’s better, why not?

A curious case of free objects

It’s been a long time since I posted some memory dump investigation. Not that I haven’t done that, but it’s, most of the times, hard to tell a good story. In many cases we have a gotcha moment and in the context of the work we were doing, very exciting, but to tell a story to you who don’t want to be bothered with the underlying details (and we usually can’t share a lot of them), the story becomes much less exciting.

In many cases memory dump is an aid of the investigation, but not the only tool. It is easy to see what the app was doing at the exact moment the memory dump was taken, but when it’s an ongoing process, harder to pinpoint the cause. Nevertheless, it’s a powerful tool to at least, limit the scope of what you should look into.

A colleague reached out to me the other day because a customer has raising memory over time.

Normally you would want the memory to be relatively stable over time. An increasing memory over time should raise an eyebrow, especially if it’s not eventually reaching a plateau. As this is a customer that we just engaged with some other memory allocations, we wanted to make sure that everything was as it should. Another colleague helped to collect some memory dump, so game is on.

What immediately puzzles me was that there was a lot of free objects in the memory dump. That was indication of a GC happened. Not much left for us to track the source of memory allocations. Fine, let’s take another memory dump 30 minutes later. And it’s … the same.

7c343a686798    201,951     24,234,120 EPiServer.Core.Html.StringParsing.ContentFragment
7c34431d93a8     59,155     36,690,528 System.Collections.Generic.Dictionary<System.String, EPiServer.Core.PropertyData>+Entry[]
7c3438e63230    534,370     42,749,600 EPiServer.Core.PropertyLongString
7c3436362a28    202,652    107,559,201 System.Byte[]
7c34356d7c18    158,223  1,082,118,840 System.String[]
7c34355cd7c8 10,944,050  1,235,497,138 System.String
5b4faa00d1b0  8,039,645 11,560,389,656 Free
Total 28,779,604 objects, 14,685,150,235 bytes

What could potentially create that many free objects and leave them floating around. And memory keeps raising? The free objects indicating that there have been some heavy Garbage collection. But we can’t track where those come from because they were “freed”, rendering !gcroot useless.

I was pulling my hairs for a while (not that I have that many hairs left to pull). But then I realize, could these be big objects allocated in LOH (Larger objects heap). If they are big enough (more than 85.000 bytes in size), they will not be be freed when no longer referenced, but they are not “moved” (e.g. compacted).

0:000> !dumpheap -stat -mt 5b4faa00d1b0 -min 85000
Statistics:
          MT Count   TotalSize Class Name
5b4faa00d1b0    53 237,914,208 Free
Total 53 objects, 237,914,208 bytes

Hmm, we have 53 big object with more than 200MB in size, so averaging around 4MB per object. Let’s see the biggest objects by removing the -stat option

0:000> !dumpheap -mt 5b4faa00d1b0 -min 85000
         Address               MT           Size
    7bf42b10e140     5b4faa00d1b0         97,264 Free
    7bf42bd1bb50     5b4faa00d1b0      2,319,816 Free
    7bf42c8521a0     5b4faa00d1b0        586,096 Free
    7bf42ca8d448     5b4faa00d1b0         91,064 Free
    7bf42d71bdd8     5b4faa00d1b0      4,396,576 Free
    7bf42dc0a5a8     5b4faa00d1b0      2,428,160 Free
    7bf42e041680     5b4faa00d1b0      4,639,520 Free
    7bf42faad6e8     5b4faa00d1b0      2,527,600 Free
    7bf43057f0c8     5b4faa00d1b0      2,857,968 Free
    7bf431c98d60     5b4faa00d1b0      2,609,160 Free
    7bf4321c2a08     5b4faa00d1b0      4,194,392 Free
    7bf4325e2a78     5b4faa00d1b0      3,153,664 Free
    7bf43a747550     5b4faa00d1b0        662,736 Free
    7bf44c90f528     5b4faa00d1b0     13,684,008 Free
    7bf44da5b780     5b4faa00d1b0      3,578,672 Free
    7bf484525ad8     5b4faa00d1b0        386,352 Free
    7bf4845c8710     5b4faa00d1b0     15,650,872 Free
    7bf485522248     5b4faa00d1b0      2,131,104 Free
    7bf489000028     5b4faa00d1b0      5,767,368 Free
    7bf489597aa8     5b4faa00d1b0      2,568,696 Free
    7bf4b9c00028     5b4faa00d1b0      6,029,488 Free
    7bf4bc000060     5b4faa00d1b0     12,785,496 Free
    7bf4bcc517d0     5b4faa00d1b0    117,106,864 Free
    7bf4c7400028     5b4faa00d1b0      2,854,216 Free
    7bf4c7ae2930     5b4faa00d1b0      2,180,536 Free
    7bf4c7d0d260     5b4faa00d1b0      3,539,256 Free
    7bf4d7000028     5b4faa00d1b0     14,066,712 Free
    7bf4d7e0b350     5b4faa00d1b0        871,840 Free

The biggest object is more than 100MB. Could there be any big objects that were alive of that size? Let’s go with !dumpheap -min 100000000 to see if we find any

!dumpheap -min 100000000
         Address               MT           Size
    7bf4bcc517d0     5b4faa00d1b0    117,106,864 Free
    7bf724c00048     7c34356d7c18  1,073,741,848 

Statistics:
          MT Count     TotalSize Class Name
5b4faa00d1b0     1   117,106,864 Free
7c34356d7c18     1 1,073,741,848 System.String[]
Total 2 objects, 1,190,848,712 bytes

The result turned out to be better than expected. We found two object – one is the biggest free object, and the one one is an array of string with size of almost 1GB. It has 134217728 elements, which is … a lot. (But also a lot of empty elements, more below)

0:000> !dumpobj /d 7bf724c00048
Name:        System.String[]
MethodTable: 00007c34356d7c18
EEClass:     00007c343551bee0
Tracked Type: false
Size:        1073741848(0x40000018) bytes
Array:       Rank 1, Number of elements 134217728, Type CLASS (Print Array)
Fields:
None

Let’s hope this is not a free object waiting to be collected. !gcroot 7bf724c00048 might give us some clue

          -> 7bf42810a4a8     Microsoft.Extensions.Http.DefaultHttpClientFactory 
          -> 7bf429868210     Microsoft.Extensions.DependencyInjection.ServiceLookup.ServiceProviderEngineScope 
          -> 7bf429882b78     System.Collections.Generic.List<System.Object> 
          -> 7bf429cf3280     System.Object[] 
          -> 7bf4299a5550     SomeNamespace.Services.CustomMemoryCache 
          -> 7bf4299a55a8     System.Collections.Generic.List<System.String> 
          -> 7bf724c00048     System.String[] 

A bit of dotPeek reveals the code

public class CustomMemoryCache : MemoryCache, IMemoryCache, IDisposable
  {
    private List<string> _cacheNames = new List<string>();

    public List<string> GetKeys() => this._cacheNames;

    public new ICacheEntry CreateEntry(object key)
    {
      this._cacheNames.Add(key.ToString());
      return base.CreateEntry(key);
    }

As you can see the _cacheNames will add the cache key to a list. Because List<T> is not unique, whenever a new cache item is added, the key will be added to the list (even if it was a duplicate), so it becomes ever growing. In the previous memory dump (that was taken 30 minutes earlier), there were 81928424 entries in the list. As this is strongly referenced, the list will remain forever and only increases over time.

Now the funny part – because the List<T> has a limit of 2GB on the array, but because string is a reference types, each element in the array is simply a reference of it – so 8 bytes per element. You can see that is a lot. Not only that, there are two things to know about List<T>

  • List<T> will create a new array, double in size copy the items over once it’s filed the internal array with items. The old array will become unreachable and ready to be GC’ed the next time. Which is also why we might have a lot of empty items in the list.
  • Once the array grows large enough, they will be created in the Large Object Heap (LOH). The threshold is 85.000 bytes, so around 10k items in the list (because each element is 8 bytes, they do not hold the actual string object, but a reference to it). After that, each time the list is doubled, a new object will be created, and the old list will remain on LOH as a free memory object. They will (almost) never be compacted, unless there was a need to create a big object in which they will be overwritten.

The fix in this case is to simply remove _cacheNames, as it does not serve a purpose. It took a while for the customer to deploy the fix, but when they do, the result was … impressive

Before the fix, the memory was ever increasing until the app needs to be restarted. After that, it remain below 3GB.

Moral of the story

  • Keep looking.
  • If there are a lot of big, free objects, most likely, there are live objects of equal size that can give you some ideas.

Loading sos.dll to Windbg

Windbg is still the golden standard to troubleshoot memory dumps. and sos extension is still the essential tool to help with managed memory dump investigation. With .NET Framework (4.8 or earlier), it could be as easy as .loadby sos clr. But things got a little trickier with .NET Core, as you need to install dotnet-sos for the extension, and that could cause some confusions to get it work.

So, the thing is that you can’t (or rather, can’t stop at) install dotnet-sos like this

dotnet tool install -g dotnet-sos

Which will install it in a weird path like this C:\Users\quma.dotnet\tools.store\dotnet-sos\9.0.607501\dotnet-sos\9.0.607501\tools\net6.0\win-x64

And if you run the command to load it like this

.load C:\Users\vimvq\.dotnet\tools\.store\dotnet-sos\8.0.510501\dotnet-sos\8.0.510501\tools\net6.0\any\win-x64\sos.dll

You can’t run any command that you’re used to, like !dumpheap

0:000> !dumpheap 
Error: Fail to create host ldelegate 80070002
Error: ICLRRuntimeHost::ExecuteInDefaultAppDomain failed 80070002
Unrecognized command 'dumpheap'

Why? Because now sos.dll is simply a loader, the commands like !dumpheap is implemented in a different dll that is not in the same folder with sos.dll. If you open the folder, it looks like this

So you don’t have the assemblies that contain the commands you need.

What you want is to install it with

dotnet-sos install

so it will be installed in a path like C:\Users\quma.dotnet\sos, and the folder should look like this

And then you can run .load C:\Users\quma\.dotnet\sos\sos.dll in WinDBG and everything should run as expected. Note that if you run .load command with wrong path before, you need to restart the debugging process. (Press Stop Debugging and Ctrl+D to reload the memory dump.

Best deal on Amazon.se week 10/2025

Assassins Creed Mirage PS5


Reduced to kr199.00

https://amzn.to/4bo19e2

Hultafors 827023 Crowbar, Red

Reduced to kr67.00

https://amzn.to/3QHb30N

Belkin BoostCharge Wireless Magnetic Car Phone Holder Compatible with iPhone 15 14 13 12 Plus Pro Pro Max Mini and More with MagSafe (Cable and Power Supply Adapter Included)

Reduced to kr96.00

https://amzn.to/4btGTYG

Belkin charger for Apple Watch, MFi certified wireless fast charging travel pad with bedside mode

Reduced to kr126.00

https://amzn.to/41nl1cE