TimeSpan.Parse(“24:00:00”) may surprise you!

The other day a dev asked the team I’m on for help with a caching issue they were having. They were putting things into the cache, with an expiration time, but it wasn’t refreshing at the expected time. We recently switched our caching from memcached to Redis so we just assumed this was a bug somewhere in the code. Turns out something more interesting was going on.

The devs can control the cache expiration times from a JSON configuration file. It looks like this (more or less):

[
   {"Type":"CacheItemType1","AbsoluteExpirationDuration":"08:00:00"},
   {"Type":"CacheItemType2","AbsoluteExpirationDuration":"24:00:00"},
   {"Type":"CacheItemType3","AbsoluteExpirationDuration":"12:00:00"},
]

The times in that file eventually end up as TimeSpans (via a Parse call) on a configuration lookup data structure.

Check this out:

Console.WriteLine(TimeSpan.Parse("23:59:59"));
Console.WriteLine(TimeSpan.Parse("24:00:00"));
// Outputs:
//   23:59:59
//   24.00:00:00

Do you see it? TimeSpan.Parse("24:00:00") ends up 24 days. The devs were expecting 24 hours. Yowza! Once we figured this out the devs went and switched them all to 23:59:59 and called it a day. Note you can also do 1.00:00:00 but I couldn’t get any traction on that in our group, for some reason.

Some people have argued that 24:00:00 shouldn’t work. Maybe. This works fine:

Console.WriteLine(TimeSpan.FromHours(24));
// Outputs:
//   1.00:00:00

I think the expectation is that “24 hours” would end up 1 day. “25 hours” would end up 1 day and 1 hour. Anyway, it turns out it doesn’t!

Getting it working is great, but I was kind of curious why this works the way it does, so I went digging through the source.

TimeSpan in dotnet/runtime calls into TimeSpanParser. It has this comment right at the top:

//       1 number  => d
//       2 numbers => h:m
//       3 numbers => h:m:s     | d.h:m   | h:m:.f
//       4 numbers => h:m:s.f   | d.h:m:s | d.h:m:.f
//       5 numbers => d.h:m:s.f

Notice the “3 numbers” case has 3 possible formats? One of them is [day].[hour]:[minute]. If I had to guess, we’re falling into that format for one reason or another.

Scrolling down from there, there’s a lot of code in here! Wow. But don’t worry, I’m not going to give up on you guys. Here’s the money:

bool inv = ((style & TimeSpanStandardStyles.Invariant) != 0);
bool loc = ((style & TimeSpanStandardStyles.Localized) != 0);

bool positive = false, match = false, overflow = false;
var zero = new TimeSpanToken(0);
long ticks = 0;

if (inv)
{
   if (raw.FullHMSMatch(raw.PositiveInvariant))
   {
      positive = true;
      match = TryTimeToTicks(positive, zero, raw._numbers0, raw._numbers1, raw._numbers2, zero, out ticks);
      overflow = overflow || !match;
   }

   if (!match && raw.FullDHMMatch(raw.PositiveInvariant))
   {
      positive = true;
      match = TryTimeToTicks(positive, raw._numbers0, raw._numbers1, raw._numbers2, zero, zero, out ticks);
      overflow = overflow || !match;
   }
}

if (loc)
{
   if (!match && raw.FullHMSMatch(raw.PositiveLocalized))
   {
      positive = true;
      match = TryTimeToTicks(positive, zero, raw._numbers0, raw._numbers1, raw._numbers2, zero, out ticks);
      overflow = overflow || !match;
   }

   if (!match && raw.FullDHMMatch(raw.PositiveLocalized))
   {
      positive = true;
      match = TryTimeToTicks(positive, raw._numbers0, raw._numbers1, raw._numbers2, zero, zero, out ticks);
      overflow = overflow || !match;
   }
}

There is a lot more code and other tests going on in the real file (one more format, negative versions, invariant and localized), but I took them out to keep it simple.

We’re not passing a style so inv and loc are both true, meaning, it’s going to try invariant and localized parsing. Turns out that is key to the problem!

The invariant format looks for:

  • Day: ‘.’
  • Hour: ‘:’
  • Minute: ‘:’
  • Second: ‘:’

For the invariant test FullHMSMatch is true, but we fall out because our hours (stored in raw._numbers0) (24) is greater than MaxHours (23). We don’t match on FullDHMMatch because we don’t have a . in there.

The localized format looks for:

  • Day: ‘:’
  • Hour: ‘:’
  • Minute: ‘:’
  • Second: ‘:’

Day separator is different because it is parsed from d':'h':'mm':'ss'.'FFFFFFF. And there we go! The localized FullDHMMatch matches where the invariant one did not and we end up with 24 days.

I’m going to call this a bug. For the following reasons, but I wouldn’t fault you for arguing otherwise.

  • The comment indicates the intention was to support h:m:s, d.h:m, and h:m:.f but that isn’t what we got.
  • The below h:m format throws an exception. I think most people would expect it to throw in both cases, if what was supplied turned out to be invalid.
Console.WriteLine(TimeSpan.Parse("23:59"));
Console.WriteLine(TimeSpan.Parse("24:00"));
// Outputs
//   23:59:00
Run-time exception (line -1): The TimeSpan could not be parsed because at least one of the numeric components is out of range or contains too many digits.

Stack Trace:

[System.OverflowException: The TimeSpan could not be parsed because at least one of the numeric components is out of range or contains too many digits.]
   at System.Globalization.TimeSpanParse.ProcessTerminal_HM(TimeSpanRawInfo& raw, TimeSpanStandardStyles style, TimeSpanResult& result)
   at System.Globalization.TimeSpanParse.ProcessTerminalState(TimeSpanRawInfo& raw, TimeSpanStandardStyles style, TimeSpanResult& result)
   at System.Globalization.TimeSpanParse.TryParseTimeSpan(String input, TimeSpanStandardStyles style, IFormatProvider formatProvider, TimeSpanResult& result)
   at System.Globalization.TimeSpanParse.Parse(String input, IFormatProvider formatProvider)
   at System.TimeSpan.Parse(String s)
   at Program.Main()

Leave a Reply

Your email address will not be published. Required fields are marked *

This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.