Writing

Software, technology, sysadmin war stories, and more. Feed
Sunday, March 17, 2013

Time handling is garbage

This is why programmers have grey hair. Dealing with time sucks.

I have a "simple" task: take a time string and turn it back into a time_t value. The input string is unambiguous:

Tue, 01 Nov 2011 10:54:00 -0700

Likewise, the format string is also unambiguous:

%a, %d %b %Y %H:%M:%S %z

I have three different machines here: a Mac running Mountain Lion (10.8.2), a Linux box running Slackware64 13.37, and an OpenBSD 5.2 (amd64) box. (I actually have a RHEL 5 box handy too, but since it yields the same results as the Slackware machine, it's not being enumerated separately).

Using strptime() with that time input and that format string yields a "struct tm", and it's populated like this:

obsd slack mac
tm_year 111 111 111
tm_mon 10 10 10
tm_mday 1 1 1
tm_hour 10 10 10
tm_min 54 54 54
tm_sec 0 0 0
tm_wday 2 2 2
tm_yday 304 304 304
tm_isdst 0 0 1
tm_gmtoff 0 -25200 -25200
tm_zone (null) (null) PDT

The first part is totally fine, but it goes off the rails when it comes time to figure out the offset from GMT. The "-0700" indicates seven hours west of GMT, or basically, my local time zone in the summer.

The Slackware box and my Macs pick up on this and populate the "tm_gmtoff" field with the offset as a number of seconds. The Mac goes one step further and takes a guess at the time zone using its local name: "PDT".

The problem is that my OpenBSD machine punts on this entirely. It doesn't do anything with the "%z" in there, apparently, even though the return value from strptime() indicated that it processed the entire string.

It gets better. If you take this "struct tm" as supplied by strptime(), you might think you could hand it to mktime() to get a nice time_t value. According to the man page, it takes a pointer to a struct tm (this should worry you, since it's not a const pointer, but just a plain old pointer - more about that later), and it returns a time_t.

Okay, so, let's call mktime() and see what we get.

obsd slack mac
mktime 1320173640 1320173640 1320170040

Before we continue, for reference purposes, here's that input time again:

Tue, 01 Nov 2011 10:54:00 -0700

I'll translate that to UTC because it comes in handy later:

$ date -d "Tue, 01 Nov 2011 10:54:00 -0700" -u
Tue Nov  1 17:54:00 UTC 2011

10:54:00 -0700 (PDT) turns into 17:54:00 +0000 (UTC). This makes sense.

That time, no matter whether it's PDT/-0700 or UTC/+0000, is 1320170040. That's the only way to express it as a time_t.

Now let's look at the results.

OpenBSD and Slackware gave me 1320173640, which is 3600 seconds (one hour) too high, and translates to 18:54:00 UTC. The Mac gets the expected value of 1320170040. A one hour difference says "DST" to me.

The Linux man page for mktime() says that a positive value in tm.tm_isdst means DST is in effect, while zero means it is not. A negative value means it should use the time zone info and system databases to try to figure out whether it's active or not.

OpenBSD says the same thing: positive is in effect, zero is not. Negative values make it try to "divine whether summer time is in effect", and "it may give a different answer when later presented with the same argument". Fun!

There's something interesting in the OpenBSD manual, though:

timelocal() is a deprecated interface that is equivalent to calling mktime() with a negative value for tm_isdst.

Linux mentions timelocal() is nonstandard and should be avoided. However, it also says this:

The timelocal() function is equivalent to the POSIX standard function mktime(3). There is no reason to ever use it.

It curiously neglects the "... with a negative value". In any case, it's nonstandard and deprecated, so I won't be using it.

One thing I can do is make a copy of the "struct tm" and whack tm_isdst down to -1 before handing it to mktime(). I'll let it use its local ruleset to see what happens.

Now the results seem to agree.

obsd slack mac
mktime with tm_isdst hack 1320170040 1320170040 1320170040

There's an interesting bit of code in the Linux timelocal/timegm man page. It says that you can get a portable version of timegm by just clearing the TZ environment variable (!) before calling mktime. Yes, really.

So, okay, I make yet another copy of my clean struct tm and do the "clear-the-TZ" craziness before calling mktime. This is what happens:

obsd slack mac
mktime with TZ hack 1320144840 1320144840 -1

OpenBSD and the Slackware box agree, and give a value which is 25200 seconds ahead -- that's 7 hours. Basically, they interpret it as if it was 10:54:00 UTC instead of 10:54:00 PDT.

At the same time, the Mac punts and fails, and I get a -1 back.

It turns out that if you also whack tm_isdst to -1 at the same time when doing this TZ hack then the Mac plays along and gives a result.

obsd slack mac
mktime with TZ hack and isdst hack 1320144840 1320144840 1320144840

It's still the wrong value, of course, but hey, notice how it's exactly offset by the tm_gmtoff value! You might be tempted to do something like this:

time_t t = mktime(&tm);
time_t actual_time = t - tm.tm_gmtoff;

t (from mktime) is 1320144840, tm_gmtoff is -25200, so 1320144840 - (-25200) is 1320170040, and that's the value we want.

But... beware of this technique. Remember earlier when I said it takes a non-const pointer to your struct tm? mktime can and will modify the struct. One of the changes I've noticed is that sometimes it will zero out the tm_gmtoff field.

If you just rely on that value being there after a call to mktime() you may be surprised. I can't actually trigger this at the moment, but I know I tripped over it earlier while fighting with this problem. If you intend to do something like this later, you have to make a copy of that value separately before the mktime() call.

That's pretty much the situation here. You start down this dusty desert road of trying to figure out something which works everywhere, and before long you're up to your armpits in rattlesnakes.

I'm sure I've missed quite a few things here, and I'm guessing that upon saving and publishing this post, I will return to my code only to trip over yet another problem from another format or another Unix system.

Snakes. Why did it have to be snakes?


June 11, 2013: This post has an update.