Skip to content

Convert jsonpull to C++ with shared_ptr and std::vector/std::string - #388

Open
e-n-f wants to merge 13 commits into
jsonpull-renamefrom
jsonpull-cpp
Open

Convert jsonpull to C++ with shared_ptr and std::vector/std::string#388
e-n-f wants to merge 13 commits into
jsonpull-renamefrom
jsonpull-cpp

Conversation

@e-n-f

@e-n-f e-n-f commented May 30, 2026

Copy link
Copy Markdown
Collaborator

Modernize jsonpull: C++ rewrite with unique_ptr ownership

Summary

Replaces the hand-rolled malloc / realloc / free memory management
in jsonpull with a modern C++ object model: smart-pointer ownership,
std::vector for arrays and hash entries, std::string for string
values, and a small inheritance hierarchy for the value-bearing types.

The motivation was to make jsonpull less of a footgun (no more
json_free mistakes, no more reading o->value.string.string for a
node that turned out to be JSON_HASH) without giving up the ability
to inspect a partial tree, splice out subtrees, or hand a tree off to
outlive its parser. After several rounds of iteration, the final
shape is also a touch faster than the original C code on a real
workload.

What changed

Memory model

  • json_object_ptr is now a std::unique_ptr<json_object, json_object_deleter>
    where json_object_deleter is a stateless deleter that dispatches on
    the discriminator and static_casts to the right subclass before
    delete. No virtual destructor, no per-node vptr; subclass dtors
    still run, so the std::vector / std::string members get freed.
  • Every container owns its children through std::vector<json_object_ptr>
    (arrays) or std::vector<json_entry> (hashes, where json_entry is
    a {key, value} aggregate that preserves insertion order).
  • The parser keeps a unique_ptr to the most recent top-level value in
    jp->root; json_read_tree / json_disconnect are the supported
    ways to move ownership out of the parser.
  • Strings are std::string. Numbers store one of three representations
    (double, unsigned long long, long long) in a discriminated union.

Type-tagged subclasses

The original C json_object used a union for its value payload,
which kept primitive nodes small. The first cut of the C++ port
couldn't reuse that union (the new members -- std::string,
std::vector<json_object_ptr>, etc. -- are non-trivial and can't sit
in an untagged union), so it widened every node to one struct
containing all possible fields. That ballooned the per-node cost to
168 bytes, even for JSON_NULL / JSON_TRUE / JSON_FALSE.

Splitting json_object into a base class plus json_number /
json_string / json_array / json_hash subclasses recovers most
of what the union was buying in C, plus a bit more via hoisting
parser-only expect state out of the data model and packing
json_number's representation into a discriminated union:

Type Size
json_object (TRUE/FALSE/NULL) 24 bytes
json_number 32 bytes
json_string 48 bytes
json_array 48 bytes
json_hash 48 bytes

Callers reach the typed payload through asserting accessors:
o->string(), o->number(), o->array(), o->entries(), etc.
The accessors assert the type matches before downcasting, so the
class of bug where a JSON_HASH is read as a string surfaces
immediately at debug time rather than as a silent garbage read.

Public API

Ownership is now explicit in the type signature:

Function Returns Meaning
json_read / json_read_separators json_object * Borrowed view into the parser-owned tree
json_hash_get json_object * Borrowed view into the containing hash
json_read_tree json_object_ptr Caller takes ownership; back-pointers cleared
json_disconnect json_object_ptr Splice subtree out and hand it to caller
json_free(json_object *) void Destroy subtree, removing it from its owner
json_stringify(const json_object *) std::string Serialize to a string

json_read still returns each parser token as the tree is being built
up (so callers can keep their existing "watch for a JSON_HASH whose
type field is "Feature"" pattern), and json_read_tree now clears
parent / parser back-pointers on the way out so detached trees can
outlive the json_pull they came from.

Per-call-site changes

jsonpull is used in a lot of places, so the migration is broad
(24 files, ~1800 added / ~560 deleted), but mechanical:

  • Streaming parsers (geojson-loop.cpp, read_json.cpp::parse_layers,
    plugin.cpp::parse_feature) iterate one feature at a time, hand it
    to add_feature / build a serial_feature, then call json_free(j)
    to release it before the next iteration so already-serialized
    features don't pile up in memory.
  • Filter loaders (evaluator.cpp::read_filter / parse_filter,
    main.cpp) hold filters as json_object_ptr; everywhere downstream
    that just needs to evaluate the filter takes a borrowed
    json_object *.
  • Tree-mutating consumers (jsontool.cpp::join_csv) use
    json_read_tree to take ownership and then mutate the resulting
    tree directly through the new subclass accessors.
  • Ephemeral-tree consumers (pmtiles_file.cpp, geobuf.cpp,
    dirtiles.cpp, attribute.cpp) just read attribute hashes through
    the accessors; no ownership transfer needed.

Bug fixes flagged in code review

Bundled in as part of the port:

  • jsontool.cpp::out: route non-string values through
    json_stringify instead of o->string(), which now asserts on a
    non-string type. Previously --extract would crash on numeric
    attributes.
  • geojson.{hpp,cpp}::json_end_map: take json_pull_ptr & so the
    caller's shared_ptr is actually released, null-check before
    touching jp->source, and clear jp->source after delete.
  • jsonpull.cpp string parser: the low-surrogate range check was
    comparing the outer-loop byte c instead of the parsed code unit
    ch, breaking decoding when a high surrogate was followed by a
    non-low-surrogate BMP code point. Covered by a new regression test
    ("\uD83D\uE000" used to combine into U+1F400; now correctly emits
    the stale high surrogate as standalone CESU-8 then U+E000 normally).
  • tile-join.cpp::handle_vector_layers: require JSON_STRING (and a
    non-null key) before calling string() so a non-string value
    doesn't trip an assert.

Performance

The first cut of the port (with shared_ptr ownership and per-token
std::string construction) was a real regression. Subsequent
commits brought it back:

Build Median of 5 runs Δ vs main
main (pre-refactor C) 8.69 s --
C++ port, shared_ptr 10.57 s +22 %
shared_ptr + cheap perf wins 9.33 s +7 %
unique_ptr (this PR's tip) 8.52 s -2 %

(time ./tippecanoe -z0 -f -o /tmp/foo.mbtiles --extend-zooms-if-still-dropping tl_2022_us_county.json, Apple Silicon, 238 MB input.)

The shared_ptrunique_ptr switch alone was worth the largest
chunk; the rest came from pre-reserving small std::vectors, reusing
a parser-wide std::string buffer for string tokens to skip
SSO-promotion / capacity-doubling on each token, std::moveing
container pointers into and out of the parser's frame stack, and
packing json_number's representation into a single 8-byte slot via
the discriminator.

Tests

  • New unit tests in unit.cpp:
    • Surrogate-pair regression ("\uD83D\uE000").
    • json_free pruning: parsing "[[1, 2], [3, 4], [5, 6]]" token by
      token, calling json_free on the [3, 4] node, and asserting the
      outer array ends up with [1, 2] and [5, 6].
    • json_free of the parser's current root really destroys the tree
      (verified by re-reading and checking jp->root is empty).
  • Full make test passes (10488 assertions across 9 test cases) plus
    the entire tests/ integration suite, including the prefilter test
    (tests/ne_110m_admin_0_countries/out/--coalesce_-z2_-Ccat.json.check)
    that turned out to be a useful canary for "we're freeing partial
    parser state too early."

Commit-by-commit

  1. Convert jsonpull to C++ with shared_ptr and std::vector /
    std::string.
  2. Subclass json_object so primitives shrink from 168 to 24 bytes.
  3. Store hash key/value pairs in one ordered vector.
  4. Move parser-only expect state out of json_object.
  5. Discriminate json_number's three numeric slots into one union.
  6. Fix bugs flagged in code review of the C++ port.
  7. Add jsonpull regression test for surrogate-pair decoding.
  8. Cheap perf wins in jsonpull C++ port.
  9. Make json_free actually free the subtree.
  10. Migrate jsonpull to unique_ptr ownership.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR migrates the in-repo jsonpull JSON parser from C/manual allocation to C++ using std::shared_ptr ownership and STL containers, and updates the rest of the codebase to use the new json_object_ptr / json_pull_ptr APIs.

Changes:

  • Replaces jsonpull.c with jsonpull.cpp and updates jsonpull.h to expose C++ types (std::shared_ptr, std::vector, std::string) and accessors.
  • Updates GeoJSON parsing/serialization, filtering/evaluation, and metadata handling to use json_object_ptr and json_stringify() returning std::string.
  • Propagates new pointer types through tile generation/join/overzoom tooling and related utilities.

Reviewed changes

Copilot reviewed 26 out of 26 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
tile.hpp Updates traverse_zooms() filter parameter to json_object_ptr.
tile.cpp Updates filter/parser pointer types (json_object_ptr, json_pull_ptr) through tiling pipeline.
tile-join.cpp Updates filter plumbing and JSON traversal to json_object_ptr and STL-backed accessors.
read_json.hpp Updates function signatures to accept json_object_ptr / json_pull_ptr.
read_json.cpp Switches JSON stringify usage to std::string and updates JSON navigation to new accessors.
pmtiles_file.cpp Updates metadata JSON parsing/serialization to new JSON APIs and string return types.
plugin.hpp Updates parse_feature() signature to accept json_pull_ptr.
plugin.cpp Updates filter output parsing to json_object_ptr / json_pull_ptr and removes manual frees.
overzoom.cpp Updates filter pointer type to json_object_ptr.
Makefile Updates indent target to format jsonpull/jsonpull.cpp instead of jsonpull/*.[ch].
main.cpp Updates stream JSON begin API and input parsing call sites to json_pull_ptr / json_object_ptr.
jsontool.cpp Updates JSON plumbing to shared_ptr, STL, and json_stringify() returning std::string.
jsonpull/jsonpull.h Introduces C++ shared_ptr-based API, STL-backed value storage, and accessor methods.
jsonpull/jsonpull.cpp New C++ implementation of jsonpull parser/stringifier using shared ownership.
jsonpull/jsonpull.c Removes the legacy C implementation.
geometry.hpp Updates overzoom() signatures to accept json_object_ptr filter.
geojson.hpp Updates JSON parse argument types and map-backed parser API types.
geojson.cpp Updates GeoJSON serialization/parsing to new JSON APIs and shared ownership.
geojson-loop.hpp Updates json_feature_action interface and parse entrypoint to shared_ptr types.
geojson-loop.cpp Updates feature/geometry detection loop to new JSON APIs and std::string stringify.
geobuf.cpp Updates parsing of embedded tippecanoe JSON to shared_ptr types.
evaluator.hpp Updates filter/evaluation API to accept json_object_ptr and returns shared_ptr filters.
evaluator.cpp Updates filter evaluation and error reporting to new JSON APIs and std::string stringify.
dirtiles.cpp Updates directory metadata JSON parsing to shared_ptr types and STL traversal.
clip.cpp Updates overzoom() signatures to accept json_object_ptr filter.
attribute.cpp Updates JSON parsing for -E accumulation config to shared_ptr types and STL traversal.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread jsontool.cpp Outdated
Comment thread geojson.hpp
Comment thread geojson.cpp Outdated
Comment thread jsonpull/jsonpull.cpp Outdated
Comment on lines +554 to +556
} else if (ch >= 0xdc00 && c <= 0xdfff) {
if (surrogate >= 0) {
long c1 = surrogate - 0xd800;
Comment thread tile-join.cpp
e-n-f and others added 8 commits May 30, 2026 17:48
Replace the manual malloc/realloc/free memory management in jsonpull
with std::shared_ptr ownership. Each json_object now owns its children
through std::vector<json_object_ptr>; raw back-pointers to parent and
parser remain valid by structural invariant and are cleared on
json_disconnect so detached subtrees can outlive their parser.
Strings become std::string, child arrays become std::vector, and the
old union becomes a struct so non-trivial members can coexist while
preserving the existing o->value.xxx access paths.

The old jsonpull.c is replaced by jsonpull.cpp, json_stringify now
returns std::string, and all callers across tippecanoe, tile-join,
tippecanoe-decode, tippecanoe-json-tool, tippecanoe-overzoom and the
unit tests are updated to use json_object_ptr / json_pull_ptr.

Co-authored-by: Cursor <cursoragent@cursor.com>
The previous "every member in a struct" layout cost 168 bytes per
json_object, even for JSON_NULL / JSON_TRUE / JSON_FALSE nodes that
have no payload. Splitting json_object into a small base class plus
json_number / json_string / json_array / json_hash subclasses brings
each instance down to just the size of its actual contents:

  json_object (base, TRUE / FALSE / NULL)   24 bytes
  json_number                                48 bytes
  json_string                                48 bytes
  json_array  (empty)                        48 bytes
  json_hash   (empty)                        72 bytes

Other size wins along the way:

* Drop enable_shared_from_this<json_object> (its embedded weak_ptr
  was 16 bytes per node). json_pull now keeps an explicit
  container_stack and the parser no longer needs to resurrect a
  shared_ptr from a raw `parent` walk.
* Remove the unused `refcon` slot from the string variant.
* No virtual destructor: shared_ptr keeps the deleter from the
  original std::make_shared<json_xxx> call, so destroying a
  shared_ptr<json_object> still runs the right subclass dtor.

The base class exposes type-tagged accessors (o->string(),
o->number(), o->array(), o->keys(), o->values(), o->large_signed(),
o->large_unsigned()) that assert the type matches and downcast to
the appropriate subclass storage. All call sites were swept from
the old `o->value.X.Y` field paths to these accessors. A raw-pointer
overload of json_hash_get() replaces the few external uses of
shared_from_this() that survived in geojson-loop.cpp.

Co-authored-by: Cursor <cursoragent@cursor.com>
Replace the parallel std::vector<json_object_ptr> keys / values on
json_hash with a single std::vector<json_entry>, where json_entry is
a small {key, value} aggregate. This still preserves insertion order
(the property the parallel vectors were providing) but removes the
"keep two vectors in lockstep" pattern, and call sites can now use
range-for with structured bindings:

    for (auto &[k, v] : o->entries()) { ... }

Side effects:

* sizeof(json_hash) drops from 72 to 48 bytes (one fewer vector
  header), matching json_array.
* The keys() and values() accessors on json_object are replaced by a
  single entries() accessor returning std::vector<json_entry>&.
* All call sites were swept from the old paired-index pattern
  (`o->keys()[i]` / `o->values()[i]`) to entry-based access. Where the
  original pattern relied on `nprop = 0` to short-circuit iteration on
  a null or non-hash `properties`, the rewrite now guards the loop
  explicitly with `if (o->type == JSON_HASH)` so that calling
  entries() doesn't trip the asserting downcast.

Co-authored-by: Cursor <cursoragent@cursor.com>
`expect` was only meaningful while the parser was building a container,
and only ever read or written from jsonpull.cpp itself; once parsing
finished it was dead weight on every JSON_ARRAY and JSON_HASH (and
present-but-unused on every primitive too). Move it into the parser's
container stack, alongside the shared_ptr to the container it pertains
to:

    struct json_pull::parse_frame {
        json_object_ptr container;
        json_type       expect;
    };
    std::vector<parse_frame> container_stack;

The base class now only carries data-model state (parent, parser, type).
No external caller depended on `expect`, so no sweep was needed outside
jsonpull.cpp.

This change does not, in itself, shrink any json_object: the 4-byte
`expect` field used to live at offset 20 inside the base, where it was
already being eaten by alignment padding for the 8-byte-aligned first
member of every subclass (std::string, std::vector, double). The win is
in the data model, not the byte count -- the 4-byte hole is still
there, but it is now available for a future subclass whose first member
is small enough to slot into it.

Co-authored-by: Cursor <cursoragent@cursor.com>
json_number used to carry three parallel 8-byte fields (a double plus
both a 64-bit unsigned and a 64-bit signed slot for the large-integer
cases) even though at most one of the integer slots is ever the
canonical value for any given number. Collapse them into a
discriminated union:

    enum repr_t { REPR_DOUBLE, REPR_LARGE_UNSIGNED, REPR_LARGE_SIGNED };
    repr_t repr;
    union { double d; unsigned long long u; long long s; } value;

Callers keep the same read API: number() returns the appropriate
double, large_unsigned() returns the ull (or 0 if not currently stored
that way), large_signed() likewise. Writes go through new set_number /
set_large_unsigned / set_large_signed methods that keep the
discriminator and the union value in sync.

This was prompted by an observation that moving json_type to the end
of the object should shrink things via tail-padding reuse. Empirically
the type-at-end rearrangement saves nothing on its own (every
subclass payload is 8-byte aligned so it can't slot into the 4-byte
tail), but the discriminated-number redesign hits the same idea from
a different direction: adding the 4-byte `repr` to json_number makes
the class non-standard-layout, which lets the Itanium ABI pack `repr`
into the base's 4-byte tail padding at offset 20. The union value
then starts at the natural offset 24, and json_number ends at offset
32 -- a 33% reduction.

Per-node sizes:
  json_object (TRUE/FALSE/NULL)  24 bytes
  json_number                    32 bytes  (was 48)
  json_string                    48 bytes
  json_array                     48 bytes
  json_hash                      48 bytes

Numbers dominate real GeoJSON (every coordinate is one), so the net
memory win on a typical parse is substantial.

Co-authored-by: Cursor <cursoragent@cursor.com>
- jsontool.cpp `out()`: route JSON_NUMBER (and anything else non-string)
  through `json_stringify` instead of `o->string()`, which now asserts
  on a non-string type and would crash `--extract` on numeric attributes.
- geojson.{hpp,cpp} `json_end_map`: take `json_pull_ptr` by reference so
  the caller's shared_ptr is released, null-guard before touching
  `jp->source`, and clear `jp->source` after delete to avoid a dangling
  pointer.
- jsonpull/jsonpull.cpp: low-surrogate range check was comparing the
  outer-loop byte `c` instead of the parsed code unit `ch`, breaking
  surrogate-pair decoding for some \\uXXXX escapes. Pre-existing bug
  preserved across the port.
- tile-join.cpp `handle_vector_layers`: require the field value to have
  type JSON_STRING (and the key to be non-null) before calling
  `string()`; the previous truthy `type` check would assert on a
  non-string value.

Co-authored-by: Cursor <cursoragent@cursor.com>
Covers the `c` vs `ch` bug fixed in the previous commit: parsing
"\uD83D\uE000" (a valid high surrogate followed by a non-surrogate
BMP code point) used to mis-classify U+E000 as a low surrogate and
combine the two units into U+1F400 (F0 9F 90 80). The fixed code
flushes the stale high surrogate as standalone CESU-8 (ED A0 BD)
and then encodes U+E000 normally as EE 80 80. Verified the test
fails under the pre-fix logic.

Co-authored-by: Cursor <cursoragent@cursor.com>
@e-n-f
e-n-f changed the base branch from main to jsonpull-rename May 31, 2026 00:51
e-n-f and others added 3 commits May 30, 2026 18:28
Profiling tl_2022_us_county.json (sample(1) on Apple Silicon) showed
~38% of parse time in allocator work and ~14% in std::string::push_back
during string-token construction. These changes target the low-hanging
fruit from that profile:

- Pre-reserve 2 slots in json_array and 4 slots in json_hash so
  coordinate `[x, y]` pairs and typical GeoJSON property maps avoid
  the 0 -> 1 -> 2 -> 4 vector-growth chain (and the shared_ptr copies
  it incurs).
- Reuse a parser-wide std::string buffer for JSON_STRING tokens
  instead of constructing a fresh local std::string per token. The
  buffer is cleared (capacity preserved) at the start of each token
  and copied into the final json_string, so once it has grown to the
  longest string seen it stops reallocating entirely.
- std::move the freshly-created container shared_ptr into the parser
  container stack in the `[` and `{` handlers, and move it out of the
  frame on the matching `]` / `}`. Each move skips one atomic
  inc/dec round-trip per container open and close.

On a tl_2022_us_county.json benchmark (4-iter user-time mean, Apple
Silicon, /usr/bin/time):
- main baseline:                              ~8.17s
- jsonpull-cpp before these changes:          ~10.90s  (+33%)
- jsonpull-cpp with these changes:            ~9.33s   (+14%)

So this commit recovers roughly half of the post-port regression.
The remaining gap is dominated by shared_ptr atomic refcount traffic
on the parse tree and per-node heap allocations, which would require
the larger unique_ptr/arena reworks to address.

Co-authored-by: Cursor <cursoragent@cursor.com>
In the C++ port, json_free was just `o.reset()`, which dropped the
caller's reference but left the subtree alive: the parent's vector
slot kept it allocated, and for line-delimited streams the parser's
jp->root co-owned it until the next top-level value started parsing.
That defeated the geojson-loop pattern of calling json_free on each
feature after serializing it, which is supposed to release the
feature so it doesn't sit in memory while subsequent ones are parsed.

Restore the historical "remove this from the tree" semantics by
splicing the node out of its parent (sharing splice_from_parent with
json_disconnect) and clearing jp->root when the node is the parser's
current top-level value, then dropping the caller's reference.

Two unit tests pin this down: a pruning test parses
"[[1, 2], [3, 4], [5, 6]]" element-wise and confirms that calling
json_free on [3, 4] leaves the outer array with just [1, 2] and
[5, 6]; a top-level test uses a weak_ptr observer to confirm that
json_free on the parser's root really destroys the tree.

Co-authored-by: Cursor <cursoragent@cursor.com>
Replaces the shared_ptr-based json_object_ptr with a unique_ptr that
has a stateless custom deleter dispatching on json_object::type before
calling the right subclass destructor. Eliminates per-node atomic
reference-counting and the control-block allocation that shared_ptr
required for every node in the tree.

API now distinguishes owning and borrowing pointers explicitly:
- json_read / json_read_separators / json_hash_get return raw
  json_object * (borrowed from the parser-owned tree).
- json_read_tree / json_disconnect return json_object_ptr (caller
  takes ownership; back-pointers are cleared so the subtree can
  outlive the parser).
- json_free / json_context / json_stringify take raw pointers.
- The parser's container_stack holds raw pointers; jp->root keeps
  unique_ptr ownership of the most recent top-level value.

Internally, take_from_owner moves the unique_ptr out of whichever
parent vector / hash entry / parser root owned it, which both
json_free and json_disconnect rely on.

In the streaming parsers (parse_feature, parse_layers, the
geojson-loop callback), we are careful to free `j` only after we
have processed a complete Feature: json_read returns each token
as the tree is being built up, and freeing an intermediate node
would splice it out of the surrounding hash and corrupt the
in-progress feature.

Benchmark (tl_2022_us_county.json, -z0 --extend-zooms-if-still-dropping,
median of 5 runs on macOS arm64): 8.5s, vs 10.6s with shared_ptr
and 8.7s on the pre-refactor C baseline.

Co-authored-by: Cursor <cursoragent@cursor.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 24 out of 24 changed files in this pull request and generated 2 comments.

Comment thread evaluator.hpp
Comment on lines 14 to +16
bool evaluate(std::unordered_map<std::string, mvt_value> const &feature, std::string const &layer, json_object *filter, std::set<std::string> &exclude_attributes, std::vector<std::string> const &unidecode_data);
json_object *parse_filter(const char *s);
json_object *read_filter(const char *fname);
json_object_ptr parse_filter(const char *s);
json_object_ptr read_filter(const char *fname);
Comment thread tile.cpp
run_prefilter_args rpa; // here so it stays in scope until joined
FILE *prefilter_read_fp = NULL;
json_pull *prefilter_jp = NULL;
json_pull_ptr prefilter_jp;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants