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 TimeSpan
s (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 TimeSpanParse. 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 (adapted from the .NET Framework version):
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; } } private static bool TryTimeToTicks(bool positive, TimeSpanToken days, TimeSpanToken hours, TimeSpanToken minutes, TimeSpanToken seconds, TimeSpanToken fraction, out long result) { if (days._num > MaxDays || hours._num > MaxHours || minutes._num > MaxMinutes || seconds._num > MaxSeconds || !fraction.NormalizeAndValidateFraction()) { result = 0; return false; } long ticks = ((long)days._num * 3600 * 24 + (long)hours._num * 3600 + (long)minutes._num * 60 + seconds._num) * 1000; if (ticks > InternalGlobalizationHelper.MaxMilliSeconds || ticks < InternalGlobalizationHelper.MinMilliSeconds) { result = 0; return false; } result = ticks * TimeSpan.TicksPerMillisecond + fraction._num; if (positive && result < 0) { result = 0; return false; } return true; }
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 tests FullHMSMatch
is true, but we fall out after TryTimeToTicks
because our hours (stored in raw._numbers0
) (24) is greater than MaxHours
(23). FullDHMMatch
is false because 24
doesn’t match to days (it isn’t followed by .
in our "24:00:00"
string). That is good.
Now the localized format looks for:
- Day: ‘:’
- Hour: ‘:’
- Minute: ‘:’
- Second: ‘:’
Notice the day separator is different? That is because it is parsed from d':'h':'mm':'ss'.'FFFFFFF
. And there we go! That’s the problem, the localized FullDHMMatch
matches "24:00:00"
as 24 days, 0 hours, & 0 minutes.
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
, andh: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()
Maybe "d':'h':'mm':'ss'" + DecimalSeparator + "'FFFFFFF"
really should have been "d'.'h':'mm':'ss'" + DecimalSeparator + "'FFFFFFF"
this entire time?