Unfathomable Bugs #6: Pretend Precision

posted by Craig Gidney on June 18, 2013

Today’s bug comes courtesy of Apple. Thank you, Apple, this series wouldn’t exist without the generous support of entities like you.

It’s Friday evening. Me and my coworker Jazz are sitting at my desk. Our secure telephony client written in Objective-C is failing to initiate calls and we don’t know why. We catch some minor things, until Jazz notices a specific reproducible problem: the client is sending a JSON-encoded session id that’s slightly off. It’s almost correct, except for the last few digits.

I figure the bug has to be in the JSON serialization code. It doesn’t matter that I’ve previously stepped over the serialization code line by line in the debugger, and confirmed its correctness by checking the raw values against the corresponding JSON. It doesn’t matter that we’re not actually doing anything fancy, just calling the built-in JSON methods. There’s simply nothing else that touches the session id: we never generate ids client side, and we never do arithmetic on them.

I must be missing something stupid.

Serial Confusion

I decide to start from the simplest possible parsing code that works, using only actual data received from the server, and then slowly add details until the bug appears. This is a good approach to take when a bug renews your fear that computer gnomes might really exist.

I make a tiny test that simply has to work:

NSString* json = @"{\"sessionId\":6032314514195021674}";
    
NSData* jsonData = [json dataUsingEncoding:NSUTF8StringEncoding];
NSError* err;
NSDictionary* obj = [NSJSONSerialization JSONObjectWithData:jsonData options:0 error:&err];
    
NSNumber* sessionId = [obj objectForKey:@"sessionId"];
test([sessionId isEqualToNumber:@6032314514195021674]);
PASS

See? It works. The parsed session id equals the correct value. Good. It would have been really inconvenient if that test failed.

I need to add a tiny detail now. We actually store the int64_t value of the NSNumber instead of the NSNumber itself. That value is accessed by using ‘longLongValue’.

I test that longLongValue also returns the right value:

...

test([sessionId isEqualToNumber:@6032314514195021674]);
PASS
test([sessionId longLongValue] == 6032314514195021674);
FAIL: 6032314514195021824 != 6032314514195021674

Wait… what? The wrong long long came out.

Long longs are guaranteed to have at least 64 bits, and this particular session id should fit into a 64 bit integer, so there should be no rounding or other tom-foolery. Maybe there’s some issue with the longLongValue method, or storing such large values in an NSNumber?

I test if an NSNumber literal has the same issue:

...

test([sessionId isEqualToNumber:@6032314514195021674]);
PASS
test([sessionId longLongValue] == 6032314514195021674);
FAIL: 6032314514195021824 != 6032314514195021674
test([@6032314514195021674 longLongValue] == 6032314514195021674);
PASS

WHAT? The long long I pulled out of the parsed NSNumber is not equal to the value I pulled out of an equal literal NSNumber.

I’m really confused. Equal things are supposed to give equal results when run through a function. That’s like… a fundamental part of being equal.

Maybe I’m supposed to be accessing this value in a different way? I start trying things. Using unsignedLongLongValue… same problem. Using decimalValue… error? Weird. Using description…:

...

test([sessionId isEqualToNumber:@6032314514195021674]);
PASS
test([sessionId longLongValue] == 6032314514195021674);
FAIL: 6032314514195021824 != 6032314514195021674
test([@6032314514195021674 longLongValue] == 6032314514195021674);
PASS
test([[sessionId description] longLongValue] == 6032314514195021674);
PASS

YOU HAVE GOT TO BE KIDDING. The description of the session id has more precision, with respect to the result of longLongValue, than the session id itself!

This is pretty weird, but at least it explains why I thought the code was correct when I stepped through it. When I logged values or looked at them in the debugger, I was seeing their descriptions. I assumed that the descriptions accurately reflected the values. Apparently that was a bad idea. Still… what’s going on under the hood, to make all of this happen?

Seeing Double

If I was coding in Javascript (the language JSON is derived from), the reason for the last few digits changing would be obvious: all numbers are doubles in JavaScript. Doubles can’t represent all 64 bit integers. My test session id (6032314514195021674) is one of those unrepresentable-as-double integers. Casting it to a double and back rounds it to, you guessed it, the extracted long long value (6032314514195021824).

Of course, I’m coding in Objective-C, not JavaScript. I am parsing data in JavaScript Object Notation, but the RFC that describes the JSON format makes no mention of interpreting numbers as doubles. The closest thing I found is in Section 4: “An implementation may set limits on the range of numbers.”. Whoever implemented the JSON parsing in Objective-C must have chosen to save time by parsing all numbers as if they were doubles, even if a particular value couldn’t be represented as a double but could be represented by another common numeric type in Objective-C.

The decision to treat all numbers as doubles is questionable, and inconvenient for me since the python and Java code I’m supposed to be interacting with both handle 64-bit numbers in JSON just fine, but it’s not wrong.

What is wrong is saying the two NSNumbers are equal when they don’t contain the same value.

Questionable Equality

Remember from earlier, that the parsed session id was considered equal to an NSNumber literal that ended up having a different longLongValue. My best guess as to why this happens is that a comparison between an NSNumber containing a long long and an NSNumber containing a double just secretly throws away precision on the long long before doing the comparison, by casting to double. I decided to test this, to be sure:


NSNumber* d = @6032314514195021674.0; // double
NSNumber* n = @6032314514195021674; // long long

test([d isEqualToNumber:n]);
PASS
test([d longLongValue] == [n longLongValue]);
FAIL: 6032314514195021824 != 6032314514195021674
test([[d description] isEqualToString:[n description]]);
FAIL: "6.032314514195022e+18" != "6032314514195021674"

Ugh.

Throwing away precision before checking for equality is a highly questionable decision on Apple’s part. It’s as if 0.3 == 0 evaluated to true because (int)0.3 == (int)0. Comparing a float to an integer might be hard to do correctly, but that doesn’t mean you can just pretend a long long will fit into a double!

Side note: In many languages, the expression 6032314514195021672 == 6032314514195021674.0 will return true, because 64-bit integers are automatically promoted to doubles. I think that implicitly promoting 64-bit integers to doubles is a mistake (because it loses precision), but it’s worse with NSNumber because the types aren’t available. A static verifier can detect comparing an int64_t to a double very easily, but if you tried to do the same thing with NSNumbers you’d be swamped by false positives because it’s difficult to prove that an NSNumber can’t be a double or an int64_t.

Moving on from the throwing-away-precision-when-comparing issue, there’s one interesting difference highlighted by the above test. The NSNumber created from the double literal has a description whose precision matches its value. That contradicts what I saw with the NSNumber parsed from JSON.

Other Numbers

It’s important to understand that objective C has interfaces, instead of classes. Even basic types like NSNumber may have multiple implementations. There might be an NSNumber for doubles, an NSNumber for ints, and maybe lots of other specialized ones. Not that you should have to care, because they all conveniently expose the same interface: NSNumber.

With that in mind, I think the NSNumber returned from the JSON parser is a specialized NSNumber. One modified to keep a copy of the JSON it was parsed from as its description. This would be useful for uses cases like modifying JSON without losing information in the parts you didn’t touch.

Putting it all together, I have a plausible/questionable reason for each part to work the way it does and I understand how they combined to trick me for so long:

  • Numbers parsed from JSON are all treated as having double-precision; probably because doing that saved on expensive development time.
  • Numbers parsed from JSON use the text they were parsed from as their description even if that description has extra precision; probably to allow better round-tripping of data.
  • NSNumbers that contain long longs have their values rounded when being compared to NSNumbers containing doubles; probably because it was easier than doing it correctly, and that’s what == does in C.
  • The above three combined mean a number parsed from JSON will acts as if it has full precision, when printed out or compared to the expected value, but it really doesn’t.

Tricky. Complicated tricky.

Summary

Objective-C lies about how much precision NSNumbers parsed from JSON have. If you print one out, you see the exact integer value that was parsed. If you compare one to an NSNumber containing the expected value, they’re equal. If you actually try to access the integer value, it’s been rounded to the nearest representable double.

You can work around the issue by using the numeric value of the description:

// broken:
int64_t roundedValue = [lyingValueParsedFromJson longLongValue]
// "fixed":
int64_t actualValue = [[lyingValueParsedFromJson description] longLongValue]

Make sure you leave a comment explaining that workaround, or maintainers will face-palm for the wrong reason.

Discuss on Reddit


Twisted Oak Studios offers consulting and development on high-tech interactive projects. Check out our portfolio, or Give us a shout if you have anything you think some really rad engineers should help you with.

Archive

More interesting posts (11 of 18 articles)

Or check out our Portfolio.