Skip to content

Classify leaves with operator<=> itself past the nesting bound - #5686

Merged
nlohmann merged 2 commits into
developfrom
claude/spaceship-binary-subtype-depth-5654
Oct 4, 2026
Merged

nlohmann merged 2 commits into
developfrom
claude/spaceship-binary-subtype-depth-5654

Conversation

@nlohmann

@nlohmann nlohmann commented Sep 29, 2026 •

Copy link
Copy Markdown
Owner

Summary

In C++20, operator<=> (and the <, <=, >, >= derived from it) could give a different
result for the same two values depending on how deeply they are nested. This happens when the
values contain binary values with the same bytes but a different subtype: operator== compares
the subtype and reports them unequal, but operator<=> compares only the bytes (through
std::vector<std::uint8_t>::operator<=>) and reports them equivalent. Within the first 128
levels, an array or object compares itself with operator<=> and lets the next element decide.
Past that bound, compare_iteratively<true>() classified a leaf pair by asking == first and
then </> (both derived from <=>), so the pair ended the comparison as unordered instead -
and at every depth with JSON_NO_THREAD_LOCAL defined. This is a regression from #5390.

Changes

  • compare_leaves() in include/nlohmann/json.hpp now classifies a leaf pair for an ordered
    C++20 comparison with operator<=> itself, matching how a value within the nesting bound is
    compared. The equality-only and pre-C++20 ordered cases are unchanged.
  • Which of the three implementations runs is chosen by overloading on
    std::integral_constant<bool, Ordered>, the tag dispatch order_leaves() already uses,
    rather than a runtime if (Ordered) on a template parameter, which MSVC flags as a constant
    condition (C4127).
  • single_include/nlohmann/json.hpp regenerated with make amalgamate.

Tests

Added a regression test to tests/src/unit-comparison.cpp (C++20 only) that nests a pair of
arrays whose first elements are binary values with the same bytes but a different subtype, 0,
127, 128 and 200 levels deep (127 stays within the 128-level nesting bound, 128 and 200 do not),
and checks that operator<=> and operator< agree at every depth.

  • Confirmed the new test fails without the fix: built against develop's headers (633de8e),
    it fails at 128 and 200 levels exactly as the issue describes, and passes at 0 and 127.
  • Passes with the fix, for C++17 and C++20, with and without -DJSON_NO_THREAD_LOCAL, under
    -fsanitize=address,undefined -fno-sanitize-recover=undefined with -Wall -Wextra -Werror.

Public API

No breaking changes. compare_leaves() is a private implementation detail; the observable
change is that operator<=>'s result (and the operators derived from it) for values nested past
128 levels now matches the documented and already-intended behavior of comparing "using their
respective operator<=>" at every depth.

Fixes #5654


This PR was written by Claude Code on behalf of @nlohmann.

🤖 Generated with Claude Code

In C++20, an ordered comparison past the nesting bound classified a pair of
leaves by asking == first and then order_leaves(), which calls < and > -
both derived from <=>. For a pair of binary values with the same bytes but a
different subtype, == reports them unequal, while <=> (through
std::vector<std::uint8_t>::operator<=>) reports them equivalent, so the pair
ended the comparison as unordered instead of letting the next element
decide - unlike an array or object within the bound, which compares such a
pair with its own operator<=> and gets equivalent. So operator<=>, and the
<, <=, >, >= derived from it, could give a different result for the same two
values depending on how deeply the values were nested, or unordered at every
depth with JSON_NO_THREAD_LOCAL defined.

compare_leaves() now classifies such a pair in C++20 with operator<=> itself
instead, matching how a value within the bound is compared; the equality-only
and pre-C++20 ordered cases are unchanged. Which of the three runs is chosen
by overloading on std::integral_constant<bool, Ordered>, the same tag
dispatch order_leaves() already uses, rather than a runtime "if (Ordered)" on
a template parameter, which MSVC would flag as a constant condition (C4127).

Added a regression test to unit-comparison.cpp that nests such a pair 0, 127,
128 and 200 levels deep (127 stays within the 128-level bound, 128 and 200
do not) and checks that operator<=> and operator< agree at every depth.

Fixes #5654.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
@nlohmann nlohmann added the review needed It would be great if someone could review the proposed changes. label Sep 29, 2026
## Version history

1. Added in version 3.11.0.
1. Added in version 3.11.0. Fixed in version 3.13.0 to keep the result independent of nesting depth: before, a pair

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Did this bug exist in 3.11.0 or was it just added in the current work?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

It was introduced in the current work (#5390, not in any release), so there is nothing to note in the version history. Removed in 8ab6355.

(Reply written by Claude Code on behalf of @nlohmann.)

The regression came from #5390, which is not in any release. Addresses review comment by @gregmarr.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
@nlohmann nlohmann added 🚀 ready to merge Ready to merge - just waiting for CI to complete. and removed review needed It would be great if someone could review the proposed changes. labels Oct 4, 2026
@nlohmann nlohmann added this to the Release 3.13.0 milestone Oct 4, 2026
@nlohmann
nlohmann merged commit 0d01d6a into develop Oct 4, 2026
146 of 162 checks passed
@nlohmann
nlohmann deleted the claude/spaceship-binary-subtype-depth-5654 branch October 4, 2026 09:46
nlohmann added a commit that referenced this pull request Oct 4, 2026
- binary_reader: rename the error_handler constructor parameter, which
  shadowed the member (-Wshadow, -Wshadow-field-in-constructor; #5746)
- basic_json(copy_construct_tag, ...): declare it noexcept when copying
  the base class is (GCC 16 -Wnoexcept; #5690)
- the scalar-on-left legacy comparison operators: noexcept only when
  converting the scalar is, like their member counterparts (#5682, #5751)
- compare_leaves: use std::is_eq/is_lt/is_gt instead of comparing a
  std::partial_ordering with 0 (-Wzero-as-null-pointer-constant; #5686)
- serializer: silence MSVC C4127 for the EnsureAscii template parameter
  (#5741, #5746)
- clang-tidy: return the sanitized reference in binary_writer, take the
  key of ordered_map::find_impl by const reference (#5727), and mark the
  switches over parse_array_index (#5728)
- ordered_map: keep <memory> for std::allocator (IWYU)

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
nlohmann added a commit that referenced this pull request Oct 4, 2026
* Keep the serializer conversion for objects whose keys cannot be converted

#5591 added a test converting nlohmann::json into a basic_json whose
string type cannot be constructed from std::string. That instantiates
convert_iteratively(), whose members.emplace_back(next.key(), ...) needs
exactly that key conversion, and broke the build of unit-alt-string.
Dispatch on the key's constructibility and leave such conversions to the
serializers, as the levels above the nesting bound already do (#3425).

Signed-off-by: Niels Lohmann <mail@nlohmann.me>

* Fix the remaining CI failures on develop

- unit-wstring: with a 16-bit wchar_t (Windows), a lone surrogate is
  reported as the ill-formed byte 0xFF since #5704; the std::wstring
  expectations still had the previous <U+0000>.
- ci_single_binaries: json_literals.hpp (#5610) and json.hpp include each
  other on purpose, and IWYU, not following the cycle, asks to replace
  json.hpp with json_fwd.hpp. Report its findings without failing the
  build, as already done for json.hpp.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>

* Fix the library warnings and noexcept specifications from the merged PRs

- binary_reader: rename the error_handler constructor parameter, which
  shadowed the member (-Wshadow, -Wshadow-field-in-constructor; #5746)
- basic_json(copy_construct_tag, ...): declare it noexcept when copying
  the base class is (GCC 16 -Wnoexcept; #5690)
- the scalar-on-left legacy comparison operators: noexcept only when
  converting the scalar is, like their member counterparts (#5682, #5751)
- compare_leaves: use std::is_eq/is_lt/is_gt instead of comparing a
  std::partial_ordering with 0 (-Wzero-as-null-pointer-constant; #5686)
- serializer: silence MSVC C4127 for the EnsureAscii template parameter
  (#5741, #5746)
- clang-tidy: return the sanitized reference in binary_writer, take the
  key of ordered_map::find_impl by const reference (#5727), and mark the
  switches over parse_array_index (#5728)
- ordered_map: keep <memory> for std::allocator (IWYU)

Signed-off-by: Niels Lohmann <mail@nlohmann.me>

* Split unit-conversions.cpp so MinGW can link it

clang 18 with the MinGW linker failed to link test-conversions_cpp17
("relocation truncated to fit: IMAGE_REL_AMD64_REL32"). As windows.yml
recommends, keep the objects small by splitting the test file.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>

* Fix the tests added by the merged PRs for all CI configurations

- discard the results of dump() and from_*() in CHECK_THROWS with
  utils::ignore_return_value (GCC -Werror=unused-result)
- give unit-bson's huge_string_t a default constructor (MSVC C2512,
  GCC 5, clang 3.5)
- unit-disabled_exceptions: use the literals namespace when the global
  UDLs are off (ci_test_noglobaludls; #5700)
- unit-binary_utf8_strict: expect the JSON pointer prefix with
  JSON_DIAGNOSTICS (#5741)
- skip the tests that rely on exceptions under JSON_NOEXCEPTION
  (#5678, #5732)
- clang-tidy and clang -Werror: static test data, CAPTURE(...);,
  const-correctness, use-after-move alias, unused conversion operator,
  a missing <iterator> include

Signed-off-by: Niels Lohmann <mail@nlohmann.me>

* Title the macro examples and add JSON_STRICT_BINARY_UTF8 to the docset

The documentation style check requires "Example: ..." titles on pages with several examples (#5741, #5591) and a docset entry for every macro page.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>

* Regenerate BUILD.bazel and nlohmann_json.natvis

Signed-off-by: Niels Lohmann <mail@nlohmann.me>

#5746 added detail/output/error_handler.hpp and #5741 the json_abi_sbu8 ABI tag.

* Install libidn11 for the CMake 3.5.0 binary in ci_cmake_flags

Signed-off-by: Niels Lohmann <mail@nlohmann.me>

#5733 moved ci_cmake_options from ubuntu:focal to ubuntu:24.04, which no longer ships libidn.so.11; the CMake 3.5.0 release binary links against it, so every ci_cmake_flags run has failed since. Install focal's libidn11 package for that matrix entry only.

* Suppress Infer's false STACK_VARIABLE_ADDRESS_ESCAPE in get_impl

get_impl() returns its local by value. A test added by the merged PRs instantiates it with a type Infer misreads, so ci_infer reported the 2021 code for the first time.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>

---------

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

L 🚀 ready to merge Ready to merge - just waiting for CI to complete. tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

C++20 operator<=> result depends on nesting depth when binary values differ only in subtype

2 participants