TL;DR: We ported GoogleChromeLabs/jsbi — a pure-JS arbitrary-precision integer library — to modern C++17, bridged back to Node via N-API, for Port Mortem 2026 (Track H). The port itself wasn't the hard part. Proving it behaved identically to the original — and being honest about the one place we couldn't prove that — was.
This is the story of a compile error that was secretly a correctness bug, a shared_ptr that lied about owning memory, and a benchmark that changed its mind three times in a row, and what we did when it wouldn't sit still.
Why JSBI → C++, of all pairs
JSBI isn't really a JavaScript library. It's V8's internal MutableBigInt — written in C++ — manually downgraded to 30-bit digits so its arithmetic could survive inside JavaScript's 2^53 safe-integer ceiling without silently losing precision. Every multiply in the original source carries a 15-bit split trick that exists for exactly one reason: JS numbers can't safely hold the product of two 32-bit values.
So porting it back to C++ isn't a random language swap for Track H credit. It's undoing a constraint. In C++, a uint64_t accumulator holds that same product natively — no split needed. We weren't rewriting an algorithm. We were giving one back its native width.
Original: V8 C++ MutableBigInt → downgraded to 30-bit digits → shipped as JS
This port: JS jsbi → C++17, native 64-bit accumulators → the loop closes
The verification pipeline
Two of us, 72 hours. The port had to run the original test suite unmodified — not a translated copy, the literal upstream files, pinned via git submodule at the kickoff commit hash, so nobody has to take our word for what "unmodified" means.
tests/original-upstream/*.mjs (pinned submodule, byte-identical to upstream)
│ import JSBI from '../dist/jsbi.mjs'
▼
tests/dist/jsbi.mjs (thin JS bridge — API shape only, zero math logic)
│ require(native addon)
▼
src/addon.cpp (N-API boundary — ownership + exception mapping)
│
▼
src/jsbi.cpp / jsbi.hpp (the actual math — zero Node/V8 headers, by design)
That last line matters more than it looks: the core engine has zero includes from Node or V8. You can grep -L napi.h src/*.cpp and confirm it yourself. If this project ever needed to compile standalone — WASM, embedded, whatever — the math layer doesn't care that N-API exists.
On top of the pinned test suite, we ran a three-way differential fuzzer every session: native V8 BigInt, the real upstream jsbi npm package, and our port, on identical inputs, for 60+ seconds at a time. Three-way, not two — because a two-way comparison against native BigInt alone can't tell you whether a divergence is in your math or in your bridge wrapper. Three-way can.
209,401 iterations, zero divergences, on the final clean run.
What broke — four bugs, in increasing order of "wait, that's not a style nitpick"
1. The compile error that was hiding a correctness question
r.digits.empty() ? (r.digits.push_back(1), void()) : (r.digits[0] |= 1);
This was in our division algorithm. It looks like a harmless one-liner. It's actually ill-formed C++ — the ternary operator requires both branches to share a common type, and void is only compatible with another void, or with a throw-expression. r.digits[0] |= 1 has type uint32_t&. Neither branch qualifies. GCC and Clang under -std=c++17 reject this outright. It happened to build on our first Windows/MSVC pass, which is a genuinely dangerous kind of luck — it meant our first "it compiles" signal was compiler-specific, not portable. We didn't find this by reading docs. We found it by trying to build on a second toolchain and watching it fail.
2. A shared_ptr that owned nothing
return std::shared_ptr<jsbi::JSBI_CPP>(std::shared_ptr<jsbi::JSBI_CPP>{}, raw);
This is the aliasing constructor, called with an empty control block. It type-checks. It has -> and .get(). Its use_count() is zero. It contributes nothing to keeping the object alive — it's a raw pointer wearing a shared_ptr costume, sitting in a codebase whose entire pitch for the Zero-Unsafe bonus was "we don't do that." The fix was smaller than the bug: return an honest, documented, non-owning raw pointer instead, and let the real shared_ptr — the one captured in the N-API finalizer closure — do the actual owning. Faking safety is worse than admitting you're borrowing.
3. The spec quirk nobody wrote a test for, until the fuzzer got wide enough
if (b.is_negative) throw std::invalid_argument("Cannot shift by negative amount");
Reasonable-looking guard. Spec-wrong. 5n << -1n doesn't throw in real BigInt — it redirects to 5n >> 1n. Negative shift counts aren't an error, they're a direction flip. This bug was invisible to our own fuzz harness for a while, because our shift-amount generator only ever produced values 0 to 64. The bug wasn't in the math. It was in the range of inputs we were brave enough to generate. Widening the generator to -64..64 is what actually caught it — not code review, not the original test suite, just deciding to stop being polite to our own implementation.
4. The sign character nobody thought to negate
BigInt("-0x1") throws — non-decimal radixes don't accept a sign, and our code correctly rejected the - case. BigInt("+0x1") should also throw, for the same reason. Ours didn't. We were only tracking is_negative, and + doesn't set that flag — so a + before a hex prefix sailed straight through unguarded. Caught during a manual code-review pass, not fuzzing, because our fuzz string generator never happened to emit a leading + before a 0x. A reminder that fuzzing finds what your generator can imagine, and code review finds what it can't.
The benchmark that wouldn't agree with itself
Here's the part I actually want other teams to read.
We built an honest benchmark harness — real hrtime.bigint() measurements, forced GC between blocks, identical operand pairs fed to both implementations, three operand sizes so we couldn't hide behind a single flattering number. First run, large operands:
| Operation | Original JS | Our Port | Verdict |
|---|---|---|---|
add |
3.78ms p99 | 1.60ms p99 | Port wins, clearly |
divide |
1.60ms p99 | 3.63ms p99 | Port loses, clearly |
Clean story. Port wins at addition, loses at division — makes sense, our division is a bit-serial restoring-division algorithm, upstream's is presumably limb-serial. Write it up, move on.
Except we ran it again.
| Operation | Run 1 | Run 2 | Run 3 |
|---|---|---|---|
add (port vs original) |
1.75× faster | 1.09× faster | 0.95× (slightly slower) |
divide (port vs original) |
0.69× | 1.08× | 0.86× |
Three runs. Same machine. Same code. No consistent winner on any operation. The "our division algorithm is measurably worse" story I was ready to write didn't survive a second data point.
We had two options. Report the run that told the best story. Or report what actually happened.
We reported the range. All three runs, in the README, with the honest conclusion: measurement noise on a general-purpose Windows machine dominates whatever true performance difference exists, we don't have a reliable claim to make, and here's exactly why (shared long-lived process across every block, non-isolated host, cumulative RSS climbing past 1.2GB by the last measurement). We even named the fix we didn't have time to do — isolated process per block, Linux CI runner, median-of-ten instead of a point estimate — as explicit future work instead of a silent gap.
That's not the exciting version. It's the true one. And per this hackathon's own scoring language — hiding a regression scores worse than disclosing it — inconclusive-and-honest beats confident-and-wrong every time a judge actually checks your numbers instead of just reading your headline.
The decision I'd take back
We spent real hours getting the N-API bridge to a place where the literal, unmodified, pinned upstream test files could require() our compiled binary directly — zero-touch, not a translated copy. It's the more defensible architecture, and I'd make the same call again on the merits. But it cost us a full evening fighting Windows toolchain issues that had nothing to do with C++: node-gyp not recognizing a newer Visual Studio release, a Build Tools install silently missing the Windows SDK component, the kind of error that eats a clock without teaching you anything about your actual port.
If I ran this again, I'd stand up a Linux/WSL2 build path in the first hour, not discover I needed one during a compile failure. The bridge architecture was worth it. The hours lost to a toolchain neither of us had properly checked ahead of time were not — that's just tax, and I'd pay it earlier and smaller if I could.
Final scorecard
- 246/246 assertions passing against the pinned, unmodified upstream test suite (submodule-hashed at kickoff, not a copy)
- 209,401 three-way differential fuzz iterations, zero divergences
- 33+ entries in our decision log — including, deliberately, the ones marked Superseded and Known Limitation, because a log that only records what worked isn't a log, it's a highlight reel
-
Zero raw
new/delete, zero fake ownership (after we caught the one that was faking it), zero Node/V8 dependency in the math core - One benchmark result we can't confidently claim — and said so, in writing, instead of picking the run that looked good
Reproduce it yourself
- Repository: https://github.com/codewisp-ai/Coderesurrection-2026
- Demo (original test suite passing live against the port): https://youtu.be/UKKCHzXfF5Y
- Full decision log:
DECISIONS.mdin the repo — read the Superseded entries first, they're more honest than the accepted ones
Closing thought
Anyone with an AI coding agent can produce C++ that compiles. Ours didn't, the first time — and the compiler catching that was luckier than it should have been, since a more permissive toolchain let it through initially.
What we actually spent 72 hours on wasn't writing arithmetic. It was building enough ways to catch ourselves being wrong — a pinned test suite we couldn't quietly edit, a three-way fuzzer that could tell our math bugs from our bridge bugs, a benchmark methodology honest enough to report its own noise instead of its best headline.
The port is the artifact. The willingness to publish the run that didn't flatter us is the actual submission.
Top comments (1)
cool project. what is the perf like under load? tried something similar and the bottleneck was serialization.