Skip to content

SR-5640: JSONEncoder misrepresents UInt.max #1173

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Aug 30, 2017

Conversation

spevans
Copy link
Contributor

@spevans spevans commented Aug 13, 2017

This is a workaround rather than a full fix. However it should make it easier to create the correct fix.

  • NSNumber(value: UInt.max).stringValue was returning "-1"
    instead of "18446744073709551615" because NSNumber holds any
    value > Int64.max as a 128Bit quantity using a high Int and
    low UInt. It marks this type as an 'SInt128Type'.

  • CFNumberFormatterCreateStringWithNumber() uses CFNumberGetType()
    to get this type however CoreFoundation wants to hide SInt128Type
    (probably for backwards compatibilty) and instead returns it
    as an SInt64Type. Thus only the low part of the NSNumber is used
    for the number and this is interpreted as an Int64.

  • The workaround is simply in NSNumber.description(withLocale:)
    to test to see if the value is of type SInt128Type and if so
    use String(format: "%@") to convert it to a string instead of
    using CFNumberFormatterCreateStringWithNumber(). This should be
    ok for most situations since it is only used for positive
    integers and there are no issues with formatting leading zeros
    or decimal points.

  • For JSONWriter._serializationString(for: NSNumber) the value
    is tested to see if it is an SInt128Type and if so the .stringValue
    method is used to create a string.

@@ -256,6 +256,10 @@ open class NSNumber : NSValue {
fatalError("unsupported CFNumberType: '\(numberType)'")
}
}

public var isSInt128Type: Bool {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can't add new public API unfortunately.

@spevans
Copy link
Contributor Author

spevans commented Aug 14, 2017

Ah, didn't realise that. Ive removed the function and inlined the test instead

func test_stringValue() {
XCTAssertEqual(NSNumber(value: UInt.min).stringValue, "0")
XCTAssertEqual(NSNumber(value: UInt.min + 1).stringValue, "1")
XCTAssertEqual(NSNumber(value: UInt.max).stringValue, "18446744073709551615")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On a 32-bit platform UInt.max is 4294967295 so this will break.

XCTAssertEqual(NSNumber(value: UInt64.max).stringValue, "18446744073709551615")
XCTAssertEqual(NSNumber(value: UInt64.max - 1).stringValue, "18446744073709551614")

XCTAssertEqual(NSNumber(value: Int.min).stringValue, "-9223372036854775808")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment here - Int.min and Int.max are different on 32-bit platforms.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there any easy test for platform wordsize apart from Int.max == Int32.max etc ?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm only aware of testing MemoryLayout<Int>.size == 32, which isn't much better.

@@ -401,6 +401,68 @@ class TestJSONEncoder : XCTestCase {
test_codingOf(value: URL(string: "https://swift.org")!, toAndFrom: "\"https://swift.org\"")
}


// UInt and Int
func test_uintMinMax() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can the naming of this be consistent with the other tests, so test_codingOfUIntMinMax()?

Also we should add this testcase on the Darwin side too: https://github.com/apple/swift/blob/master/test/stdlib/CodableTests.swift

@ianpartridge
Copy link
Contributor

I'm wondering if the upcoming merge of code from Apple might have a better fix for this...

@ianpartridge
Copy link
Contributor

@swift-ci please test

@spevans
Copy link
Contributor Author

spevans commented Aug 14, 2017

I fixed up the test names. I left the CodableTests.swift out for now as I will do a separate JSON PR for some other minor fixes I want to do.

I suspect you are correct about there being a better fix in the 10.13 code dump but won't that happen after 4.0 has been released, i.e. when Xcode9 and High Sierra are publicly available?

@spevans
Copy link
Contributor Author

spevans commented Aug 14, 2017

Could you please retest, I added another commit after you kicked off the last test, thanks.

@ianpartridge
Copy link
Contributor

ianpartridge commented Aug 14, 2017

I think the code drop is due pretty soon: after we stop auto-merging from master to swift-4.0-branch but before Swift 4 ships. It's possible we might be able to extract the full fix (if it exists!) into a PR against swift-4.0-branch, I don't know.

@ianpartridge
Copy link
Contributor

@swift-ci please test

//[SR-2151] https://bugs.swift.org/browse/SR-2151
private mutating func _serializationString(for number: NSNumber) -> String {
if _CFNumberGetType2(number._cfObject) == kCFNumberSInt128Type {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like an encapsulation violation to have JSONSerialization calling this function.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@parkera I agree, but this seems like a reasonable fix for the moment until changes come in from the CF side, no? Or is it possible to get CF changes in for this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the stringValue can be used for any integer value, I could replace it with:

        if !CFNumberIsFloatType(number._cfObject) {
            return number.stringValue
        }

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem I have is the way this ties the implementation detail of how NSNumber stores its value to JSONSerialization - the two shouldn't really be coupled to each other that tightly if we can help it. Maybe the formatter is a better place to encapsulate that...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@parkera So ideally you want all references to _cfObject removed from JSONSerialization?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No - it's the _CFNumberGetType2 that is the problem. _cfObject is just our way to get toll-free bridging to work here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so is !CFNumberIsFloatType(number._cfObject) ok to use instead?

@spevans
Copy link
Contributor Author

spevans commented Aug 14, 2017

I replaced the _CFNumberGetType2(number._cfObject) == kCFNumberSInt128Type

@ianpartridge
Copy link
Contributor

@swift-ci please test

@parkera
Copy link
Contributor

parkera commented Aug 15, 2017

Yes, using the public CFNumber API is much better. Thanks.

@spevans
Copy link
Contributor Author

spevans commented Aug 15, 2017

Looks like the test failed due to a build timeout, Ive just tested it against swift-DEVELOPMENT-SNAPSHOT-2017-08-14 on Xcode and it worked ok

@ianpartridge
Copy link
Contributor

@swift-ci please test

@djones6
Copy link
Contributor

djones6 commented Aug 17, 2017

I think there's a performance problem with the latest change - by routing the stringification of all int types through NSNumber.description, with the exception of the special case for SInt128, every conversion will create a new CFNumberFormatter.

A quick benchmark of JSONEncoder (on Linux) with a struct containing 10 Ints ran 2x slower.
(benchmark: https://github.com/djones6/swift-benchmarks/blob/master/jsonEncoderTest.swift)

@ianpartridge
Copy link
Contributor

I guess we could change NSNumber.description(withLocale:) so it caches a static CFNumberFormatter and only creates a new one if the locale changes?

@spevans
Copy link
Contributor Author

spevans commented Aug 17, 2017

It could just use a fixed one when locale is nil as that is the case we care about.

@spevans
Copy link
Contributor Author

spevans commented Aug 17, 2017

@djones6 I added a cache for the CFNumberFormatter when locale is nil, can you see if that helps with the performance regression?

@ianpartridge
Copy link
Contributor

@swift-ci please test

open func description(withLocale locale: Locale?) -> String {
// CFNumberFormatterCreateStringWithNumber() doesnt like SInt128Type as
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Drive-by nit: can you correct the spelling of "doesn't" and throw in a few punctuation marks (before the first "as" and before "so")?

// so special case them.
if _CFNumberGetType2(_cfObject) == kCFNumberSInt128Type {
return String(format: "%@", unsafeBitCast(_cfObject,
to: UnsafePointer<CFNumber>.self))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Drive-by nit no. 2: can you put this on the same line?

@djones6
Copy link
Contributor

djones6 commented Aug 20, 2017

@spevans Thanks - this does address the performance regression. In fact, it seems to perform slightly better than the original code.

This is a workaround rather than a full fix.

- NSNumber(value: UInt.max).stringValue was returning "-1"
  instead of "18446744073709551615" because NSNumber holds any
  value > Int64.max as a 128Bit quantity using a high Int and
  low UInt. It marks this type as an 'SInt128Type'.

- CFNumberFormatterCreateStringWithNumber() uses CFNumberGetType()
  to get this type however CoreFoundation wants to hide SInt128Type
  (probably for backwards compatibilty) and instead returns it
  as an SInt64Type. Thus only the low part of the NSNumber is used
  for the number and this is interpreted as an Int64.

- The workaround is simply in NSNumber.description(withLocale:)
  to test to see if the value is of type SInt128Type and if so
  use String(format: "%@") to convert it to a string instead of
  using CFNumberFormatterCreateStringWithNumber(). This should be
  ok for most situations since it is only used for positive
  integers and there are no issues with formatting leading zeros
  or decimal points.

- For JSONWriter._serializationString(for: NSNumber) the value
  is tested to see if it is not a floating point value and if so
  the .stringValue method is used to create a string.

- NSNumber.description(withLocale:) - cache the CFNumberFormatter
  when locale is nil as a small speedup.
@spevans
Copy link
Contributor Author

spevans commented Aug 20, 2017

Thanks for running those tests @djones6.

I have squashed and rebased and tidied up the comment so I think this may be ready to go.

@spevans
Copy link
Contributor Author

spevans commented Aug 23, 2017

@swift-ci please test

1 similar comment
@spevans
Copy link
Contributor Author

spevans commented Aug 24, 2017

@swift-ci please test

@ianpartridge
Copy link
Contributor

@swift-ci please test

@ianpartridge
Copy link
Contributor

I think this is good for merge - @spevans @djones6 @itaiferber @parkera?

@spevans
Copy link
Contributor Author

spevans commented Aug 30, 2017

I think it is. Its fixes the NSNumber bug and I will have a follow up PR soon to hopefully speed up some of the integer serialisation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants