You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Lessons learned — Bun PR #30412 AI-orchestrated Zig→Rust port
Synthesized from five parallel research agents (branch ecosystem, workflow internals, code-vs-audit verification, post-merge bug taxonomy, missed angles) plus the recovered audit documents and direct re-verification against origin/main two days post-merge. PR #30412 by Jarred Sumner, squash-merged 2026-05-14 as commit 23427dbc12 "Rewrite Bun in Rust (#30412)". Per gh api: 1,009,257 additions / 2,188 files. The PR's GitHub-displayed commit-count badge is misleading — the actual branch contains 5,512 first-parent commits across 32 claude/* sub-branches with 87 merge points and 49 first-parent revert commits (66 in the --all log) (external — git log pr-30412 ^MERGE_BASE --first-parent). No human approving review on the PR.
Sourcing convention. Three categories of claim appear below:
Audit-grounded — claims of the form "the audit documents X" are verifiable from the recovered audit docs (ZIG_RUST_DIVERGENCE_AUDIT, NOALIAS_HUNT_REPORT, NOALIAS_SUSPECTS, PORTING.md, rust-rewrite-plan.md, rust-rewrite-verified-claims.md) published alongside this gist.
External — claims about workflow files, the merged code tree, git log, branch graph, or gh api data are external to the audits and labeled (external — <source>). They would need to be re-run to verify.
Inference — bridging claims marked (inference).
Correction notice (v7). A prior revision asserted that the NOALIAS_HUNT_REPORT.md "Systemic Recommendation" (R-2 Phase 2/3: .classes.ts codegen → &self + interior mutability) was not done. That was wrong. The fix shipped via claude/r2-phase2-migration (merged into claude/phase-a-port at 75814df100 on 2026-05-11) and R-2 Phase 3 (commit 695d5a2f96). The codegen file src/codegen/generate-classes.ts now defaults sharedThis = true and emits &${T} for host-fn receivers, with sharedThis: false an explicit per-class opt-out (external — git show origin/main:src/codegen/generate-classes.ts lines 2823, 2866–2872). Nine .classes.ts files currently use the flag (external — code-grep). The closing observation below has been rewritten to reflect this. What survives the correction: the handle_reading instance (Cluster C #7 of the audit) shipped without the launder fix while its three siblings got it — see "What didn't work" §4.
The methodology in one paragraph
(this paragraph mixes audit-grounded statements with workflow-file-derived ones; external labels apply collectively to workflow content)
A two-tier specification (architecture invariants + per-file translation rules) sat above an adversarially-verified fact corpus tied to source file:line, plus a pre-classified LIFETIMES.tsv of 2,253 rows mapping every Zig pointer field to its Rust ownership class (external — git show 10cfa0b237^:docs/LIFETIMES.tsv | wc -l). (external — from the 51 phase-*.workflow.js files on the PR branch:) A graph of 51 phase workflows (A → H) dispatched Claude subagents to draft, verify, fix, ungate, and audit. Verification rituals were multi-vote with default-deny: Phase-A 1-vote with mandatory fix, Phase-B2/D/F/G 2-vote + 3rd-agent tiebreak with the literal prompt "Default confirmed=false unless you verify against .zig"(external — phase-b2-cycle.workflow.js:107). Sharding was by crate × file × line-range; coordination was git pull -X ours, not a lock service. (external — from rust-rewrite-verified-claims.md, line count from wc -l:) The verified-claims corpus is 1,355 lines, 200+ FACT/RUST/PERF/SRC claims, each surviving 3-vote review against source. Opinion: the verified-claims corpus and LIFETIMES.tsv together — the fact-and-classification substrate — are the most novel artifact in the methodology; the spec itself (PORTING.md) is a competent coding standard but the least novel of the three.
What worked
1. Fact-first, plan-second.(external — from rust-rewrite-verified-claims.md's header convention and its referencing relationship to rust-rewrite-plan.md.) Claims (extracted facts about Zig source at file:line) preceded the architecture plan. This inverts how most rewrites are organized and forces the plan to be a derived artifact of agreed-upon source facts. Each verified claim uses a four-field schema [FACT (Zig source-cited) → RUST (translation) → PERF (testable target) → SRC (file:line)] (external — rust-rewrite-verified-claims.md). When agents disagreed during porting, the tiebreak could reference an immutable, peer-verified fact rather than re-litigate. Opinion: this is the strongest portable idea in the methodology.
1b. Pre-classification before code generation.(external — docs/LIFETIMES.tsv on commit c241df2861, 2,253 rows, deleted in 10cfa0b237.) Every pointer struct field in Zig was pre-classified (INTRUSIVE / STATIC / JSC_BORROW / etc.) with a per-row evidence citation, and the Phase-A workflow hardcodes grep "^${f.zig}\\b" ${REPO}/docs/LIFETIMES.tsv into the implement agent's prompt (external — phase-a-port.workflow.js:17). Lifetime decisions were thus not re-derived per-agent; they were pre-decided and propagated. This is the only place I observed a specification-as-data artifact (TSV with mandatory columns) rather than a specification-as-prose artifact.
2. Default-deny on verification.(external — from phase-b2-cycle.workflow.js:107, phase-b2-verify.workflow.js, phase-d-build-queue.workflow.js:165, phase-e-proper-port.workflow.js.) Tiebreak agents return confirmed=false unless they can re-verify against .zig. Verifiers default to refuted unless they can cite both .zig:line and .rs:line + observable divergence. Re-gating a previously-ungated module auto-fails Phase E. Phase H's diff-review verifier accepts only with ub > leak > semantics triage (UB and leaks are blocking; perf/style are not). These biases stack — they bias the system toward finding more bugs than it ignores.
3. ASM-verified UB hunting. The noalias hunt is unusually rigorous: 277 candidates → 73 survived adversarial triage → 23 ASM-confirmed miscompiles + 46 the audit classifies as NOT_CACHED ("safe in current codegen" but "Still UB. Flag for the systemic fix; do not rely on current codegen." per NOALIAS_HUNT_REPORT.md). Most engineering organizations would have stopped at "lint flags 277 suspicious patterns" and either fixed or dismissed them in bulk. Going to release-mode x86-64 disassembly to prove miscompiles is the gold standard.
4. Systemic codegen fix, not just per-site patches. The audit's R-2 Phase 2/3 recommendation — flip the .classes.ts codegen to emit &self + Cell/JsCell interior mutability by default — shipped. Evidence: src/codegen/generate-classes.ts:2823 is sharedThis = true, (the default). Lines :2866–2871 carry a six-line //-prefixed comment block beginning "R-2 Phase 3: default flipped to sharedThis: true" and explaining that every JS-exposed host-fn now receives &${T} (no noalias on the LLVM arg), that sharedThis: false remains an explicit opt-out for types not yet migrated to Cell/JsCell, and that _shared helpers live in src/jsc/host_fn.rs alongside the legacy &mut originals (// prefixes elided from this paraphrase). Line :2872 is const recv = sharedThis ? \&${T}` : `&mut ${T}`;*(external —git show origin/main:src/codegen/generate-classes.ts)*. Nine .classes.tsfiles use thesharedThisflag;ResumableSink.classes.ts is the one I verified as an explicit opt-out (sharedThis: false) carrying the line // R-2 Phase 3 opt-out: ResumableSink host-fns still take `&mut self`.. Whether the other eight files using the flag set it : true(the new default, redundant but explicit) or as opt-outs is **(inference)** beyond a one-line grep. **Opinion:** systemic fixes at the codegen layer are the right pattern for a class of bugs that's compiler-version-dependent, and the team did this fornoalias` — a rare and worth-imitating move.
5. Compile-time layout asserts at scale. PORTING.md prescribes #[repr] + layout asserts for every FFI-crossing type (external — PORTING.md § Constraints, in this gist). The merged tree contains 148 total const _: () = assert!(...) patterns (external — git grep -E "const _: \(\) = assert!\(" origin/main -- '*.rs' | wc -l). Two example sites verified directly: core::mem::size_of::<B>() == 16 in src/ast/b.rs, and core::mem::size_of::<Data>() == 16 in src/ast/expr.rs (the source line carries a trailing // Do not increase the size of Expr directive). The full 148-site distribution is (inference) beyond what is shown in source evidence — only the first 8 grep matches were inspected, and the two example sites cited here (both in src/ast/) are from that 8-line sample. PR #30722's post-merge security audit added offset_of! proof asserts for struct padding bytes in npm.rs (finding #67: "Raw serialization reads struct padding bytes — add explicit _padding_* fields with offset_of! proof asserts (npm.rs)") (external — gh pr view 30722). Opinion: these are real, executed asserts (not a paper requirement) and a portable pattern.
6. Bounded repair loops with explicit caps and zero leftover.(external — from phase-a-port.workflow.js, phase-d-build-queue.workflow.js, phase-g-test-swarm-v3.workflow.js.) Each phase has a round cap (100 for D-crate-shard, 80 for E, 12 for G). The IOU mechanism (todo!("blocked_on: X::Y")) had 0 unresolved instances on merge (external — git grep -c 'todo!("blocked_on' origin/main -- '*.rs'). Bounded loops that empty their own queue is the AI-orchestration pattern I'd most readily import.
7. Branch ecosystem as orchestration substrate.(external — git log pr-30412 ^MERGE_BASE --first-parent.) The actual structure of the PR is 32 claude/* sub-branches merged into the PR's history across 87 merge commits with 5,512 first-parent commits unique to the PR. Opinion: a single "commits" count substantially understates this orchestration complexity. (The verified merge-into-claude/phase-a-port evidence is for claude/r2-phase2-migration at merge commit 75814df100; the other 31 sub-branches' specific target branches are visible in their individual merge-commit messages but I did not enumerate each one's parent.) Sub-branches include r2-phase2-migration (the systemic codegen fix — verified above), zig-rust-divergence-audit, divergence-fix-all, divergence-top5, unsafe-5k(inference — branch name suggests an unsafe-block-reduction effort with target ~5000; I did not read the branch's commits to confirm), parking-lot-to-futex, code-dedup, bench-until-green (merged 19 times (external — grep count in first-parent log)), flaky-stabilize / v2 / v3(inference — the v2/v3 suffix pattern in the branch names suggests three rounds of test stabilization), security-audit-patches, ci-auto-fix-*(inference — five branches with ci-auto-fix-NNNNN numeric suffixes appear in Source D's branch list; "issue-specific autofixes" is my interpretation of the naming pattern), stack-fallback-allocator. There are 49 Revert commits in the first-parent log (66 in the --all log)(external — git log pr-30412 ^MERGE_BASE --first-parent --oneline | grep -ic revert), including Revert "Merge claude/spec-divergence-fix" (hash 473cf58820). Opinion: this branch-as-task topology is the load-bearing piece a casual reader of the PR cannot see and the methodology's most under-credited innovation; the Revert presence is evidence the methodology caught and rolled back its own bad merges.
What didn't work
1. Spec enforcement decayed toward merge.(external — code-grep on origin/main two days post-merge.) PORTING.md prescribes a PORT STATUS trailer at the end of every .rs file. The Phase-A workflow's output schema demanded {rs_path, confidence, todos, rs_loc} (external — from phase-a-port.workflow.js). Yet 1/1,448 files in the merged tree contains a PORT STATUS: trailer(external — git grep -l 'PORT STATUS:' origin/main -- '*.rs' | wc -l). The workflow JS code references the trailer in agent prompts but does not validate its presence as a schema invariant (external — phase-a-port.workflow.js:49,79). The gap between "the spec said so" and "the code reflects it" was not enforced by any gate.
2. git pull -X ours as the synchronization primitive.(external — from inspecting the phase-d / phase-e workflow files.) With dozens of agents writing simultaneously, the resolution strategy was "prefer the puller's edits on conflict." The methodology partitions work by crate/file to minimize collisions, but shared infrastructure (generated bindings, type definitions in core crates) is a known race surface. -X ours can drop concurrent edits and there is no record of which edits got dropped. Refinement from v6: this overstates what the strategy provably does; what's verifiable is the strategy is in use, and -X ours is capable of silent overwrite on shared files.
3. No semantic-correctness gate after Phase E; tests are the oracle.(external — from the phase-e + phase-g workflow files, git log.) Phase E auto-succeeds when cargo check --workspace returns 0 errors. Phase G's test-swarm-v3 workflow uses the existing Bun JS test suite as the oracle — it does not snapshot Zig-build behavior and diff against Rust. So when tests pass under both implementations, divergence inside the tested behavior class is invisible. Commit e99311e584 ("revert non-additive test changes") + 591214fd67 ("recalibrate TOML/JSONC stack-overflow depth tests for smaller release-build frames") + 78d74e84dc ("un-gate hot.test.ts sourcemap test and jsc-stress Wasm describe block") all landed in the final 48 hours, suggesting the test suite was a moving target during the port — calibrated to the new implementation rather than holding it to the old one.
4. The 23 PROVEN miscompiles got 22 fixes; one instance escaped. Direct verification on origin/main: of the four sibling methods in Cluster C of NOALIAS_HUNT_REPORT.md (handle_reading, handle_writing, update_handshake_state, shutdown in src/uws/lib.rs), three got the let this: *mut Self = core::hint::black_box(core::ptr::from_mut(self)); launder with explicit PORT_NOTES_PLAN R-2 comments at the merged-code line numbers 849, 578, 1069. The fourth — handle_reading at line 964 — kept its &mut self receiver and re-reads self.ssl in the loop without the launder(external — git show origin/main:src/uws/lib.rs, lines 964–1010). The audit explicitly listed handle_reading as Cluster C member #7 with ASM evidence at audit-line 79; the audit's specific evidence for handle_reading cites stale closed_notified() re-check elision on self.flags at audit-line 79, not the SSL* UAF that handle_writing exhibits — same family of bug (LLVM noalias caching across re-entrant callbacks), different specific manifestation (inference about the same-family classification). That this one method did not receive the same launder while its three siblings did is a missed instance; the audit lists all four as the same Cluster C, with identical prescribed fix language. (Note: the audit also flagged 46 NOT_CACHED sites — the R-2 Phase 2/3 codegen change addresses the class by retyping host-fn receivers to &self, which is the systemic fix. Whether each of the 46 individual sites is now in a re-typed function vs. still in a &mut self non-host-fn path is (inference) beyond what I verified in a one-day pass.)
5. The audits were deleted from main immediately before merge.ZIG_RUST_DIVERGENCE_AUDIT.md (4 HIGH, 11 MEDIUM, 11 LOW divergences with line citations), NOALIAS_HUNT_REPORT.md (23 PROVEN miscompiles + 46 NOT_CACHED-but-still-UB sites), NOALIAS_SUSPECTS.md, PORTING.md, rust-rewrite-plan.md, rust-rewrite-verified-claims.md, and LIFETIMES.tsv were all created during the port to track decisions and known bugs, then deleted in commit 10cfa0b237 "docs: remove internal porting/audit notes added during the Rust migration" immediately before merge. The squash-merge (next paragraph) compounds the loss. Opinion: keeping the audit punch-lists in main with closed items struck through, rather than deleting them, is what makes a methodology peer-reviewable; this is the most concerning meta-finding for me.
6. The squash merge erased the orchestration graph.(external — git log main after the merge.) PR #30412 was squash-merged as a single commit 23427dbc12. The 5,512 first-parent commits, the 32 claude/* sub-branches, the 87 sub-merges, and the 49 first-parent Reverts — none of them appear in main's history. A future reader of git log main sees one line. Opinion: for an AI-orchestrated PR of this scale, a merge commit (preserving the graph) would have been a load-bearing methodology artifact; the squash was a methodology-preservation regression.
7. No formal verification despite the code's role.Opinion: for a JS runtime, memory safety is a primary motivation for choosing Rust. (external — grep of Cargo.toml post-merge: no verus/kani/prusti/creusot/vstd dependencies.) The methodology has no theorem prover, model checker, or SMT-backed proof — only empirical verification (tests, ASAN, ASM grep, agent voting). (inference) The fact that 23 proven miscompiles surfaced only via release-asm disassembly suggests the test suite alone was not a safety net for this class of bug; that the audit was needed at all also suggests ASAN coverage at the time did not flag them, though I have not independently verified the sanitizer-coverage claim.
The post-merge bug pattern is itself methodology evidence
(external — git log 23427dbc12..origin/main, 22 commits within ~48h of the squash merge per commit dates. The D1–D5 classifications and the per-row counts in the table below are (inference) — my categorization of each commit's title against the audit doc; the source evidence is only the commit titles and the audit doc content.)
Class
Count
Examples
Defect — class flagged in audit, instance missed (D2)
1
f7c692ae9c"Fix worker teardown crash from missing dupeRef on synthetic-module specifiers (#30882)" — refcount/Drop class flagged in ZIG_RUST_DIVERGENCE_AUDIT.md (the deinit-vs-drop family that includes H3, "Body::Value recycled without Drop → WTFString / Blob content-type leak per Request/Response GC") (inference — same family, different instance; the audit-flagged class is deinit-vs-drop, the post-merge fix is for a missing dupeRef on synthetic-module specifiers — my classification claim that this fits the same family is the inference)
Defect — unflagged class (D3)
3
2a3d0e7d29"resolver: keep forward slashes when imports target is a package specifier (#30845)"(inference — that this is a Windows-path-separator issue specifically is my read of the commit title; the title says "forward slashes" without naming Windows); 314d044c0a JSON lexer rejecting ?/*/(/) before define auto-quote could recover; 428f61eb34 h3→http3 rename incomplete
Defect — test gap (D4)
1
bbd3e624af"bun:ffi: extract embedded shared libraries from bunfs in dlopen() (#30720)" — embedded-libs path was missing in the merged tree (inference — classification as test-gap is mine, based on the commit's framing as adding missing functionality rather than fixing a present-but-broken path)
Hardening (post-merge audit)
1
e520065ebb (PR #30722) 36 reachable security findings"across the runtime, package manager, parsers, HTTP client/server, and SQL drivers"(external — PR #30722 body opening paragraph)
Platform
1
8093571242"rust: include target_os = "android" in linux-kernel cfg gates (#30735)"
CI / build / cleanup / docs / refactor
15
cargo fmt, build:btg, dead-unsafe removal, contributing-guide docs, h3-rename file-split, etc. (sum across the remaining commits in the chronological list)
Window: the 22 commits span from 19d8ade2c6 Delete stray files (first post-merge commit) to e750984db6 cargo fmt. The "2 days" framing is (inference — commit dates inspection) based on the post-merge commit dates being within ~48h of the squash merge.
Two structural notes about the post-merge fixes:
PR #30722's body explicitly drops three auto-applied fixes because each one introduced a new specific bug: "Three auto-applied fixes (#61 SSL exception leak, #68 YAML merge dedup, #104 archive overwrite precheck) were dropped: #61 introduced a use-after-free, #68 stored a non-'static byte view in a 'static field, and #104 added dead gating that did not close the traversal"(external — gh pr view 30722). (inference) the three new bugs (UAF, 'static lifetime violation, dead-code gate) are each in the same general fault families the audits had already documented — but the PR body does not explicitly assert "same class as the bug being fixed," that bridging claim is mine. The PR's body uses the phrasing "auto-applied"; whether those auto-applies were agent-authored vs human-authored is (inference) beyond what the body states. Opinion: that a downstream review surfaced the three regressions (rather than the auto-apply pass catching them itself) is the failure mode worth flagging — patches without an independent re-check after apply.
0 of 22 post-merge commits update the PORT STATUS: trailer. The convention died at merge. (external — code-grep.)
Transferable rules for the next AI-orchestrated rewrite of this scale
Make the fact corpus the source of truth, and make pre-classification a separate artifact. Claims are prose (cite-able). Lifetime/ownership classification is data (TSV with mandatory columns, evidence per row). Don't conflate.
Bias verifiers toward false-positive over false-negative. Default-deny on tiebreak with the literal prompt language ("Default confirmed=false unless you verify"), default-fail on missing trailers/asserts, default-fail on re-gating. Asymmetric cost (a wrong skip ships UB; a wrong flag wastes an agent-round) demands asymmetric bias.
Compile-time-assert cross-language invariants for every type the plan declares as FFI-crossing — #[repr], size_of, offset_of for padding. Cheap, mechanical, blocks PRs. (PR #30412 ships 148 of these — proof it scales.)
Keep audit documents in the repo with closed items struck through, not deleted. Closed-item history is the durable evidence the methodology ran. Don't squash-merge a 32-branch orchestration into one commit; preserve the graph with a true merge.
Add a behavioral-equivalence gate, not just build-green. Snapshot or property-fuzz the old implementation as the oracle, per-flip. cargo check clean ≠ "behavior preserved." Tests are not a semantic oracle when they are a moving target during the port.
No global write-conflict resolution by -X ours. Either lock shared infrastructure files behind a single agent or serialize edits explicitly. Generated bindings and core type definitions are not crate-partitionable.
For language-level UB classes (noalias, aliasing &mut, race-free interior mutability), require systemic fixes at the codegen / type-system layer, and re-run the per-site audit after. PR #30412's R-2 Phase 2/3 did exactly this for .classes.ts host-fn receivers; the surviving handle_reading miscompile shows the systemic fix is necessary but not sufficient — re-audit the per-site list against the fix to catch the instances the codegen change doesn't cover.
When a phase auto-fixes bugs, gate the merge on a human or independent-tool review of the fix itself. PR #30722 dropped 3 of 36 agent-authored fixes because the fixes were wrong. The system needs an asymmetric "trust agents to find, distrust agents to repair" stance for security-class bugs.
Opinion (9): for an AI-authored rewrite at >1M-LOC scale, shipping without any human approving review reads as a category mismatch between the engineering rigor of the methodology and the absence of a human gate. One scoped human review focused on the audit punch-list would likely have caught the handle_reading miscompile and triggered the security audit before merge rather than 8 days after. I hold this as opinion, not analytic conclusion — others may reasonably argue the multi-vote agent verification is the gate.
Closing observation
The methodology is the most carefully-engineered AI-orchestrated rewrite I've examined. The spec layering, claim verification, multi-vote rituals, ASM-grounded UB hunting, pre-classification of pointer lifetimes, codegen-layer systemic fixes, bounded repair loops, branch-as-task orchestration, and self-reverting bad merges are real innovations that would be worth porting to other large-scale AI rewrites. The execution shipped with at least the following caveats:
One verified PROVEN miscompile that escaped the per-site fix pass (src/uws/lib.rs:964 handle_reading, an ASM-confirmed Cluster C bug whose three siblings were fixed). (external — direct re-verification on origin/main)
672 .expect("unreachable") call sites total, of which the ZIG_RUST_DIVERGENCE_AUDIT.md H4 class flagged ~10 as reachable. How many of the 672 are reachable per the audit's class is (inference) — could be ~10 or substantially higher. (external — git grep -c '.expect("unreachable")' origin/main -- '*.rs' = 672)
The PORT STATUS trailer requirement is satisfied by 1/1,448 files in origin/main two days post-merge. (external — code-grep.)
The audit trail and the 32-branch orchestration graph are not preserved in main (deleted in 10cfa0b237, squashed in 23427dbc12).
A 36-finding security-hardening PR landed 8 days post-merge (PR #30722), of which 3 agent-authored fixes were rolled back as themselves-buggy.
The lesson isn't that AI can't do this. It's that an AI-orchestrated system at this scale produces a methodology that works at the class level and leaks at the instance level — the audit found the noalias class, the codegen fix addressed it, the per-site pass got 22 of 23 PROVEN miscompiles. The remaining instance escape and the post-merge security audit's 36 findings are not class failures; they're the long tail. The fix is not "do less AI." It's: retain the audit history, preserve the orchestration graph, and add a human or independent-tool gate that re-runs the per-class checks against the codegen fix before merge. That last piece — re-auditing the per-site list after the systemic fix lands — is the one specific recommendation I'd make to PR #30412's authors for the next port at this scale.
Repo:/root/bun-5 (branch claude/phase-a-port)
Precedent:NodeHTTPResponse::cork (commit b818e70e1c57) — LLVM observed caching ref_count: Cell<u32> across an opaque JS call because &mut self carries noalias and no self-derived pointer reached the FFI boundary. Fixed via black_box(ptr::from_mut(self)) launder.
Executive Summary
Stage
Count
Candidates enumerated (pattern match)
277
Survived 2-vote adversarial triage
73
ASM-verified PROVEN_CACHED
23
ASM-verified NOT_CACHED (safe in current codegen)
46
INCONCLUSIVE (Windows-only, no asm available)
4
23 sites are confirmed by release-mode x86_64 disassembly to cache a self.* field in a callee-saved register or stack slot across an opaque re-entrant call, then reuse the stale value without reloading. Each is a latent UAF, refcount-leak, or guard-elision bug.
The 46 NOT_CACHED sites are still language-level UB (two live &mut Self to the same allocation) but LLVM happens not to exploit it today — typically because a self-derived pointer incidentally escapes to a nearby call, or the post-call read goes through a non-inlined callee. These are one inlining-heuristic change away from miscompiling.
PROVEN_CACHED — 23 sites (asm-confirmed)
Cluster A: NodeHTTPResponse ref_count fold (3 sites — same bug as cork)
#
File:Line
Method
Cached
Mechanism
1
src/runtime/server/NodeHTTPResponse.rs:779
handle_abort_or_timeout::<Timeout>
ref_count → r12d
ref_()/deref() folded to load-once/store-back across run_callback
ASM (tick_immediate_tasks):if next_immediate_tasks.capacity() > 0 { cold_merge } is entirely eliminated on the exception_thrown==false path — recursive setImmediate tasks silently dropped.
Fix:run_callback* should use the existing enter_scope(*mut EventLoop) RAII pattern. tick_immediate_tasks needs a black_box launder or *mut Self receiver.
movr15, qword ptr [rdi+48] ; self.ssl — ONLY loadtestr15,r15je .LBB505_16 ; null check HOISTED OUT OF LOOP....LBB505_4: ; loop bodycall qword ptr [rbp-64] ; (handlers.write)(ctx, data) — re-entrantmovrdi,r15 ; STALE ssl, no reloadcall SSL_get_wbio
Fix: convert handle_reading/handle_writing/update_handshake_state/shutdown to take this: *mut Self. The defensive "callback may have closed the connection" comments at :879/:900/:912 are precisely the checks LLVM deletes.
flags.remove(WRAPPER_BUSY) stores stale r15, clobbering IS_CLOSED set by re-entrant ssl_on_close; is_closed() reads stale → wrapper=None deferred-drop never runs
#[cfg(windows)]; no x86_64-pc-windows-msvc build available
src/runtime/socket/WindowsNamedPipe.rs:555
on_internal_receive_data
dead code (private, zero callers)
src/runtime/socket/WindowsNamedPipe.rs:653
on_connect
#[cfg(windows)]
src/runtime/socket/WindowsNamedPipe.rs:338
on_read_error
#[cfg(windows)] straddle block
Action: generate x86_64-pc-windows-msvc release asm and re-run verifier on these 4.
Recommended Fixes (per-site)
Pattern
Fix
Applies to
ref_()/deref() straddle
let _ = core::hint::black_box(ptr::from_mut(self)); before opaque call
A1-3, F16, H23
enter()/exit() straddle
use existing enter_scope(*mut EventLoop) RAII
B4-5
flags-byte RMW straddle
convert receiver &mut self → this: *mut Self
C7-10, D11-12, E13-15, H22
Vec/queue ptr cached across JS
black_box launder, or move-out-then-move-back (mem::take)
F16, H18, H20
spin-on-&mut bool
&Cell<bool> / *const bool
H21
Vec::take const-prop
black_box(&mut self.next_immediate_tasks) after take
B6
post-resolve re-read
black_box(ptr::from_mut(self)) before resolve
G17
StrongRef ptr cached
reload via (*ptr::from_mut(self)).worker after JS
H19
Systemic Recommendation
This is PORT_NOTES_PLAN R-2 territory. Spot-fixing 23 sites with black_box is a stopgap; 46 more are one inlining decision away from joining them.
Root cause:.classes.ts codegen + uws/libuv/c-ares callback shims materialize a fresh &mut Self from m_ctx/userdata while an outer &mut self is live across JS. Every such method's &mut self is a lie to LLVM.
Proposed systemic fix (pick one or layer):
.classes.ts codegen → &self + interior mutability. Change host_fn(method) to derive &Self (no noalias when Self: !Freeze). Convert hot mutable fields to Cell/JsCell. Subprocess already does this (and is NOT_CACHED for that reason).
Always escape self before JS. Add a #[reentrant] attr (or default-on for host_fn) that emits let _escape = black_box(ptr::from_mut(self)); at the top of every generated wrapper. Cheap, mechanical, asm-verifiable.
*mut Self receivers for callback-straddling methods. What enter_scope, do_run, and the Windows on_dns_poll_uv already do correctly. Enforce via clippy lint: ban &mut self on any fn that calls run_callback/JSValue::call/JSPromise::resolve/uws-dispatch without a #[reentrancy_safe] opt-out.
Crate-level -Zmutable-noalias=no on bun_runtime/bun_jsc/bun_uws/bun_http/bun_sql_jsc as a temporary safety net while (1)/(2)/(3) land. Perf cost TBD (likely <1% — most hot loops don't straddle JS).
noalias SUSPECT sites (73 — UB, not currently miscompiled)
From noalias-hunt @ wa4pe2sat. These survived 2-vote triage but ASM showed NOT_CACHED in current codegen — typically because a self-derived ptr incidentally escapes nearby, or post-call read goes through a non-inlined callee. One inlining-heuristic change away from miscompiling.
PR #30412 — Deep analysis after cloning the PR branch
Status
MERGED 2026-05-14 08:09Z by Jarred Sumner as commit 23427dbc12. Final: 1,009,257 additions / 4,024 deletions / 2,188 files / 100 commits. PR body: "Blog post with details coming soon. Still some optimization work to do before this lands in non-canary version." Co-authors: Dylan Conway, autofix-ci[bot], robobun.
This document supersedes the May-10 gist. I fetched refs/pull/30412/head into the local repo as pr-30412 and inspected every artifact, including the audits that were deleted before merge.
1. The deleted audits documented known bugs at merge time
Two audit documents were added to the PR branch between my May-10 snapshot and the May-14 merge, then deleted just before merge in commit 10cfa0b237.
26 known structural divergences between Zig and Rust, classified by risk: 4 HIGH, 11 MEDIUM, 11 LOW, across three categories:
refcount-transfer-vs-plus1 (7) — Zig toJS() transfers caller's +1 to the wrapper; Rust to_js()adds a fresh +1. Verbatim ports leak one ref each call.
deinit-vs-drop (10) — Zig pools recycle with value.* = undefined (no destructor); Rust HiveArray::put runs drop_in_place. Types that had deinit but no impl Drop leak; types with both risk double-free.
error-union-vs-result (9) — Zig !T signatures whose bodies never error were transliterated with real Err arms. Every catch unreachable → .expect("unreachable") is now reachable.
The four HIGH-risk bugs documented in the audit:
H1: Bun.spawn({stdin:'pipe'}) leaks one FileSink per proc.stdin read → fd exhaustion in long-running parents
H2: Bun.spawn({stdin: ReadableStream}) leaks at rc≥2 — compounds H1
H3: Body::Value has no Drop → every Request/Response GC leaks WTFString refs + Blob content-type buffers (monotonic RSS growth under HTTP load)
H4: convert_utf16_to_utf8_in_buffer widened from infallible to fallible — ~10 Windows path call sites now panic or silently swallow
Verification — were these fixed before merge? I checked the merged code:
Bug
Status in merged code
H1 FileSink leak (Writable.rs)
Fixed.Self::pipe_release(pipe_nn) is called after to_js(global_this) at both the has_exited() and the destructor branch — exactly the recommended fix from the audit.
H3 Body::Value Drop
Fixed.impl Drop for Value exists at src/runtime/webcore/Body.rs:1555.
H2, H4
Did not verify in detail; the patterns above suggest fixes were landed.
So the divergence audit was used as a punch-list that was at least partially worked off before merge.
1b. NOALIAS_HUNT_REPORT.md (267 lines, deleted)
Far more alarming. A systematic LLVM-miscompile hunt motivated by an existing bug (NodeHTTPResponse::cork, commit b818e70e1c57) where LLVM cached ref_count: Cell<u32> across an opaque JS call because &mut self carries noalias.
ASM-verified NOT_CACHED (safe in current codegen, still UB):46
Inconclusive (Windows-only, no asm): 4
The 23 PROVEN miscompiles cluster into 8 groups of latent UAF / refcount leak / guard elision:
Cluster A (3): NodeHTTPResponse::{handle_abort_or_timeout, on_timeout, on_drain_corked} — ref_count fold across run_callback
Cluster B (3): EventLoop::{run_callback, run_callback_with_result, tick_immediate_tasks} — entered_event_loop_count fold; tick_immediate_tasks has the recursive-setImmediate guard fully eliminated by dead-code analysis
Cluster C (4): SSLWrapper flags byte cached across handler fn-ptrs — UAF on freed SSL* after re-entrant deinit
Cluster D (2): WindowsNamedPipe::{close, shutdown} — IS_CLOSED flag clobbered by stale store, deferred-drop never runs
Cluster E (3): PipeWriter — UAF on freed FilePoll / handle
Cluster F (1): PostgresSQLConnection::clean_up_requests — request-queue ptr dangles
G & H (and VirtualMachine::wait_for): the most dramatic — wait_for's cond: &mut bool becomes an unconditional jmp .LBB1720_2 infinite loop because *cond is checked only once before entry.
Verification — were the PROVEN fixes applied? I greped for the recommended mitigation patterns in the merged code (corrected and extended in a second pass against origin/main two days post-merge):
Cluster
Recommended fix
Status in merged code
A (NodeHTTPResponse)
convert &mut self → &self + *const shim
Applied.handle_abort_or_timeout, on_timeout, on_drain_corked all take &self; on_timeout_shim casts through *const. PORT NOTE: "R-2: deref as shared (&*const) — bodies take &self."
3 of 4 applied.update_handshake_state (line 849), shutdown (line 578), handle_writing (line 1069) all have the launder with explicit PORT_NOTES_PLAN R-2 comments. handle_reading (line 964) does NOT — still &mut self, no launder. The audit listed it as Cluster C #7 with ASM evidence (audit-line 79). This is a verified still-live PROVEN miscompile.
H.21 (VirtualMachine::wait_for)
Cell<bool> parameter + black_box
Applied at line 3520.
R-2 Phase 2/3 systemic codegen change — applied. Corrected from the v1 of this doc: the audit's "Systemic Recommendation" (R-2 Phase 2: .classes.ts codegen → &self + interior mutability) shipped via claude/r2-phase2-migration (merged into claude/phase-a-port at 75814df100 on 2026-05-11) and R-2 Phase 3 (commit 695d5a2f96). src/codegen/generate-classes.ts:2823 defaults sharedThis = true; the source comment at :2866 reads "R-2 Phase 3: default flipped to sharedThis: true. Every JS-exposed host-fn now receives &${T} (no noalias on the LLVM arg…)"; per-class opt-outs exist for the 9 types not yet migrated (ResumableSink.classes.ts: "R-2 Phase 3 opt-out: ResumableSink host-fns still take &mut self"). The 62-commit r2-phase2-migration branch contains per-type R-2 Phase 2 migrations (BlockList, Crypto, ResolveMessage, S3Client, BuildMessage, TextEncoderStreamEncoder, NodeJSFS, etc.) plus the FileSink callback-chain R-2 (commit 116f9c7595) and BunString RAII Phase 2 (commit afd5a5decf, OwnedStringCell + OwnedResolvedSource). This is real systemic engineering at the codegen layer.
The handle_reading instance is the consequential leftover: the audit found the class, the codegen change addresses the class, the per-site pass closed 22 of 23 PROVEN miscompiles, but this one re-entrant &mut self method (not a JS host-fn, so untouched by the codegen change) escaped the per-site fix while its three siblings got it.
2. Census of the merged Rust surface
Counts taken on the PR head (= what was merged, modulo first post-merge "Delete stray files" commit):
Metric
Count
.rs files
1,448
Files containing any unsafe
776 (54%)
unsafe { } blocks
10,394
unsafe fn
1,077
transmute
80
mem::forget / ManuallyDrop::new
174
.expect("unreachable")
672 ← every one is the H4-class pattern
unimplemented!() / todo!() (live)
1
TODO(port) comments
3,376
PORT NOTE comments
5,922
For comparison: tokio's entire workspace has ~1,500 unsafe blocks. This codebase has ~7× more unsafe blocks than tokio at a similar order of magnitude in size. The .expect("unreachable") count is the most striking — the divergence audit (H4 + the error-union-vs-result category as a whole) called out exactly this pattern as a class of reachable panics, and 672 of them shipped.
Sampled TODO(port) comments show real semantic gaps still flagged in code:
"arena-owned slice" — Vec ownership not reconciled with the Zig arena model (recurring)
"Vec Drop semantics must distinguish arena-backed vs heap-backed to..."
"jsonStringify — Zig std.json protocol; Phase B picks a serde strategy" — JSON serialization wasn't ported, still TODO
"narrow error set" — fallible signatures that should be infallible (the H4 class)
"move to *_jsc" — layering work not done
3. The methodology, with the spec/workflow files recovered
The 51 phase workflow files (51 .claude/workflows/phase-*.workflow.js, deleted by the first post-merge commit) and the spec docs (deleted in 10cfa0b237) are present on pr-30412. The methodology I summarized in the May-10 gist is intact and verified against those files. The summary stands:
Phases A → H, agent-orchestrated, structured-output schemas
Phase-A: Implement → Verify → Fix per file
Phase-B2: 2-vote verify + 3rd-agent tiebreak for logic-bug claims; default to confirmed=false
Spec corpus = rust-rewrite-plan.md (924 lines) + PORTING.md (756 lines) + .rust-rewrite-verified-claims.md (1,355 lines of 3-vote-adversarially-verified claims)
Verification: layout assert! consts, Bun test suite, shadow-diff per flip, ≤2% perf microbench gate, 2/3-vote agent review, static ISA verifier (scripts/verify-baseline-static/), ASAN, the noalias hunt
No formal verification — no Verus/Kani/Prusti/Creusot; no vstd in Cargo.toml
4. Final-week shape of the work (May 10 → May 14)
Drawing on the recovered docs + the late commits:
Noalias hunt completed. The 277→73→23+46 funnel was a substantive auditing exercise. Spot fixes were applied for the 23; the 46 were left and the doc was deleted.
Divergence audit completed. 26 cross-language divergences classified; at least H1/H3 fixed in code; the doc was deleted.
Perf fight. PGO training reweighted for cold startup; cold/inline(never) annotations on clap/cli/bundler; js_parser/js_printer const-generic monomorphization rolled back to runtime fields/args; the two-stage PGO build was dropped right before merge.
Test churn.e99311e584 reverted "non-additive test changes" (the @skyfallwastaken Bun.sleep concern was a real pattern). 591214fd67 lowered TOML/JSONC stack-depth thresholds because Rust release frames are smaller than Zig. 78d74e84dc un-gated a sourcemap test and a jsc-stress Wasm block (i.e. tests had been disabled).
Reverts in the final 48h. Eager host-timezone seeding (8a621d7087). Four commits to fix Windows process.env proxy-var case-insensitive detection + DontEnum clearing.
Review process.Zero human approving reviews. CodeRabbit posted 78 + 11 actionable comments. claude[bot] posted dozens. The substantive verification was the agent workflows (now deleted from main).
5. Post-merge fix commits on main (first 48h: 22 commits)
Class (D-taxonomy from LESSONS_LEARNED.md)
Commits
Examples
D2 — class flagged in audit, instance missed
1
f7c692ae9c worker-teardown SIGABRT (H3 class — refcount transfer / dupeRef missing on synthetic-module specifiers)
D3 — unflagged class
3
2a3d0e7d29 Windows path-separator applied to package specifiers; 314d044c0a JSON lexer rejecting operators before define auto-quote could recover; 428f61eb34 h3→http3 rename incomplete
D4 — test gap
1
bbd3e624af FFI dlopen() missing bunfs extraction (third Zig call site not tracked in PORT NOTE)
Android cfg gates (46 files); CI format job pointed at zig fmt; debug-only code in release; FFI binding mismatch; etc.
Two structural observations:
PR #30722's body says 3 auto-applied agent fixes were dropped because they themselves had bugs — #61 SSL exception leak introduced a use-after-free; #68 YAML merge dedup stored non-'static byte view in 'static field; #104 archive overwrite precheck added dead gating. This is the methodology generating fixes that introduce new bugs of the same class. Human review caught it, the agents did not.
0 of 22 post-merge commits update the PORT STATUS: trailer. The convention died at merge.
The merge is into canary, not stable. Jarred himself flagged it isn't ready for non-canary. The deletion of the audit docs that catalogued shipped-known bugs, combined with the squash-merge that erased the 32-branch orchestration graph, are the methodology-preservation concerns; the post-merge bug pattern is the execution concern.
6. Honest assessment (revised)
Methodology: ambitious, well-engineered, agent-orchestrated. The spec-first approach + 2/3-vote adversarial review + ASM-verified noalias hunt + R-2 Phase 2/3 codegen-layer systemic fix + 32-branch sub-task orchestration with 43+ self-reverts is genuinely sophisticated. Not formal verification, but the strongest empirical scheme I've seen for an AI-authored rewrite this large.
Execution: the spec was used as a real punch-list. HIGH-risk divergences (FileSink leaks, Body::Value Drop) were fixed. 22 of 23 PROVEN-CACHED noalias miscompiles were fixed via spot patches + the R-2 Phase 2/3 codegen change shipped. The one verified leftover is handle_reading at src/uws/lib.rs:964 — Cluster C #7 — whose three siblings got the launder while it kept its &mut self receiver.
Concerns:
One verified PROVEN miscompile (handle_reading) shipped despite the audit explicitly listing it.
A 36-finding post-merge security audit landed 8 days later — class found, instances escaped.
672 .expect("unreachable") calls in merged tree. How many are reachable per the audit's H4 class is (inference) — could be ~10 or substantially higher; the audit didn't enumerate the full set.
No human approving review. 3 of 36 PR #30722 agent-authored fixes were dropped because they were wrong — a human caught it.
Tests were a moving target right up to merge — disabled then re-enabled, depth thresholds lowered, "non-additive changes" reverted, etc.
The audit/spec/workflow artifacts were systematically removed from main (10cfa0b237) and the orchestration graph was squashed (23427dbc12) — making the methodology and known-bug list unreproducible from the public repo.
Artifacts
PR branch: locally fetched as pr-30412 (commit ed1a70f817 head before merge)
Pre-deletion commit: 10cfa0b237^ — has all spec + audit + workflow files intact
Full text of recovered audits available at pr-30412:docs/{ZIG_RUST_DIVERGENCE_AUDIT.md,NOALIAS_HUNT_REPORT.md,NOALIAS_SUSPECTS.md,PORTING.md,rust-rewrite-plan.md,LIFETIMES.tsv,...} and in this gist
Squash-merge commit on main: 23427dbc12
32 claude/* sub-branches (visible only via git log pr-30412 ^MERGE_BASE --first-parent + merge-commit messages)
You are translating one Zig file to Rust. Read this whole document before
writing any code. The goal of Phase A is a draft.rs next to the .zig
that captures the logic faithfully — it does not need to compile. Phase B
makes it compile crate-by-crate.
Ground rules
Write the .rs in the same directory as the .zig, same basename.<area> is always the first path component under src/ (the crate
root). If the .zig basename equals its immediate parent directory name
(any depth), name it mod.rs; if it equals the top-level <area> dir, name
it lib.rs. Examples: src/bake/DevServer/HmrSocket.zig →
src/bake/DevServer/HmrSocket.rs; src/bake/DevServer/DevServer.zig →
src/bake/DevServer/mod.rs; src/http/http.zig → src/http/lib.rs.
Do not invent crate layouts. Cross-area types are referenced as
bun_<area>::Type (see crate map below). Phase B wires the Cargo.toml.
No tokio, rayon, hyper, async-trait, futures. No std::fs,
std::net, std::process. Bun owns its event loop and syscalls. (Rust
core/std slice, iter, mem, fmt, and core::ffi are fine — only the
I/O-touching modules are banned.)
No async fn. Everything is callbacks + state machines, same as the Zig.
unsafe is fine when the Zig was already unsafe. Annotate every block
with // SAFETY: <why> mirroring the Zig invariant.
Leave // TODO(port): <reason> for anything you can't translate
confidently. Don't guess. Flagging is better than wrong code.
Leave // PERF(port): <zig idiom> — profile in Phase B wherever the Zig
used a perf-specific idiom (appendAssumeCapacity, arena bulk-free,
stack-fallback alloc, comptime monomorphization) and the port uses the plain
idiomatic form. Phase A optimizes for correctness+idiom; Phase B greps
PERF(port) and benchmarks.
Match the Zig's structure. Same fn names (snake_case), same field order,
same control flow. Phase B reviewers diff .zig ↔ .rs side-by-side.
Acronyms collapse to one lowercase word: toAPI→to_api, isCSS→is_css,
toUTF8→to_utf8, toJS→to_js, errorInCI→error_in_ci. Rule: a run
of ≥2 uppercase letters is one segment.
Exception — out-param constructors.fn foo(this: *@This(), ...) !void
whose body assigns this.* = .{...} → fn foo(...) -> Result<Self, E>. Zig
uses out-params because it lacks guaranteed NRVO for error unions; Rust does
not. Diff readers should expect this reshape. If this is a pre-allocated
slot in a pool/array (in-place init to avoid a move), keep
&mut MaybeUninit<Self> and flag // TODO(port): in-place init.
Exception — deinit.pub fn deinit becomes impl Drop, not an
inherent method named deinit (see Idiom map).
Borrow-checker reshaping is allowed. When matching Zig flow yields
overlapping &mut, capture the needed scalar (.len(), index) into a local,
drop the borrow, then re-borrow. Do NOT reach for raw pointers just to
silence borrowck; leave // PORT NOTE: reshaped for borrowck so Phase B
diff readers aren't confused.
Prereq for every crate:#[global_allocator] static ALLOC: bun_alloc::Mimalloc = bun_alloc::Mimalloc;
must be set at the binary root before any Box/Rc/Arc/Vec mapping in
this guide is valid — otherwise you silently switch from mimalloc to glibc
malloc. Phase B asserts this; Phase A can assume it.
Crate map
@import("bun").X → look up X here. @import("../<area>/file.zig") →
bun_<area>::file::Thing.
bun_paths::SEP: u8, SEP_STR: &str, DELIMITER: u8, is_absolute(&[u8]) — do NOT use std::path (operates on OsStr, wrong type)
bun.windows, bun.c, bun.darwin, bun.linux
bun_sys::windows etc.
bun.c is translated-c-headers
bun.hash(...)
bun_wyhash::hash
wraps std.hash.Wyhash (seed 0), NOT Wyhash11
bun.Wyhash11
bun_wyhash::Wyhash11
port of an earlier Zig wyhash — used for on-disk compatibility (lockfile, npm manifest cache, integrity). Distinct from std.hash.Wyhash. Do NOT swap to Wyhash in bun_install — existing .lockb/.npm files would invalidate.
If it's not in this table: the crate is bun_<top> where <top> is the
first directory under src/ (verbatim — crash_handler →
bun_crash_handler, bun_alloc stays bun_alloc, no double prefix).
Intermediate directories become module path segments, snake_cased:
src/bake/DevServer/Assets.zig → bun_bake::dev_server::Assets.
Type map
c_int, c_char, c_void come from core::ffi::* — they are not in the
prelude.
Zig
Rust
notes
[]const u8
fn param/return → &[u8]. Struct field → look at deinit in this file: if it calls allocator.free(self.field) → Box<[u8]> (or Vec<u8> if it grows); if never freed and only ever assigned literals → &'static [u8]; if arena-owned (CSS/parser) → raw *const [u8] / StoreRef (see Allocators). Same split applies to []const T generally.
never put a lifetime param on a struct in Phase A — Box vs &'static vs raw is the decision
[]u8
&mut [u8]
[:0]const u8
&ZStr (bun_str::ZStr)
length-carrying NUL-terminated slice
[:0]u8
&mut ZStr (bun_str::ZStr)
length-carrying NUL-terminated mutable slice
[:0]const u16
&bun_str::WStr
length-carrying NUL-terminated UTF-16 slice
[:0]u16
&mut bun_str::WStr
[*:0]const u8
*const c_char in extern "C" signatures and #[repr(C)] fields; &CStr everywhere else (fn params/returns inside Rust)
convert at the FFI boundary with CStr::from_ptr
?T
Option<T>
?*T / *T / *const Tstruct field
look it up in docs/LIFETIMES.tsv (cols: file·struct·field·zig_type·class·rust_type·evidence) and use the rust_type column verbatim. Classes: OWNED→Box<T>, SHARED→Rc/Arc<T>, BORROW_PARAM→&'a T (struct gets <'a>), STATIC→&'static T, JSC_BORROW→&JSGlobalObject etc., BACKREF/INTRUSIVE/FFI→raw *const/*mut T, ARENA→&'bump T, UNKNOWN→Option<NonNull<T>> + // TODO(port): lifetime.
the TSV is pre-computed cross-file analysis; trust it over local guessing
?*T / *T / *const Tfn param/return (not a field)
Option<&T> / &mut T / &T
raw ptr only at extern "C" boundary
anyopaque
core::ffi::c_void
anyerror!T
Result<T, bun_core::Error>
always in Phase A. bun_core::Error is not an enum: #[repr(transparent)] #[derive(Copy, Clone, Eq, PartialEq, Hash)] pub struct Error(NonZeroU16) with a link-time-registered name table; bun_core::err!("ENOENT") interns the tag and yields a const Error; .name() -> &'static str returns the exact Zig tag. Every per-crate thiserror enum auto-derives Into<bun_core::Error>. Neveranyhow::Error / Box<dyn Error> — heap-allocates, !Copy, breaks @errorName snapshot compat and the 77 struct fields that store bare errors. Phase B narrows to local enums where the call graph permits.
!T (inferred error set)
Result<T, bun_core::Error>
same as anyerror!T in Phase A; leave // TODO(port): narrow error set. Exception: if the body's only try sites are allocations, use Result<T, bun_alloc::AllocError> directly.
anyerror (bare value: field/param/local)
bun_core::Error
the CopyNonZeroU16 newtype above. Never Box<dyn Error> / anyhow::Error — Zig errors carry no payload; a fat trait object loses Copy/Eq and cannot live in #[repr(C)] payloads.
OOM!T / bun.OOM!T / error{OutOfMemory}!T
Result<T, bun_alloc::AllocError>
re-exported as bun_core::OOM; From<AllocError> for bun_core::Error and for bun_jsc::JsError provided. bun.JSOOM!T → bun_jsc::JsResult<T> (JsError already has OutOfMemory).
error{A,B}!T
Result<T, FooError> where #[derive(thiserror::Error, strum::IntoStaticStr)] enum FooError { A, B }
IntoStaticStr provides the @errorName string; impl From<FooError> for bun_core::Error.
bun.JSError!T
bun_jsc::JsResult<T>
Maybe(T) (bun.sys)
bun_sys::Result<T>
tagged { Ok(T), Err(SysError) }
JSC.JSValue
bun_jsc::JSValue
#[repr(transparent)] i64, Copy, !Send
*JSC.JSGlobalObject
&bun_jsc::JSGlobalObject
always borrowed, never owned
JSC.CallFrame
&bun_jsc::CallFrame
bun.String
bun_str::String
see "Strings"
bun.PathBuffer ([MAX_PATH_BYTES]u8)
bun_paths::PathBuffer
var buf: bun.PathBuffer = undefined; → let mut buf = bun_paths::PathBuffer::uninit();
bun.WPathBuffer
bun_paths::WPathBuffer
[u16; MAX_PATH], Windows
std.mem.Allocator
&dyn bun_alloc::Allocator
see "Allocators"
u32, i64, usize, c_int
u32, i64, usize, c_int
1:1
bool
bool
packed struct(uN)
bitflags! if every field is bool; otherwise #[repr(transparent)] pub struct Foo(uN) with manual const/shift accessors matching field order
enum(uN)
#[repr(uN)] enum
union(enum)
enum with payload variants
Rust enums are tagged unions
extern struct
#[repr(C)] struct
pub const Foo = opaque {}; (FFI handle, used as *Foo)
opaque {} as type-tag (e.g. GenericIndex(u32, opaque {}))
drop entirely — declare a newtype: pub struct FooId(u32);
Zig needs opaque {} to mint distinct type params; Rust newtypes are already distinct
x: anytype
x: impl Trait if a single trait covers it (impl AsRef<[u8]>, impl Display); else a generic <T> bounded by the methods the body actually calls. If the body never calls a method on x (opaque context/userdata pattern), use an unbounded<C> — no trait; if stored across calls, <C: 'static> (and Box::into_raw when it round-trips through C as *mut c_void). For args: anytype in printf-style fns → core::fmt::Arguments via format_args!.
(comptime X: type, arg: X) paired params
drop the type param; write arg: &mut impl Trait (or <X: Trait>(arg: X) if X is reused in another position). For writers: &mut impl core::fmt::Write (text) / &mut impl bun_io::Write (bytes).
Zig's verbose spelling of arg: anytype when the type needs naming
Idiom map
Zig pattern
Rust pattern
defer x.deinit()
delete the line — impl Drop for T makes it implicit at scope exit. Reach for ManuallyDrop<T>only when (a) the value is arena-allocated and freed by arena.reset() not per-value, (b) destruction order must differ from declaration order and matters for correctness (rare — add // PORT NOTE), or (c) the type is the m_ctx payload of a .classes.ts class and finalize() owns teardown. Never expose pub fn deinit(&mut self) as the public API; if explicit early release is needed (sockets, fds), name it close(self) taking ownership.
pub fn deinit(self: *T) (definition)
impl Drop for T. If the body only frees/deinits owned fields, delete the body entirely — Rust drops Box/Vec fields automatically. Keep an explicit Drop body only for side effects beyond freeing (closing FDs, deref-ing intrusive refcounts, FFI destroy calls). If deinit takes an allocator param, retype the fields to own their allocator (Box/Vec, not raw slices) — Drop cannot take params. Types that are #[repr(C)] and constructed/destroyed across FFI keep an explicit unsafe fn destroy(*mut Self) instead; .classes.ts payloads use finalize, not Drop (see §JSC).
delete — retype the field/local as Box<[T]> / Vec<T> so Drop (or scope exit) frees it. Only keep an explicit alloc.dealloc(ptr, layout) when the allocation came from a non-default allocator that must be matched. Arena-allocated slices are never individually freed (the Zig won't have allocator.free for them anyway).
defer pool.put(x) after pool.get()
The Rust pool returns a guard: let x = bun_paths::path_buffer_pool().get(); — guard Derefs to &mut PathBuffer and puts back on Drop. Do not hand-roll a defer here.
errdefer x.deinit() / errdefer alloc.free(x) (local you just constructed)
delete it. Once x is Vec/Box/any Drop type, ? drops it automatically on the error path. No guard, no inline cleanup.
errdefer { <side effects> } (rolls back a counter, unregisters from a map, closes a remote handle — anything beyond freeing a local)
let guard = scopeguard::guard(state, |s| <cleanup>); and on the success path let state = ScopeGuard::into_inner(guard); to disarm. Never hand-roll a Drop struct + mem::forget. Leave // TODO(port): errdefer only when the cleanup captures ≥2 disjoint &mut borrows that scopeguard cannot express.
DO NOT write scopeguard::guard((), |_| ...) — that's transliterated Zig. The Rust answer is RAII: (a) Output::flush() → the buffered writer should impl Drop and flush itself; (b) lock.unlock() → use a MutexGuard (lock returns a guard, drop unlocks); (c) x.ref_count -= 1 → wrap in an Rc/Arc-shaped owner. If the cleanup is genuinely one-off and a Drop type doesn't fit, scopeguard::defer! { ... } is the last resort (macro form, no unit-state noise). scopeguard::guard with unit state () is banned — it always means a missed RAII opportunity.
comptime T: type param
plain generic <T> (add a trait bound for whatever methods the body calls — usually one already exists). Not const generics — Rust const generics carry values, not types.
comptime flag: bool / comptime n: uN param
<const FLAG: bool> / <const N: uN> const generic. If the param is only forwarded and never used in a type/const position, demote to a runtime arg and leave // PERF(port): was comptime monomorphization — profile in Phase B. Do not demote when the bool gates a hot inner-loop branch (e.g. enable_ansi_colors in printers, ssl in NewHTTPContext).
comptime e: SomeEnum param
<const E: SomeEnum> with #[derive(core::marker::ConstParamTy, PartialEq, Eq)] on the enum.
comptime on an expression
const fn / const { }. Reach for macro_rules! only when the Zig is doing token-pasting or type-list iteration with no shared trait.
pub struct Foo<T[, const OPTS...]> { ... } with impl<T> Foo<T> { ... }. The Zig fn name becomes the struct name; nested pub const/pub fn become associated items. Only use a macro_rules! type-generator when the body branches on @typeInfo to emit structurally different layouts (rare).
switch (u) { inline else => |v[, tag]| v.expr() }
match u { A(v) => v.expr(), B(v) => v.expr(), ... } written out (or generated by a small derive if >8 variants). If the payload types share the called method, that method belongs on a trait they all impl.
if the callee still needs <const B: bool>: if b { callee::<true>(...) } else { callee::<false>(...) }. If the comptime bool was only forwarded (never used in a type position), drop the const param, pass b at runtime, and leave // PERF(port): was comptime bool dispatch — profile in Phase B.
struct field default field: T = .{} / = "" / = 0
#[derive(Default)] if every default is the field type's Default; otherwise impl Default for T { fn default() -> Self { ... } }. Callsites .{} → T::default(). For = "" on owned slice fields, the Default is Box::default() (empty slice).
concat!(...) for literal concatenation, or const_format::formatcp!(...) which yields &'static str. Neverformat! — that heap-allocates at runtime where Zig paid zero.
const x = brk: { ...; break :brk v; }
Rust labeled block (stable since 1.65): let x = 'brk: { ...; break 'brk v; };. Works for early breaks too — no loop hack, no helper fn. Only hoist to a helper if the block is >40 lines AND has ≥3 break points (and leave // TODO(port): hoisted from labeled block). If there are no early breaks, a plain let x = { ...; v }; suffices.
const Foo = @This(); (file-level)
drop — name the pub struct Foo { … } directly.
@This() inside a generic fn body
Self (the impl's inherent Self).
@as(T, x)
drop it — Rust infers the result type. If used to set the result type of a nested cast (@as(u32, @intCast(x))), write the target type on the cast itself (u32::try_from(x).unwrap() / x as u32). For type ascription on a binding, use let v: T = x;.
@fieldParentPtr("field", ptr)
unsafe { &mut *(ptr as *mut _ as *mut u8).sub(core::mem::offset_of!(Parent, field)).cast::<Parent>() } with // SAFETY: ptr points to Parent.field. (offset_of! stable since 1.77.)
@ptrCast / @alignCast
ptr.cast::<T>() / &*(p as *const T) in unsafe
@intFromEnum(e)
e as uN
@enumFromInt(n)
unsafe { core::mem::transmute::<uN, E>(n) } (with #[repr(uN)]) or a const fn E::from_raw(n: uN) -> E that debug-asserts range. NeverFromPrimitive in hot paths — it generates a runtime match over every variant.
@intCast(x)
T::try_from(x).unwrap() (narrowing — always checked; Phase B may swap to as in proven-hot loops with // PERF(port): @intCast) or x.into() / T::from(x) (widening — infallible). Never bare as for narrowing; reserve bare as for @truncate.
@truncate(x)
x as T (intentional wrap)
@intFromBool(b)
b as uN (or usize::from(b)). Compiles to the same single instruction; no branch.
@floatFromInt(x)
x as f64 (lossless for ≤52-bit ints; otherwise Zig also rounds).
@intFromFloat(x)
x as uN — note: Rust saturates on overflow/NaN where Zig is UB. If the Zig relied on prior range checks, keep them; do not add new ones.
@bitCast(x)
unsafe { core::mem::transmute(x) } for same-size POD; prefer safe forms when they exist: f64::to_bits/from_bits, u32::from_ne_bytes, packed-struct .bits().
@intFromPtr(p)
p as usize (or p.addr() strict-provenance)
@ptrFromInt(n)
n as *mut T in unsafe; if round-tripping a real pointer, prefer ptr.byte_add(off) to keep provenance.
@memcpy(dst, src)
dst.copy_from_slice(src) (panics on len mismatch, same as Zig; non-overlapping only)
bun.copy(T, dst, src)
dst[..src.len()].copy_from_slice(src) (matches Zig: dst.len() >= src.len() allowed). If src and dst may overlap (same buffer): dst.copy_within(range, dest_idx) or unsafe { core::ptr::copy(src.as_ptr(), dst.as_mut_ptr(), src.len()) }.
@memset(dst, v)
dst.fill(v); for zeroing raw bytes: unsafe { ptr::write_bytes(p, 0, n) }
@min(a, b) / @max(a, b)
a.min(b) / a.max(b) (method form, avoids Ord import). For >2 args use [a, b, c].into_iter().min().unwrap(). If Zig was relying on peer-type widening, cast the narrower operand first.
@tagName(e)
<&'static str>::from(e) (or e.into()) — #[derive(strum::IntoStaticStr)] on the enum. For union(enum) ported to a Rust enum, same derive.
@errorName(e)
<&'static str>::from(e) — #[derive(strum::IntoStaticStr)] on the error enum. For bun_core::Error the crate provides .name() -> &'static str. Never use Display/to_string() here — that is the human message, not the tag, and diverges from Zig output (snapshot tests, JS error.code, crash-handler trace encoding all depend on the exact string). Never format!("{e:?}").
a.wrapping_add(b) / a.wrapping_sub(b) / a.wrapping_mul(b) — do not use bare +; Rust panics in debug.
std.math.maxInt(T) / std.math.minInt(T)
T::MAX / T::MIN
std.mem.zeroes(T)
unsafe { core::mem::zeroed::<T>() }only if T is #[repr(C)] POD with no NonNull/NonZero/enum fields. Otherwise implement T::ZEROED / Default by hand. Add // SAFETY: all-zero is a valid T.
std.mem.span(p) on [*:0]const u8
unsafe { CStr::from_ptr(p) }.to_bytes() or bun_str::ZStr::from_ptr(p).
std.mem.sliceTo(buf, 0)
&buf[..buf.iter().position(|&b| b == 0).unwrap()] (or bun_str::slice_to_nul(buf)).
inline for over a tuple
if all elements are the same type, use a const [T; N] + plain for. Only reach for macro_rules!/unrolling when elements are heterogeneous types.
for (slice, 0..) |x, i|
for (i, x) in slice.iter().enumerate()
for (a, b) |x, y|
for (x, y) in a.iter().zip(b) — Zig asserts a.len == b.len; add debug_assert_eq!(a.len(), b.len()) because zip silently truncates.
for (a, b) |x, *y|
for (x, y) in a.iter().zip(b.iter_mut())
switch on tagged union
match
catch |e| { ... }
.map_err(|e| ...)? or explicit match
x catch |e| switch (e) { error.A => fa, error.B => fb, else => fe }
match x { Ok(v) => v, Err(FooError::A) => fa, Err(FooError::B) => fb, Err(_) => fe } when the error type is a local enum. When the error is bun_core::Error, match against interned consts: Err(e) if e == bun_core::err!(ENOENT) => …. Never compare e.name() to a string literal.
x catch return <expr> (no capture)
let Ok(v) = x else { return <expr>; } — or .ok()? when the enclosing fn returns Option and <expr> is null.
x.expect("unreachable") (or .unwrap_or_oom() if it's an alloc). Do not turn into ?, and do not use unwrap_unchecked() — keep the safety check until Phase B proves the invariant.
bun_str::w!("...") macro → &'static [u16] (.len() excludes the trailing NUL, matching Zig [:0]const u16 — backing storage has NUL at [len]).
bun.strings.fooComptime(x, "lit")
bun_str::strings::foo(x, b"lit") — drop the Comptime suffix; Rust &'static [u8] literal is already const-propagated.
bun.assert(x)
debug_assert!(x)
comptime bun.assert(x)
const _: () = assert!(x); at item scope. Inside an inline for body, hoist to a per-element const or drop it (Phase B).
bun.unreachablePanic(...) / unreachable
unreachable!()
@branchHint(.cold)
#[cold] on the fn, or if cold_path() { #[cold] fn cold() {..} cold() }
bun.Output.scoped(.X, .vis)("fmt", .{a,b})
bun_output::scoped_log!(X, "fmt {} {}", a, b); — visibility is encoded by registering the scope once with bun_output::declare_scope!(X, hidden); at module level. Zig {s} on []const u8 → wrap arg in bstr::BStr::new(x) (Display impl over bytes); do not from_utf8 — bytes may not be valid UTF-8. scoped_log! MUST expand to if cfg!(feature="debug_logs") && SCOPE.enabled() { ... } so arg expressions are inside the dead branch. Do not pre-build format_args! outside the gate — that forces evaluation of every interpolated expr in release.
threadlocal var X: T = init;
thread_local! { static X: Cell<T> = const { Cell::new(init) }; } — the const { } initializer (stable 1.59+) elides the lazy-init branch. Access via X.with(|x| ...). For large buffers (threadlocal var buf: PathBuffer): thread_local! { static BUF: RefCell<PathBuffer> = const { RefCell::new(PathBuffer::ZEROED) }; } and BUF.with_borrow_mut(|b| ...).
impl core::fmt::Display for T { fn fmt(&self, f: &mut Formatter) -> fmt::Result { ... } }. If the Zig wraps another value (struct { x: *X } + format()), make it a tuple newtype pub struct XFmt<'a>(&'a X); with Display.
pub const X = @import("../foo_jsc/..").y; (the *_jsc alias)
delete it. In Rust, to_js/from_js are extension-trait methods that live in the *_jsc crate. The base type has no mention of jsc.
Comptime reflection
@TypeOf(param) where param: anytype → drop it; name the generic <T>
and use T directly. Zig needs @TypeOf because anytype is unnamed; in Rust
the generic param IS the name. @TypeOf only needs special handling when fed
into @typeInfo (true reflection) — see below.
@typeInfo(T) / @field(x, "name") have no Rust equivalent. Strategy:
If used to iterate struct fields to implement equality/hash/clone/drop →
#[derive(PartialEq, Eq, Hash, Clone)] (and Drop by hand). If iterating
fields to implement a domain protocol (toCss, parse, toJS) → make the
protocol a trait and impl it per type (a targeted #[derive(ToCss)] is fine,
but the trait comes first). Only reach for a generic Fields reflection
derive when the body truly needs field NAMES at runtime.
if (@hasDecl(T, "foo")) T.foo(x) else @compileError(...) → drop the if;
add trait bound T: Foo and call x.foo(). @hasDecl is Zig's structural
duck-typing check — a trait bound IS that check.
if (@hasDecl(T, "foo")) T.foo(x) else default_expr (optional behavior) →
trait with a default method, or a blanket impl that the type can override.
Never a runtime check.
If used to inspect a fn signature (the host_fn pattern) → proc-macro
attribute; leave // TODO(port): proc-macro.
@field(x, comptime name) for intrusive lists → keep raw-ptr offset via
core::mem::offset_of!(T, field) (stable since 1.77).
Strings
Data is bytes, not str. Do not use std::string::String / &str /
.to_string() / String::from_utf8* for file paths, source code, HTTP
bytes, module specifiers, env vars, or anything that came from a syscall or
the network. These are &[u8] / Vec<u8> / Box<[u8]>. Bun handles WTF-8
and arbitrary bytes; inserting UTF-8 validation is both a perf tax and a
correctness bug (rejects valid Linux paths, lone surrogates, etc.).
Only use &str/String for: (a) string literals you wrote, (b) the final
hop into a Rust API that genuinely requires &str (rare — and use
bstr::BStr::new(bytes) for Display/Debug instead of from_utf8_lossy).
Never .unwrap() a from_utf8 on external data.
Zig
Rust
[]const u8 (text-ish)
&[u8] — not&str
owned text buffer that grows
Vec<u8> — notString
std.mem.eql(u8, a, b)
a == b
slice Eq
bun.strings.eqlComptime(a, "lit")
a == b"lit"
byte literal
bun.strings.hasPrefix / hasSuffix
a.starts_with(p) / .ends_with(p)
bun.strings.indexOfChar(a, c) / indexOfScalar
bun_str::strings::index_of_char(a, c)
FFI → highway_index_of_char SIMD. Notmemchr/bstr.
bun.strings.indexOf(a, n)
bun_str::strings::index_of(a, n)
highway SIMD substring
bun.strings.indexOfAny(a, set) / indexOfAnyT
bun_str::strings::index_of_any(a, set)
FFI → highway_index_of_any_char
bun.strings.containsChar / contains
bun_str::strings::index_of_char(..).is_some()
bun.highway.*
bun_highway::*
direct extern "C" re-exports; same C++
any other bun.strings.<fn> not listed
bun_str::strings::<fn>
port src/string/immutable.zig 1:1; do NOT substitute bstr/memchr for hot-path scanners
cold-path byte ops with no bun.strings equivalent (.split(), .trim_ascii(), ad-hoc .find())
bstr::ByteSlice ext trait
OK here only
std.fmt.allocPrint(a, "..", .{})
build into Vec<u8> with use std::io::Write; write!(&mut v, ..)
drop allocator; never format! (returns String)
std.fmt.bufPrint(buf, ..)
write!(&mut &mut buf[..], ..)
std::io::Write on &mut [u8]
Shared/ref-counted strings stay shared.bun.String is the
WTFString-backed shared buffer (crosses to JSC without copy). Keep it as
bun_str::String — do not "simplify" to Arc<str> or String; you lose
zero-copy JS interop and Latin-1/UTF-16 storage.
bun.String is a 5-variant tagged union over WTF-backed and Zig-slice-backed
strings. In Rust:
// bun_str::String — #[repr(C)] struct { tag: u8, value: StringValue }// NOT a Rust enum (C++ mutates tag and value independently across FFI).
s.toUTF8(alloc) → s.to_utf8() returning bun_str::Utf8Slice<'_> (borrows
if already UTF-8, else owns the transcoded buffer; Drop frees). No allocator
param. This is encoding (WTF-16→UTF-8), not validation — output is bytes.
s.toJS(global) → s.to_js(global) — only callable in *_jsc/runtime/jsc crates via the StringJsc extension trait. If your file is in a base crate and calls .toJS, leave // TODO(port): move to *_jsc.
bun.String.borrowUTF8(slice) → bun_str::String::borrow_utf8(slice) (caller keeps slice alive — 'a lifetime on the borrow).
pubstructZStr<'a>{ptr:*constu8,len:usize,_p:PhantomData<&'a[u8]>}// .as_bytes() / .as_ptr() / .as_cstr() — len does NOT include the NUL.
Construct from a buffer you just NUL-terminated:
unsafe { ZStr::from_raw(buf.as_ptr(), len) } // SAFETY: buf[len] == 0 written above.
For [:0]u16 use WStr::from_raw (&WStr) or WStr::from_raw_mut(buf.as_mut_ptr(), len)
(&mut WStr). Same for ZStr::from_raw_mut.
Allocators
AST/parser crates keep arenas. Everything else uses the global allocator.
AST crates = js_parser, js_printer, css, bundler, bake, sourcemap,
shell (parser), interchange, install/lockfile. These build large trees of
small nodes bulk-freed at end-of-parse; arena allocation is load-bearing for
throughput.
In AST crates:
MimallocArena / std.heap.ArenaAllocator → bumpalo::Bump (re-exported as
bun_alloc::Arena).
std.mem.Allocator param (when callers in this file pass an arena) →
bump: &'bump Bump. Thread it. The struct/fn gets a <'bump> lifetime.
When callers pass bun.default_allocator → delete the param (global mimalloc).
std.ArrayList(T) / ArrayListUnmanaged(T) fed an arena →
bumpalo::collections::Vec<'bump, T>. .append(a, x) → v.push(x) (arena
bound at construction, not per-call).
Expr.Data.Store / Stmt.Data.Store / ASTMemoryAllocator are typed slabs
with stable addresses (nodes reference each other) → typed_arena::Arena<T>.
Returns &'arena T, never moves. Cross-node refs are &'arena Expr. Do not
convert to Vec<Expr>.
In all other crates:
std.mem.Allocator param → delete it.Box/Vec/String use global
mimalloc.
MimallocArena / ArenaAllocator local → delete the arena and its
.reset()/.deinit(). Only leave // PERF(port): was arena bulk-free if
the body allocates per-iteration in a hot loop.
allocator.dupe(u8, s) → Box::<[u8]>::from(s) (or s.to_vec() if it
grows). allocator.dupeZ → bun_str::ZStr::from_bytes(s).
allocator.alloc(T, n) → vec![T::default(); n].into_boxed_slice() or
Box::new_uninit_slice(n) if uninitialized.
StackFallbackAllocator → just use the heap; // PERF(port): was stack-fallback.
Everywhere:
bun.default_allocator → delete the expression.
bun.new(T, init) / bun.destroy(p) → Box::new(init) / drop(b). If the
pointer crosses FFI as *mut T, use Box::into_raw / Box::from_raw.
bun.handleOom(expr) → expr (Rust Vec/Box allocation aborts on OOM;
handleOom was Zig's panic-on-OOM wrapper, which is now the default).
Forbidden patterns
Never write these. The verify gate flags them as logic-bug.
Re-implementing C/C++ library code in Rust. We port Zig, not C++.
If the .zig calls into WTF, JSC, BoringSSL, simdutf, highway, mimalloc,
libarchive, lol-html, c-ares, lsquic, libuv, picohttp, ICU, libdeflate,
zlib, brotli, zstd, tinycc, etc. — those .a/.o files are linked into
the binary. Declare extern "C" { fn X(...) } (often already in a *_sys
crate) and call it. Never hand-roll a Rust equivalent. Never add a
crates.io dep (chrono, ring, sha2, …) to replace something the
linked C/C++ already provides. Find the C signature with
grep -rn '<symbol>' src/jsc/bindings/ vendor/.
Exception: if the Zig calls a pure-Zig stdlib function (e.g.
std.hash.Wyhash, std.hash.XxHash64, std.crypto.pwhash.argon2) with
no C backing, prefer a well-tested crates.io dep (twox-hash,
argon2, bcrypt, adler, etc.) over hand-porting the algorithm —
hand-ported bit-twiddling is a bug magnet. Only hand-port if no crate
matches the exact algorithm variant (e.g. Wyhash11 is Bun-specific).
Box::leak / mem::forget to satisfy a &'static lifetime — the field
should be Box<[T]> / Vec<T> / Cow<'static, [T]>, not &'static [T].
If the Zig freed it (via arena or deinit), Rust must too.
Only exception: a true process-lifetime singleton, and even then use
OnceLock<T> / LazyLock<T>, never Box::leak.
ManuallyDrop<T> without a paired unsafe { ManuallyDrop::drop(..) } or
into_inner on every exit path. If you can't point to where it drops, it
leaks. Prefer an enum over a union+ManuallyDrop.
unsafe { &*(p as *const _) } to extend a lifetime — same as leak. Fix
the ownership.
.clone() on a &[u8] / Vec just to escape a borrow — restructure
(capture-len-then-reslice) or change the field type.
todo!() / unimplemented!() / unreachable!() as a stub — NEVER. Port
the real body from the .zig spec. There is no "blocked_on" deferral.
#[cfg(any())] / #![cfg(any())] — NEVER. There is no legitimate gate.
Dep-cycle "fixes" via fn-ptr hooks — NEVER. If bun_alloc imports
bun_runtime, the code is in the WRONG CRATE. Fix the architecture:
(a) move the code to the crate it actually belongs in, OR (b) strip
the bogus dependency by porting whatever it needs locally. A dep cycle is a
layering bug, not something to route around.
Concurrency
Rust enforces thread-safety at compile time via Send/Sync auto-traits.
Most Zig locks were defensive or init-once; they disappear.
Zig
Rust
lock: Lock + has_loaded: bool + data (lazy init)
static X: OnceLock<T> (or LazyLock<T> if init is const fn-ish)
std handles double-checked locking
lock: Lock around a refcount
Arc<T>
Arc's count is atomic
lock: Lock + single-producer→consumer queue
crossbeam::channel::{bounded,unbounded} or crossbeam::queue::SegQueue
lock-free
lock: Lock protecting data that only the JS thread touches
delete the lock; type is !Sync (contains JSValue/*mut JSGlobalObject which are !Sync), sharing won't compile
bun_threading::Guarded<T> (owns T) or bun_threading::RwLock<T>
the ~20% that stay
Futex/Condition wait
bun_threading::Condvar + Guarded
std.atomic.Value(T)
core::sync::atomic::Atomic*
same orderings (.monotonic→Relaxed, .acquire→Acquire, .release→Release, .seq_cst→SeqCst)
bun.threading.Once
std::sync::Once
Never raw std::sync::Mutex (poisoning is noise here); use
bun_threading::Guarded. Crates layered belowbun_threading (bun_alloc,
bun_core, bun_ptr, …) use bun_core::Mutex / bun_core::RwLock instead —
poison-free std::sync newtypes with the same API.
Never put a lock next to the data — Mutex<T>owns T. If the Zig had
lock: Lock, table: HashMap → Rust is table: Mutex<HashMap>.
When unsure if a lock is defensive: delete it, mark the type // PERF(port): was Lock-guarded — verify !Sync is sufficient. Phase B cargo check will
error if another thread actually needs it (T: Sync bound fails) and you add
the Mutex<T> then.
Dispatch (union(enum) across crate tiers)
Zig's union(enum) { A: *Foo, B: *Bar, fn run(self) { switch(self) { inline else => |p| p.run() } } }
is closed-set dynamic dispatch with inlined arms. When the variants live in
a higher-tier crate than the union, a naïve port creates a cycle. Break it
without losing the inlining:
Cold path (called per-request, not per-tick — most cases):
Low tier defines a manual vtable; high tier provides static instances.
Indirect call; LTO will not devirtualize a heterogeneous list. Acceptable
when the callee does real work (syscall, JS callback). Mark
// PERF(port): was inline switch.
Hot path (per-tick dispatch — short list below):
Low tier stores (tag: u8, ptr: *mut ()) and exposes an iterator; high tier
owns the match loop. Direct calls per arm → LLVM inlines exactly like Zig.
// low tier (bun_event_loop)#[repr(transparent)]pubstructTaskTag(pubu8);pubstructTask{pubtag:TaskTag,pubptr:*mut()}implQueue{pubfndrain(&mutself) -> implIterator<Item = Task> + '_{ ...}}// high tier (bun_runtime) — the ONLY place variant types are named#[inline]pubfnrun_tasks(q:&mutQueue){forTask{ tag, ptr }in q.drain(){match tag.0{
tag::PROMISE => unsafe{&mut*ptr.cast::<PromiseTask>()}.run(),
tag::TIMER => unsafe{&mut*ptr.cast::<TimerTask>()}.run(),// ... one arm per variant
_ => unsafe{ core::hint::unreachable_unchecked()},}}}
Hot-path list (use hoisted-match; everything else uses vtable):
Do not use Box<dyn Trait> / enum_dispatch for these. dyn Trait only
where Zig already used *anyopaque + fn-ptr (already indirect).
Debug/crash hooks (crash_handler dump callbacks, safety allocator
checks): low tier defines static HOOK: AtomicPtr<()>; high tier writes the
fn-ptr at init. One-shot registration, no vtable.
Pointers & ownership
Zig
Rust
bun.ptr.Owned(T)
Box<T>
bun.ptr.Shared(*T)
Rc<T> (always single-thread; non-intrusive). Do not introduce a custom bun_ptr::Shared<T> to save the weak-count word — 4 uses tree-wide, 8 bytes per allocation is negligible, and you lose Rc::downgrade/make_mut/get_mut. Leave // PERF(port): Rc weak-count header — profile in Phase B if you suspect a hot array.
bun.ptr.AtomicShared(*T)
Arc<T> (always atomic)
bun.ptr.RefCount(...) / ThreadSafeRefCount(...)
Default: Rc<T> / Arc<T>. Only use intrusive bun_ptr::RefCounted trait when *mut T crosses FFI and C++ calls ref()/deref() on the raw pointer (i.e. .classes.ts payloads, extern fn-exposed, or recovered via container_of!). ~14 of 88 uses need intrusive; the rest are Rc/Arc. If unsure: Rc, and the verify gate / cargo-check will tell you if FFI breaks.
bun.ptr.TaggedPointer / TaggedPointerUnion
Don't. Per §Dispatch, store (tag: u8, ptr: *mut ()) as two fields. The 8→16 byte cost is negligible (~dozens of instances). If a struct truly needs 8-byte packing for cache-line reasons, leave // PERF(port): was TaggedPointer pack.
bun.ptr.Cow(T)
Cow<'_, T> or Arc<T> + Arc::make_mut
bun.ptr.WeakPtr(T, field) (intrusive, deprecated)
keep as *mut T + manual ref/deref over an embedded WeakPtrData, or migrate the owner to Rc<T> and use std::rc::Weak. Do NOT blindly map to std::rc::Weak / std::sync::Weak when the owner is intrusive — those assume an Rc/Arc allocation header.
bun_collections::TaggedPtrUnion<(T1, T2, ...)> — always. The packed u64 layout is load-bearing (stored in arrays, hashed). Do NOT expand to a Rust enum; that's 16 bytes vs 8.
bun.HiveArray(T, N)
bun_collections::HiveArray<T, N>
*T field with separate deinit()
Box<T> if unique owner; *mut T + // SAFETY: if shared
Intrusive lists / @fieldParentPtr patterns: keep them. Use raw pointers
and core::mem::offset_of! (see @fieldParentPtr row in §Idiom map). Don't
try to make them Pin<Box<T>> in Phase A.
Collections
| Zig | Rust |
| --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| std.ArrayList(T) / std.ArrayListUnmanaged(T) | Non-AST crates:Vec<T>, drop every allocator arg. AST crates (see §Allocators): bumpalo::collections::Vec<'bump, T> if Zig fed it an arena, else Vec<T>. Method map (both): .append(x)→.push(x) · .appendSlice(s)→.extend_from_slice(s) · .appendAssumeCapacity(x)→.push(x) + // PERF(port): was assume_capacity · .ensureTotalCapacity(n)→.reserve(n.saturating_sub(v.len())) · .ensureTotalCapacityPrecise(n)→.reserve_exact(..) · .toOwnedSlice()→.into_boxed_slice() (or .into_bump_slice()) · .items→.as_slice()/&v · .clearRetainingCapacity()→.clear() · .swapRemove(i)→.swap_remove(i). Managed/unmanaged split disappears. |
| std.AutoHashMap(K,V) | bun_collections::HashMap<K,V> (wyhash, not SipHash) |
| std.StringHashMap(V) | bun_collections::StringHashMap<V> |
| std.AutoArrayHashMap(K,V) / std.StringArrayHashMap(V) | bun_collections::ArrayHashMap<K,V> — wyhash, insertion-order iteration, .values() returns contiguous slice. Do NOT substitute HashMap or indexmap. |
| bun.MultiArrayList(T) | bun_collections::MultiArrayList<T> (SoA) |
| bun.BabyList(T) | bun_collections::BabyList<T> (ptr+len+cap, #[repr(C)]) |
| std.BoundedArray(T,N) | bun_collections::BoundedArray<T, N> |
| std.EnumArray(E, V) | enum_map::EnumMap<E, V> with #[derive(enum_map::Enum)] on E. Dense [V; N] indexed by variant; the derive's associated Array<V> type hides the count (stable Rust cannot write [V; <E as Enum>::COUNT] generically). Do NOT use HashMap. |
| std.EnumSet(E) | enumset::EnumSet<E> with #[derive(enumset::EnumSetType)] on E; storage is the smallest uN fitting the variant count. Do NOT use bitflags! — it requires hand-assigning power-of-two values and defines a new type; it cannot wrap an existing #[repr(uN)] enum. |
| std.EnumMap(E, V) (sparse, not all keys set) | enum_map::EnumMap<E, Option<V>> — or, if the discriminant overhead matters, { present: enumset::EnumSet<E>, values: [MaybeUninit<V>; N] } by hand with // PERF(port). |
| bun.StringMap | bun_collections::StringMap |
| bun.ComptimeStringMap(V, .{...}) | static MAP: phf::Map<&'static [u8], V> = phf::phf_map! { b"key" => val, ... }; | compile-time perfect hash. For ≤8 entries a plain match on &[u8] is fine. .getWithEql/case-insensitive → // TODO(port): phf custom hasher |
| bun.ComptimeEnumMap(E) | phf::Map<&'static [u8], E> built from E's @tagNames | or strum::EnumString if keys are exactly variant names |
| bun.bit_set.IntegerBitSet(N) | bun_collections::IntegerBitSet<N> (#[repr(transparent)] uN) — inline, no heap |
| bun.bit_set.StaticBitSet(N) / ArrayBitSet(usize, N) | bun_collections::StaticBitSet<N> ([usize; (N+63)/64]) — inline, no heap |
| bun.bit_set.DynamicBitSet / DynamicBitSetUnmanaged | bun_collections::DynamicBitSet (heap-backed Box<[usize]>) |
| bun.bit_set.AutoBitSet | bun_collections::AutoBitSet (Bun-specific runtime static-or-dynamic; no std/crate equivalent) |
Do not use std::collections::HashMap (SipHash, different iteration order
→ behavioral diffs).
JSC types
// bun_jsc::JSValue#[repr(transparent)]#[derive(Copy,Clone,Eq,PartialEq)]pubstructJSValue(i64,PhantomData<*const()>);// PhantomData<*const ()> = !Send + !Sync// (negative impls `impl !Send` are nightly-only: feature(negative_impls), tracking #68318)// No lifetime. Kept alive by conservative stack scan — stack/registers ONLY.
Never store a bare JSValue as a field on a heap-allocated Rust struct.
Conservative scan covers stack/registers only. For struct fields use
bun_jsc::Strong (root), bun_jsc::JsRef (self-wrapper ref), or a codegen'd
own: property (C++-side WriteBarrier). A JSValue field in a
Box/Arc/Vec payload is a use-after-free.
.zero → JSValue::ZERO (encoded 0). Distinct from UNDEFINED. It means
"no value / exception pending" and is what a host fn must return after
throwing. value == .zero checks become value.is_empty().
value.ensureStillAlive() → value.ensure_still_alive():
if value.is_cell() { core::hint::black_box(value.0); }. This matches Zig's
doNotOptimizeAway (no-op for non-cells; black_box stable since 1.66).
Call it after the last use of any interior pointer derived from value
(typed-array .as_slice(), string .characters8()), not before. It is
point-in-time, not RAII — for scope-long protection use
let _keep = EnsureStillAlive(value); whose Drop calls black_box. If
release-only GC crashes persist, upgrade to inline asm matching JSC:
unsafe { core::arch::asm!("", in(reg) value.0, options(nostack, preserves_flags)); }
— black_box is best-effort per std docs and lacks the "memory" clobber
JSC uses.
Building a slice of JSValues to pass as call arguments? Do not use
Vec<JSValue> — its backing storage is on the Rust heap, not stack-scanned.
Use bun_jsc::MarkedArgumentBuffer (registered with the VM as a root) or a
fixed-size on-stack [JSValue; N]. If any element is created via
to_js()/get_index() while looping, earlier elements can be collected
mid-loop.
JSRef field → bun_jsc::JsRef (non-generic; tagged union
Weak(JSValue) | Strong(Strong.Optional) | Finalized). Its .weak arm is a
bare JSValue, not a JSC::Weak — only sound because the codegen'd
finalize() flips it to .finalized. Do not use JsRef on a struct
without finalize: true.
Strong / Strong.Optional → bun_jsc::Strong (a HandleSlot allocated
from vm.heap.handleSet() — same root set JSC::Strong<T> uses; GC root;
Drop deallocates the slot). If the Rust struct is itself owned by the JS
wrapper (m_ctx), a Strong pointing back at the wrapper or anything that
can reach it is a permanent leak — use JsRef instead.
bun_jsc::Strong and bun_jsc::JsRef are !Send + !Sync (enforce via
PhantomData<*const ()>). The HandleSlot is owned by the VM's HandleSet;
Drop must run on the JS thread. Moving one into an Arc<T> and dropping
from a thread-pool thread is UB.
globalThis.vm().reportExtraMemory(n) →
global.vm().deprecated_report_extra_memory(n) (no cell — matches the Zig
binding exactly). This is the incremental-growth path (buffer appended,
slice cloned). The non-deprecated heap.reportExtraMemoryAllocated(cell, n)
is called by the codegen at construction when .classes.ts has
estimatedSize: true — do not hand-port that. If the Zig type implements
pub fn estimatedSize(...) usize, keep it — codegen wires bothreportExtraMemoryAllocated (in construct/__create) and
reportExtraMemoryVisited (in visitChildren). You only call
deprecated_report_extra_memory(delta) manually for subsequent growth
after construction. Both halves are required: alloc-side without
visit-side → back-to-back full GCs; visit-side without alloc-side → OOM.
The macro emits the callconv(jsc.conv) shim that downcasts m_ctx →
*mut Self.
bun.JSError!T → bun_jsc::JsResult<T> (alias for Result<T, JsError>
where enum JsError { Thrown, OutOfMemory, Terminated } — exception cell
lives on the VM; the variant only records which error path).
.classes.ts-backed types: the C++ JSCell wrapper stays generated C++.
Your Rust struct is the m_ctx payload. Derive #[bun_jsc::JsClass] and
the codegen wires toJS/fromJS/hasPendingActivity. Don't hand-write
visitChildren — WriteBarrier fields live on the C++ side.
hasPendingActivity() runs on the GC thread, concurrently with the
mutator. It must use the JSC calling convention
(#[bun_jsc::host_call] extern fn(*mut Self) -> bool — same ABI rewrite as
host_fn: "sysv64" on Windows-x64, "C" elsewhere), read only Atomic*
fields (Ordering::Acquire), and never allocate, take locks, or touch JS.
Prefer JsRef upgrade/downgrade over hasPendingActivity when there is a
single busy/idle edge.
.classes.tsfinalize: true → implement pub fn finalize(this: *mut Self)
on the Rust struct. Runs on the mutator thread during lazy sweep — do not
touch any JSValue/Strong content (other cells may already be swept).
Call self.this_value.finalize() first, then drop native resources. Do NOT
rely on it for prompt cleanup; expose explicit close().
FFI
// Zig: extern fn us_socket_write(s: *Socket, data: [*]const u8, len: c_int) c_int;unsafeextern"C"{// items default to `unsafe fn`; write `safe fn` for fns the caller may treat as safe (1.82+)pubfnus_socket_write(s:*mutSocket,data:*constu8,len:c_int) -> c_int;}
All extern fn blocks → into the area's *_sys crate. If your file has
externs and isn't already *_sys, leave them in place with
// TODO(port): move to <area>_sys.
callconv(.c) → extern "C". JSC host fns: write #[bun_jsc::host_fn]
exactly as shown in §JSC types (no extern on the user-facing fn — the
attribute macro emits the correct ABI: "sysv64" on Windows-x64, "C"
elsewhere). You cannot write extern jsc_conv!(); Rust does not accept a
macro in ABI position.
Exported fns (@export, comptime { @export(...) }) →
#[unsafe(no_mangle)] pub extern "C" fn name(...). (On edition 2021 plain
#[no_mangle] still works, but match the unsafe extern style above.)
Platform conditionals
if (Environment.isWindows) { ... } else { ... }
→
#[cfg(windows)]{ ...}#[cfg(not(windows))]{ ...}// or: if cfg!(windows) { ... } for trivial value-level selection
Caution:if cfg!(windows) keeps both branches in the type-checker (and
monomorphization) — it does NOT remove the dead branch like Zig's
if (Environment.isWindows) does. Use the #[cfg(...)] form when the
disabled branch references platform-only items.
Environment.isDebug → cfg!(debug_assertions).
Environment.isPosix → #[cfg(unix)].
Environment.os == .windows/.mac/.linux/.wasm →
#[cfg(target_os = "windows"/"macos"/"linux")] (or #[cfg(windows)] for the
windows arm). Treat exactly like isWindows.
Don't translate
@import lines at the bottom of the file → just use bun_<area>::...; at
the top. Don't 1:1 the import block.
pub const X = @import("../foo_jsc/..").y; alias lines → delete. See
"Idiom map".
Generated files (*_generated.zig, grapheme_tables.zig,
boringssl_sys/boringssl.zig, libuv_sys/libuv.zig, schema.zig) →
write a 3-line .rs stub: // GENERATED: re-run <generator> with .rs output.
Test blocks (test "..." { ... }) → #[cfg(test)] mod tests { #[test] fn ...() { ... } }.
Output format
End your .rs with a trailer comment:
// ──────────────────────────────────────────────────────────────────────────// PORT STATUS// source: src/<area>/<file>.zig (NNN lines)// confidence: high | medium | low// todos: N// notes: <one line: anything Phase B needs to know>// ──────────────────────────────────────────────────────────────────────────
confidence: low means "logic is probably wrong, re-read the Zig in Phase B".
medium means "types/imports will need fixing but logic is right".
high means "should compile with only mechanical import fixes".
Global mutable state (Zig var instance: T → Rust)
Zig's *T has no aliasing requirement; Rust's &mut T is exclusive. Converting
Zig's var instance: T = undefined + pub fn get() *T to static mut +
unsafe { &mut INSTANCE } is UB the moment two &mut are live (which
happens any time a method on the singleton calls another function that also
get()s).
Thread the pointer. The C++ side already stores *mut VirtualMachine in globalObject's user-data; host fns get it via unsafe { &mut *(*global).bun_vm() }. Boot-path code takes vm: &mut VirtualMachine as a param. Do not add VirtualMachine::get() — every caller either has a globalObject or is on the boot path.
Greenfield Rust would never have the global. The FFI re-entry point is the only place the raw-ptr escape lives, and JSC guarantees single-threaded re-entry there.
Process-global, mutated post-init (Output color flags, env cache)
static X: OnceLock<T> where T's mutable fields are Atomic* / Mutex<_>
Safe shared &'static T; mutation through interior types.
static X: LazyLock<T> (or OnceLock), fn get() -> &'static T
No mutation = no aliasing problem.
Arena-scoped (Expr.Data.Store, parser scratch)
Owned by the parse session, not a static. The arena handle is passed/owned; never reset() while a StoreRef into it is live.
The store is logically per-session, not global.
Low crate "needs to call" high crate
You have a layering bug. Fix it. One of: (a) the call site is in the wrong crate — move it up to where both deps are available; (b) the data the call produces should live in the low crate, written by the high crate at init; (c) pass it as a parameter on the call path that already exists.
There is no fourth option. extern "Rust" link-time hooks, runtime AtomicPtr<fn>, and *mut c_void round-trips are all the same shape of "I didn't fix the layering."
*mut T is the alias-allowed pointer. Raw pointers carry no aliasing
assertion — unsafe { (*vm).field = x } is fine even with other *mut live.
UB only enters at &mut *ptr / &*ptr. So if you must hold a long-lived
handle, hold *mut T and deref per-access; don't bind let vm = &mut *get()
and hold it across calls.
SyncUnsafeCell<T> is the alias-allowed static cell. When a static
genuinely needs mutable access from a single thread without locking,
static X: SyncUnsafeCell<MaybeUninit<T>> + fn get() -> *mut T { X.get().cast() }
is the primitive. Callers stay in raw-ptr land.
Banned patterns (verify-stage rejects):
static mut X: T + unsafe { &mut X } — use SyncUnsafeCell or restructure.
&'static mut T stored in a struct field — that asserts exclusive forever.
unsafe extern "Rust" { fn __bun_* } / #[no_mangle] pub fn __bun_* between Rust crates — fix the layering.
Runtime-registered AtomicPtr<fn> / OnceLock<fn> hook with one consumer — fix the layering.
*mut c_void field that's downcast to one concrete type at every read site — fix the layering.
let x = unsafe { &mut *Singleton::get() }; ...calls something...; x.use() — re-get after the call or pass *mut.
Of the last 150 merged PRs to Bun, 108 are memory-safety-adjacent — missed cleanup on an error path, use-after-free, uninitialized reads, out-of-bounds access, reentrancy. 75 of those would not compile in a language with destructors, move semantics, and a borrow checker. One in three PRs we ship is "forgot to free something on an error path."
Bug class
Count (of 150)
Rust prevents?
Missing cleanup on error/early-return path
50
yes — Drop
Use-after-free / stale pointer
19
yes — ownership/borrowck (6/8 of the aliasing kind)
Uninitialized / wrong-union-tag / type confusion
6
yes — no uninit, exhaustive match
Bounds / integer overflow
18
downgraded — panic instead of UB
Race / reentrancy
15
partial — borrowck catches mutate-while-iterate; not JS-side-effect
GC-rooting (JSC-specific)
2
no — needs API discipline
Logic / spec / platform
21
no
Infra / docs / refactor
19
n/a
Of the 108, ~88 are in Zig. The ~14 in C++ are mostly ref-cycles and GC-concurrency races — the residual class that survives any language. So the Zig→Rust delta is real: the Zig bugs are exactly the destructor/ownership-fixable kind, and the C++ side is already near the floor.
Without stronger compile-time guarantees, this stays a cat-and-mouse game. The proposal is to remove the largest bug class structurally rather than fix instances of it indefinitely.
What
A strangler-fig migration of ~705K LOC of Zig to Rust, with both linked into the same binary throughout. The C++ JavaScriptCore-binding layer is unchanged; Rust replaces the Zig side of the same extern "C" seam, flipped per class via a flag in .classes.ts. No big-bang rewrite, no behavior change at any merge point, every flip gated on tests + shadow-diff + ≤2% perf microbench.
Constraints
The same philosophy that made Bun fast stays:
Own the whole stack. No std::fs, no std::net — bun_sys wraps raw syscalls with the same FD/errno/EINTR/Windows-path semantics Zig has today. No tokio, no async-std, no Future — just C callbacks on the uSockets loop and a verbatim port of Bun's ThreadPool. Very few external dependencies.
Zero perf regression. Every flip is gated. Zig already pays an FFI call at every JSC boundary; parity means don't add hops Zig doesn't have, not "inline across languages."
Understand every layer. Every type that crosses an FFI boundary has its #[repr] and layout asserted against the C/Zig definition at compile time.
Why Rust and not C++
C++ has destructors and move semantics; it would solve the leak-on-error-path class (~36% of those 150 PRs). The remaining ~16-24% gap is borrow-checking (aliasing UAFs), no-uninitialized-reads, exhaustive match, and default-checked indexing — things C++ can do with discipline but does not enforce. For an autonomous agent fleet running continuously, "wrong code doesn't compile" is worth more than "wrong code triggers a sanitizer if a test covers it." Rust's clippy + custom dylint rules give mechanical PR gates that clang-tidy cannot match. See §19 for the full comparison.
A defensible alternative — port the JSC-touching runtime to C++ (where native WTF::/JSC:: types eliminate FFI mirrors) and the JSC-free infrastructure to Rust — is noted in §19; this document pursues all-Rust.
Scope of this document
Architecture, primitives, crate layout, build integration, and invariants. Per-API enumeration (every .classes.ts entry, every node_* binding) is deferred to a follow-up document; this one establishes where each kind of code goes and what rules it follows.
Every non-obvious factual claim below survived 3-vote adversarial verification against the source tree at the cited file:line. Rust design choices are stated with their #[repr] and calling convention.
1. Approach
Zig already speaks to JavaScriptCore through a C-ABI seam: src/codegen/generate-classes.ts and src/codegen/cppbind.ts emit extern "C" symbol names that the C++ JSCell wrapper layer calls. Rust slots into the same seam — cargo build produces libbun_rs.a, the build links it next to bun-zig.o, and per class an impl: "zig" | "rust" flag in .classes.ts decides which side defines a given symbol. The C++ wrapper layer (ZigGeneratedClasses.{h,cpp}) stays byte-identical regardless of which language implements m_ctx.
No third-party async runtime. Bun owns its async: the uSockets epoll/kqueue loop (vendored C, accessed via FFI) is the executor; native objects are state machines with C callbacks; cross-thread work goes through the ported bun.ThreadPool (kprotty/zap) and the dedicated HTTPThread. There is no Future, no Waker, no tokio/rayon/hyper/smol.
No cross-language LTO requirement. Zig already pays an FFI call for Bun__WTFStringImpl__deref, *SetCachedValue, Foo__fromJS, etc. (src/string/wtf.zig:89-107, generate-classes.ts:2121-2144). Parity means don't add hops Zig doesn't have, not "inline across languages." Linker-plugin LTO (-Clinker-plugin-lto) is a follow-up once rust-toolchain.toml pins to an LLVM matching LLVM_VERSION = "21.1.8" (scripts/build/tools.ts:267-270).
2. JSC Garbage Collector — The Model Everything Depends On
Riptide is non-moving, generational, mostly-concurrent mark-sweep, conservative on the native stack and precise on the heap.
Property
Consequence
Source
Non-moving
A JSCell* (and any EncodedJSValue boxing one) is address-stable for the cell's lifetime. No Pin, no relocation handles.
MarkedBlock.h; ConservativeRoots.cpp:99-140
Conservative stack scan
Any JSValue in a Rust local is rooted automatically. ensure_still_alive(v) = core::hint::black_box(v) keeps it on the stack past last use.
A JSValue field in a mimalloc-allocated native struct is invisible to the tracer. It is only safe if a §5 mechanism guarantees the cell is alive at every read.
Heap.cpp:2970 (marking constraints)
Generational (Eden/Full)
Heap-to-heap edges go through WriteBarrier::set, which records the owner in the remembered set. Storing into a JSCell field without the barrier means an Eden GC frees a young value while an old cell still points at it.
WriteBarrier.h; generate-classes.ts:1025-1042
Concurrent marking
visitChildren/visitAdditionalChildren run on GC threads while the mutator runs. They may only read WriteBarrier<> fields — no RefCounted::ref/deref, no WeakPtr creation, no allocation.
hasPendingActivity is called from WeakBlock::visit() during the Ws constraint with ConstraintParallelism::Parallel. Side-effect-free; single atomic load.
WeakBlock.cpp:129; Heap.cpp:3104-3111
JSC::Strong is a root, not a traced edge
A Strong from native to a JS object that references the wrapper back is an uncollectable cycle. Strong is for bounded-lifetime holds with an explicit release point only.
No generated class enrolls here (generate-classes.ts:1618-1643); Rust must preserve this
Domo
Bun's DOMGCOutputConstraint over m_outputConstraintSpaces
Membership decided by runtime fn-pointer compare T::visitOutputConstraints != JSCell::visitOutputConstraints (BunClientData.h:193-196)
2.2 reportExtraMemory pairing
Two paths exist (generate-classes.ts:699-759, 1653-1657; bindings.cpp:4852):
Unpaired legacy:JSC__VM__reportExtraMemory → heap.deprecatedReportExtraMemory(size). For transient buffers.
Paired (when .classes.ts has estimatedSize: true): constructor calls reportExtraMemoryAllocated (a GC safepoint) AND generated visitChildren calls reportExtraMemoryVisited. Forgetting the visit-side half causes the GC death-spiral.
The Rust define_js_class! macro enforces pairing at compile time — estimatedSize: true implies both halves.
3. Binding Codegen Contract
generate-classes.ts emits, per .classes.ts definition:
The C++ side (ZigGeneratedClasses.{h,cpp}, IsoSubspaces, LazyStructure headers) is unchanged when a class flips to Rust. Gate: sha256sum build/debug/codegen/ZigGeneratedClasses.cpp is identical before/after flipping lang (generate-classes.ts:3026-3037).
3.1 Calling convention (jsc_conv!)
jsc.conv = SysV on every x64 target including Windows (jsc.zig:9-12; PlatformCallingConventions.h:37-46; generate-classes.ts:2642-2647). JSC's JIT emits SysV calls; a host fn with the Windows-x64 ABI corrupts the stack only when called from JIT'd code.
Every exported host function and every imported JSC extern uses extern jsc_conv!(). With panic = "abort" (workspace-wide), rustc emits nounwind and drops landing pads — no extern "sysv64-unwind" needed.
3.2 Symbols Rust exports per class
.classes.ts field
Symbol
Rust signature (extern jsc_conv!())
Source
construct: true
FooClass__construct
fn(*mut JSGlobalObject, *mut CallFrame) -> *mut c_void (null on exception)
static: usize = size_of::<Foo>() (note: static, not const — needs a symbol)
:2152-2154, 1735-1755
Thunk body distinction (from refuted-claim correction): proto methods route through toJSHostCall (opens ExceptionValidationScope, @call(.auto) user fn); class-static fns route through toJSHostFn (host_fn.zig:16-22, no *this receiver, no validation scope). The Rust emitter must generate distinct thunk bodies per kind.
3.3 Symbols Rust imports (already generated in C++)
let r = raw::X(...); if raw::Bun__RETURN_IF_EXCEPTION(g) { Err } else { Ok(r) } — two extern calls (bindings.cpp:6214)
Extend cppbind.ts to emit bun_jsc::sys Rust alongside cpp.zig from the same parse — single source of truth.
3.6 DOMJIT
Currently disabled in .classes.ts codegen (class-definitions.ts:294,302 strip it; grep -c WithoutTypeChecks ZigGeneratedClasses.{cpp,zig} == 0). However, hand-written DOMJIT exists outside the generator: JSBuffer.cpp:2518-2600 (jsBufferConstructorAlloc*WithoutTypeChecks via JSC_DEFINE_JIT_OPERATION) and FFIObject.zig (Reader.{u8,i8,...}WithoutTypeChecks). The Rust port leaves these C++/Zig until last; the nm | grep WithoutTypeChecks count is not a parity invariant for ZigGeneratedClasses (it returns 19 from hand-written code).
3.7 Aliasing discipline for m_ctx thunks
Generated thunks pass NonNull<Self> (raw) into user impls; user methods take this: NonNull<Self> and dereference per-field. Mutable fields are Cell<T> / UnsafeCell<T> / RefCell<T> so no &mut Self is ever formed across a call that can re-enter JS — host functions are re-entrant; two live &mut to one m_ctx is UB (generate-classes.ts:2423-2467 returns raw ?*T with no exclusivity contract).
4. Core Types
4.1 JSValue
// bun_jsc::value#[repr(transparent)]#[derive(Clone,Copy,PartialEq,Eq)]pubstructJSValue(pubi64);// ABI = JSC::EncodedJSValueimplJSValue{pubconstUNDEFINED:Self = Self(0xa);pubconstNULL:Self = Self(0x2);pubconstFALSE:Self = Self(0x6);pubconstZERO:Self = Self(0);// = "exception thrown"#[inline(always)]pubfnensure_still_alive(self){ifself.is_cell(){ core::hint::black_box(self.0);}}}// !Send + !Sync via PhantomData<*const ()> — JSValues are mutator-thread-only
size_of == 8, Copy, single register. No lifetime, no Pin (non-moving GC). Stack values are rooted by the conservative scanner. Heap storage requires a §5 mechanism. (JSValue.zig:1-24, 2244-2248)
4.2 CallFrame — inline, zero-FFI argument access
CallFrame is opaque but Zig hard-codes JSC's CallFrameSlot register layout and reads arguments()/this()/callee()/argumentsCount() via inline @ptrCast to [*]const JSValue + index — zero extern calls on the JS→native entry hot path (CallFrame.zig:6-111).
// bun_jsc::callframe — NOT routed through extern_constOFFSET_CALLEE:usize = 3;constOFFSET_ARGC_INCL_THIS:usize = 4;constOFFSET_THIS:usize = 5;constOFFSET_FIRST_ARG:usize = 6;#[inline(always)]fnas_registers(&self) -> *constJSValue{selfas*constSelfas*constJSValue}
cargo asm CallFrame::arguments must show zero call instructions — only mov/lea off rdi at fixed offsets. Any FFI hop here is a measurable regression on every JS→native call.
4.3 WTF::StringImpl
// bun_str::wtf#[repr(C)]pubstructWTFStringImpl{m_ref_count:AtomicU32,// bit 0 = static-string; increment = 0x2; OPAQUE — never mutate from Rustm_length:u32,m_ptr:StringPtr,// #[repr(C)] union { latin1: *const u8, utf16: *const u16 }m_hash_and_flags:UnsafeCell<u32>,// bit 2 = is_8bit (S_HASH_FLAG_8BIT_BUFFER)}const _:() = assert!(size_of::<WTFStringImpl>() == 24);#[repr(transparent)]pubstructWTFString(NonNull<WTFStringImpl>);
Clone/Drop call extern "C" Bun__WTFStringImpl__ref/deref (BunString.cpp:53-60; wtf.zig:89-107). Neverfetch_add/fetch_subm_ref_count from Rust — StringImpl::~StringImpl dispatches on bufferOwnership() across 4 cases (StringImpl.cpp:122-165: BufferInternal/Owned/External/Substring) and unregisters from AtomString/Symbol registries; this destruction path is C++-only state. The static-flag fast-path check (m_ref_count & 1) before the FFI call is fine.
pubenumWTFSlice<'a>{Latin1(&'a[u8]),Utf16(&'a[u16])}implWTFString{#[inline]pubfnas_slice(&self) -> WTFSlice<'_>;// single load+mask, zero FFI/// Borrows when 8-bit ∧ all-ASCII; allocates otherwise. Returns &[u8] (WTF-8, not &str).pubfnto_utf8_in<'b>(&self,bump:&'bBump) -> Cow<'b,[u8]>;}
(wtf.zig:56-88, 125-186; unicode.zig:371-377)
4.4 ZigString and BunString
ZigString.ptr steals high bits: bit 63 = UTF-16, bit 62 = mimalloc-owned, bit 61 = UTF-8. untagged() truncates to 53 bits (ZigString.zig:629-632). Use strict provenance (ptr.map_addr(|a| a & PTR_MASK), stable 1.84) for miri-clean masking.
Not a Rust enum.BunString__toWTFString (BunString.cpp:537-556) writes bunString->impl.wtf = ...; bunString->tag = ...; independently in place — UB on a Rust enum reference. Store tag as raw u8 (not #[repr(u8)] enum field) to tolerate unexpected bytes from C++. Drop calls Bun__WTFStringImpl__deref only when tag == 1 (headers-handwritten.h:481-491).
4.5 SIMD string scans
isAllASCII, indexOfChar, indexOfNewlineOrNonASCII, containsNewlineOrNonASCIIOrQuote, copyU16ToU8 etc. all FFI to existing C symbols simdutf__validate_ascii (double underscore, bun-simdutf.cpp:26) and highway_* (highway.zig:1-65, highway_strings.cpp). Declare identical extern "C" symbols in bun_str::simd; do not reimplement in core::simd (portable_simd is nightly-indefinite). copyU16ToU8 input is [*]align(1) const u16 — expose as *const u16 (may be 1-byte aligned), not &[u16].
5. GC Liveness Primitives — Decision Table
Six mechanisms exist in production. Each protects against a specific failure; none is interchangeable.
| # | Primitive | Protects against | Hazard | Rust type | Consumers |
| --- | ---------------------------------------------------- | ------------------------------------------ | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | ------------------------------------- |
| 1 | hasPendingActivity + AtomicU32 | Own wrapper freed while native I/O pending | None (no root cycle) — preferred for refcount-style liveness | unsafe trait HasPendingActivity { unsafe fn has_pending_activity(this: *const Self) -> bool; } (raw ptr — GC thread may alias mutator's &mut) | PostgresSQLConnection |
| 2 | JSRef {weak, strong, finalized} | Own wrapper freed while active | Leak if never downgrade() | enum JSRef { Weak(JSValue), Strong(OptionalStrong), Finalized } — .weak is bareJSValue, NOT JSC::Weak; sound only because finalize() flips to .finalized | Socket, Request, Response, ServerWebSocket, UDPSocket, Valkey, JSMySQLConnection, PostgresSQLQuery, Cron |
| 3 | Strong / OptionalStrong (heap JSC::HandleSlot) | A different JS object freed while held | Uncollectable cycle if held indefinitely | #[repr(transparent)] struct Strong(NonNull<DecodedJSValue>); get() is zero-FFI direct deref (Strong.zig:123-127) | NodeHTTPResponse.promise, RequestContext stream refs |
| 4 | JSValue.protect/unprotect (gcProtect table) | JS object held by a non-wrapper native | Same cycle hazard; manual pairing | #[deprecated] unsafe fn protect/unprotect — no RAII wrapper (would encourage use) | RequestContext.response_jsvalue, Handlers callbacks |
| 5 | Async.KeepAlive / jsc.Ref | Process exits while I/O pending | Not GC-related | #[repr(u8)] enum KeepAlive { Active=0, Inactive, Done }; struct ActiveTaskRef(Cell<bool>) — both 1 byte, idempotent | Every async native (poll_ref) |
| 6 | MarkedArgumentBuffer | Heap [JSValue] freed during allocation | — | with_marked_args( | buf | ...)scope-bound; lifetime'a prevents escape | udp.sendMany, YAML/Markdown parsers |
Design rule: prefer .classes.tsvalues: (→ WriteBarrier<> on the JSCell, traced, no cycle) over Strong in the native struct. Strong/protect() only with a documented bounded lifetime and explicit release site. The #[derive(NativeClass)] macro rejects any struct field of type JSValue with a compile error directing to cache: true in .classes.ts; opt-out via #[guarded_by(has_pending_activity)].
Two disjoint categories with disjoint lifetime primitives:
Category A — GC-wrapped (.classes.ts entry, m_ctx behind a JSCell)
Lifetime = GC finalizer calls FooClass__finalize. JSRef/Strong/hasPendingActivity/cached-values apply here only. Lives in bun_runtime.
Category B — Pure native infrastructure
HTTP client state machine, HTTPThread, AsyncHTTP, PackageManager, ThreadPool, BundleV2, LinkerContext, RequestContext, lockfile. Lifetime = Rc/Arc / explicit ownership / pool / arena. No JSValue fields, no JSRef, no GC.
Verified by grep:src/http.zig, src/http/AsyncHTTP.zig, src/http/HTTPThread.zig have zero JSRef/jsc.Strong/JSValue/hasPendingActivity (AsyncHTTP.zig:1-35). LinkerContext.zig has zero GC-related jsc.*. ThreadPool.zig has zero (the only jsc ref is wtf.releaseFastMallocFreeMemoryForThisThread — bmalloc, not GC).
However, these directories DO have non-GC jsc.* namespace coupling that must be relocated before the layer boundary is real:
Pure static phf::Map (zero jsc.* refs in HardcodedModule.zig)
jsc.RegularExpression
bun_regex
Wraps Yarr (or use regex crate)
jsc.API.BuildArtifact.OutputKind
bun_bundler
Plain enum
jsc.Node.uid_t/gid_t
bun_sys
typedefs
jsc.wtf.releaseFastMallocFreeMemoryForThisThread
bun_async (hook)
bmalloc shim
After relocation, *_core crates are genuinely bun_jsc-free; *_jsc glue crates own every toJS/fromJS/host-function (npm.zig:685-806, install_binding.zig, bundle_v2.zig:1928-2450JSBundleCompletionTask, Method.zig:151, H2Client.zig:44-46, etc.).
RequestContext is Category B despite holding JSValue fields: it is NOT a .classes.ts wrapper (grep confirms 0 hits), pooled in HiveArray(RequestContext, 2048), lifetime = manual ref_count: u8 → pool-return (RequestContext.zig:39, 59, 303-316). It borrows JS handles, releasing them in finalizeWithoutDeinit driven by refcount, not GC sweep.
Bridge objects (Category A holding Category B): FetchTasklet owns *http.AsyncHTTP (raw ptr, allocator.create) and surfaces results via jsc.JSPromise.Strong. AsyncHTTP never sees the promise; it calls back via result_callback with a plain HTTPClientResult (FetchTasklet.zig:6,26,60,1085,1401). This is the only place bun_http types and jsc::Strong coexist; dependency arrow is bun_runtime → {bun_http, bun_jsc}, never bun_http → bun_jsc.
7. bun_sys — Syscall Layer (NOT Rust std)
Why not std::fs/std::net
bun.sys invariant
std::io would break it
Linux: raw syscall instruction via std.os.linux; errno decoded from return register (-4096, 0) (sys.zig:53-58; linux_errno.zig:227-249)
std::io::Error::last_os_error() reads TLS errno unconditionally; success path should be TLS-free
Sentinel [:0]const u8 paths → zero-copy to kernel (sys.zig:1741-1781)
&Path forces conversion; Windows forces UTF-16 alloc
Windows normalizePathWindows → \??\ NT path into caller [32768]u16 buffer from thread-local 4-buffer pool (sys.zig:980-1069)
No equivalent
Design
// bun_syspubtypeMaybe<T> = Result<T,SysError>;#[repr(C)]pubstructSysError{puberrno:u16,pubsyscall:Tag,pubfd:Fd,#[cfg(windows)]pubfrom_libuv:bool,pubpath:RawSlice,pubdest:RawSlice,// borrowed; .clone_owning() for escape}#[repr(transparent)]pubstructE(pubu16);// non-exhaustive — NOT #[repr(u16)] enum (UB on unknown errno)#[cfg(unix)]#[repr(transparent)]pubstructFd(libc::c_int);#[cfg(windows)]#[repr(transparent)]pubstructFd(u64);// bit 63 = Kind::{System,Uv}#[repr(u8)]pubenumTag{Todo=0,Dup,Access,/* ~100 entries */}
Backends: bun_sys::linux (raw syscalls via linux-raw-sys or core::arch::asm!, NOT libc); bun_sys::darwin/freebsd (libc crate); bun_sys::windows (windows-sys + bun_uv_sys FFI to vendored libuv). cargo asm bun_sys::linux::read must contain syscall, not callq read@PLT.
Maybe/SysError are Rust-internal (Zig's tagged union has no C ABI); cross-language edge during migration uses flat #[repr(C)] SysErrorC { errno, syscall, fd, path_ptr, path_len, dest_ptr, dest_len }.
8. Allocators & Containers
Allocator
Used for
Rust mapping
Source
mimalloc (global)
Everything not below
#[global_allocator] static A: bun_alloc::Mi calling extern "C" mi_malloc_aligned/mi_free — NOT the mimalloc crate (would link a 2nd copy). Same heap as Zig's bun.default_allocator so cross-language ownership is mi_free-safe
bun_alloc::Arena(NonNull<mi_heap_t>); Drop = mi_heap_destroy (bulk-free, NOT mi_heap_delete). ArenaRef<'a> non-owning view = Zig Borrowed. Nightly allocator_api for Vec::new_in
MimallocArena.zig:42-162
NewStore
AST nodes (Expr.Data, Stmt.Data)
See §8.1
NewStore.zig
ASTMemoryAllocator (StackFallback(8192))
Parser scratch
bumpalo::Bump exposed through Zig-Allocator-vtable shim (built on Zig side — std.mem.Allocator.VTable is non-extern, .auto callconv)
Allocator.zig:19-123; §8.2
HiveArray<T,N>.Fallback
RequestContext(2048), FilePoll(128), H2FrameParser(256), DNS PendingCache(32×15), PackageManager Task(64)/NetworkTask(128), HTTPContext PooledSocket(64), TranspilerJob(64), etc.
Inline per-crate, no shared crate. struct Slab<T, const N: usize> { buf: Box<[MaybeUninit<T>; N]>, used: [u64; (N+63)/64] }. get() = trailing_zeros (TZCNT). Boxed so addresses are stable (handed to C callbacks)
hive_array.zig:4-142
BabyList<T>{ptr, len:u32, cap:u32} (16B)
AST ExprNodeList, Part.List, ImportRecord.List; bundler/css heavy
Crate-localThinVec<T> only on the 6 hot AST fields (E.Array.items, E.Call.args, E.New.args, E.Object.properties, E.JSXElement.children, G.Decl.List); Vec<T> (24B) everywhere else. Never crosses FFI by value (grep bindings/*.{h,cpp} = 0 BabyList)
#[repr(transparent)] struct(u64) + macro-generated match jump-table; tags = 1024 - i. Rust enum for internal-only cases
tagged_pointer.zig:1-71
8.1 NewStore / BlockStore — the AST node allocator
NewStore is a threadlocal linked list of fixed Block { [largest_size*count*2]u8, bytes_used, next }; bump-within-block; reset() rewinds to first block without freeing (NewStore.zig:33-115). Exactly two instantiations: Expr.Data.Store (count=512) and Stmt.Data.Store (count=128) (Expr.zig:3152; Stmt.zig:301). PreAlloc { metadata: Store, first_block: Block } is one heap allocation; firstBlock() is @fieldParentPtr offset (NewStore.zig:63-85).
Three coupled pub threadlocal vars (instance: ?*StoreType, memory_allocator: ?*ASTMemoryAllocator, disable_reset: bool); append() checks memory_allocator first (bundler override path), else instance.?.append (Expr.zig:3181-3230). External writers: ASTMemoryAllocator.push/pop, Macro.zig:41-44, bake.zig:714, npm.zig:1849.
// bun_ast::store#[repr(C)]structBlock<constSIZE:usize>{buf:[MaybeUninit<u8>;SIZE],used:u32,next:Option<NonNull<Block<SIZE>>>}#[repr(C)]structPreAlloc<constSIZE:usize>{metadata:BlockStore,first_block:Block<SIZE>}#[thread_local]staticEXPR_STORE:Cell<Option<NonNull<PreAlloc<EXPR_SIZE>>>>;#[thread_local]staticMEMORY_ALLOCATOR:Cell<Option<NonNull<AstMemoryAllocator>>>;#[thread_local]staticDISABLE_RESET:Cell<bool>;#[repr(transparent)]#[derive(Copy,Clone)]pubstructStoreRef<T>(NonNull<UnsafeCell<T>>);// NO safe Deref/DerefMut — see below
Why not &'ast T: the visitor algorithm holds by-value Expr copies whose StoreRef aliases the same slot while recursing and mutating children (visitExpr.zig:489,551,876,1025-1026,1196,1554); a safe &mut T here is instant UB under Stacked Borrows. StoreRef exposes fn ptr(self) -> *mut T (always sound via UnsafeCell::get) and unsafe fn as_mut<'a>(self) -> &'a mut T with documented obligation. Threading 'ast through ~40K LOC is the dominant port cost; Polonius (which would help) is nightly-indefinite.
Migration: keep NewStore.zig as-is until js_parser/js_printer callers move to Rust in the same step. The only acceptable FFI seam is at cold edges (Bun__AstStore__{init,reset,deinit,growBlock}); per-node append() stays a Zig inline fn against the #[repr(C)] Block fields. Do NOT expose extern "C" append — that adds an FFI hop per AST node (millions/file).
8.2 Expr / Stmt layout
#[derive(Clone,Copy)]#[repr(C)]pubstructExpr{publoc:Loc,pubdata:ExprData}#[repr(transparent)]pubstructLoc(pubi32);// repr(Rust), NOT repr(C,u8) — Zig union(Tag) has no C ABI; only SIZE parity matterspubenumExprData{Array(StoreRef<EArray>),Binary(StoreRef<EBinary>),Call(StoreRef<ECall>),/* ... boxed */Identifier(EIdentifier),// inline: Ref(u64) + 3 bools — drives Data to 24BNumber(f64),Boolean(bool),Missing,This,Null,Undefined,/* ... inline */}const _:() = assert!(size_of::<ExprData>() == 24 && size_of::<Expr>() == 32);const _:() = assert!(size_of::<Stmt>() <= 24);
Ref = packed struct(u64) { inner_index: u31, tag: enum(u2), source_index: u31 }. Hashing is Wyhash over the 8 bytes (NOT identity — source_index is constant per file, identity-hash would cluster). Rust: #[repr(transparent)] struct Ref(u64) + FxBuildHasher or wyhash. (base.zig:97-208; bun.zig:483-485)
9. Event Loop & Threading — Own Async, No Runtime
9.1 Task is a u64, not a list node
JS-thread Task = TaggedPointerUnion over ~95 concrete types (Task.zig:4-99). Dispatch in tickQueueWithCount is a giant switch (task.tag()) → runFromJS/runFromMainThread. No vtable, no allocation per Task value.
tasks: RingBuf<Task> (ported LinearFifo, NOT VecDeque — two-slice layout breaks the bulk-copy fast path). (event_loop.zig:113, 308-355)
9.2 ConcurrentTask — 16-byte intrusive MPSC node
{ task: Task /*u64*/, next: PackedNextPtr /*usize, bit 0 = auto_delete*/ }, compile-asserted 16B. UnboundedQueue = lock-free with back/front each on its own half-cache-line. Push = 1 swap + 1 store. (ConcurrentTask.zig:13-73; unbounded_queue.zig)
9.3 KeepAlive vs ActiveTaskRef (jsc.Ref)
Distinct, non-refcounted, idempotent 1-byte toggles gating two different counters:
KeepAlive → loop.{num_polls, active} (uSockets blocking decision). Also {ref,unref}Concurrently (atomic on vm.event_loop) and unrefOnNextTick (atomic RMW on vm.pending_unref_counter).
Decoupled via trait LoopHandle { fn ref_loop/unref_loop/add_active/sub_active/ref_concurrently/inc_pending_unref }; VirtualMachine and MiniEventLoop both impl. (posix_event_loop.zig:5-112; jsc.zig:200-220; Loop.zig:59-85; VirtualMachine.zig:46)
9.4 EventLoopTimer — intrusive pairing-heap node
5 fields { next: timespec, state, tag (22 variants), heap: IntrusiveField{child,prev,next}, in_heap }; fire() switches on tag and @fieldParentPtr recovers parent via four distinct field names (timer, max_lifetime_timer, reconnect_timer, event_loop_timer). Prerequisite: convert Zig side to extern struct + enum(u8) for ABI. (EventLoopTimer.zig:1-224; io/heap.zig)
9.5 PosixLoop overlays C us_loop_t
extern struct { internal_loop_data align(16), num_polls:i32, ..., active:u32, pending_wakeups:u32, ready_polls:[1024]EventType }. Zig reads/writes num_polls/active directly (no FFI) for ref/unref; tick/wakeup/run are extern "C" to usockets. Rust never owns an epoll fd; it borrows usockets'. Field offsets must match exactly — static_assert(offsetof(us_loop_t, active)==N) paired with Rust offset_of! assert. (Loop.zig:1-131)
9.6 AutoFlusher
1-byte { registered: bool }; DeferredTaskQueue = AutoArrayHashMapUnmanaged(?*anyopaque, *const fn(*anyopaque)->bool). Map key IS the object pointer; run() iterates by index, swap-removing entries whose callback returns false. Rust: IndexMap<*mut (), unsafe fn(*mut ())->bool, FxBuildHasher> with same swap-remove-during-iterate semantics. (AutoFlusher.zig; DeferredTaskQueue.zig:29-60)
9.7 bun.ThreadPool — port verbatim
kprotty's lock-free pool (ThreadPool.zig:3-11). Sync = packed struct(u32) { idle:u14, spawned:u14, unused:bool, notified:bool, state:enum(u2) } → AtomicU32 with bit-shift accessors. Task = { node: Node{next}, callback: *const fn(*Task) } (intrusive, 2×usize). Node.Buffer = bounded SPMC ring [256]Atomic(*Node); Node.Queue = Treiber stack with low-bit HAS_CACHE|IS_CONSUMING flags. schedule() fast path: 1 Relaxed load + 1 Relaxed fetch_add + 1 Release cmpxchgWeak loop (Treiber push — relaxed store is unsound) + 1 Release fetch_or. Do NOT substitute crossbeam-deque or rayon.
WorkTask<C> embeds BOTH a PoolTask AND a ConcurrentTask inline + KeepAlive; one round-trip = 1 heap alloc total. container_of! macro (memoffset-based) replaces @fieldParentPtr. (ThreadPool.zig:33-1042; WorkTask.zig:14-76)
ABI precondition: Zig Task.callback is currently default callconv; before cross-language enqueue, change to callconv(.C) (mechanical pass).
10. HTTP Client — h1/h2/h3
10.1 Architecture
One dedicated OS thread (HTTPThread, bun.once-spawned, detached) owning a jsc.MiniEventLoop (uSockets, no JSC VM) running processEvents() noreturn. Process-static singleton http_thread holds two embedded NewHTTPContext(false)/NewHTTPContext(true) plus cross-thread queues. (HTTPThread.zig:16-650; http.zig:6)
HiveArray(PooledSocket, 64) per context; PooledSocket (auto-layout, NOT extern) inline [128]u8 hostname_buf
HTTPContext.zig:3-358, 615-712
Custom-TLS cache
AutoArrayHashMap(*SSLConfig, SslContextCacheEntry), max 60, 30-min TTL; intrusive RefCount so in-flight clients survive eviction
HTTPThread.zig:6-358; HTTPContext.zig:74-82
Body buffer reuse
StackFallback(32K) for small; lazy singleton [512K]u8 HeapRequestBodyBuffer taken/returned via Option::take
HTTPThread.zig:46-162
10.2 h1 state machine
Two HTTPStage enums on InternalState. Response parsing via picohttp.phr_parse_response (FFI) writing into process-global [256]picohttp.Header scratch (safe — single thread). Chunked decode via phr_decode_chunked with inline phr_chunked_decoder per request. (InternalState.zig:22-236; http.zig:33-37, 1871, 2655-2735)
10.3 h2
ClientSession becomes the ActiveSocket variant after ALPN. Owns *lshpack.HPACK (FFI to vendored lshpack.c), write_buffer: StreamBuffer, read_buffer, streams: AutoArrayHashMap(u31, *Stream). Hand-written framing in h2_client/{dispatch,encode}.zig. Decoded headers feed the SAME picohttp.Response/handleResponseBody path as h1. Coalescing: active_h2_sessions + pending_h2_connects lists checked before keep-alive pool. (H2Client.zig; h2_client/ClientSession.zig:21-50; HTTPContext.zig:96-827)
10.4 h3
One process-global H3.ClientContext lazily creates lsquic engine bound to HTTP thread's loop via C shim packages/bun-usockets/src/quic.c. ClientSession = one QUIC conn multiplexing Streams 1:1 with HTTPClients. Result delivery reuses handleResponseMetadata/handleResponseBody. Keep the C shim; do NOT bind lsquic directly. (H3Client.zig; h3_client/ClientContext.zig:7-79; quic.c:36-78)
10.5 AsyncHTTP handle
Caller-owned; embeds HTTPClient by value, intrusive next, ThreadPool.Task, async_http_id: u32 (global atomic), AtomicState. Global atomics active_requests_count/max_simultaneous_requests (default 256, env BUN_CONFIG_MAX_HTTP_REQUESTS). On start, cloned into heap ThreadlocalAsyncHTTP so http-thread copy has stable address. (AsyncHTTP.zig:1-92; HTTPThread.zig:510-601)
11. bun install
11.1 PackageManager
Accumulates work into fiveThreadPool.Batch fields on main thread: task_batch (CPU: parse/extract), network_resolve_batch, network_tarball_batch, patch_apply_batch, patch_calc_hash_batch. scheduleTasks() posts CPU batches to manager.thread_pool; merges tarball→resolve via O(1) Batch.push, posts to HTTP.http_thread (the dedicated reactor thread, NOT a worker pool). Preallocated HiveArray(Task,64) / HiveArray(NetworkTask,128). (PackageManager.zig:48-73, 1137-1138; runTasks.zig:1128-1157)
11.2 Lockfile binary format
Package = extern struct { name, name_hash, resolution, dependencies, resolutions, meta, bin, scripts } stored in MultiArrayList(Package) (SoA, single slab). All variable-length data via ExternalSlice<T> { off:u32, len:u32 } (8B) into 6 flat Buffers arrays. Semver.String = 8B [8]u8 (inline ≤8 / external {off:u32,len:u32} via bit 63 of u64 bitcast). bun.lockb is NOT mmapped — readToEnd into heap, then per-section bytemuck::pod_collect_to_vec (NOT try_cast_slice — Vec<u8> align 1). Dependencies undergo per-element parseWithTag re-parse (NOT zero-copy). (Package.zig:1-2227; ExternalSlice.zig; SemverString.zig; Buffers.zig:79-355; bun.lockb.zig)
11.3 Streaming tarball extraction
NetworkTask.tarball_stream: ?*TarballStream — Mutex-guarded double-buffer (NOT lock-free ring). notify() (HTTP thread) on first 2xx chunk ≥ minSize(): commits, onChunk(chunk, false), response_buffer.reset(). onChunk locks, pending.appendSlice, schedules drain_task on thread_pool (guarded by draining: AtomicBool). Worker swaps pending↔reading under mutex; libarchive's pull-based read callback hands out reading.items[read_pos..] lock-free. tarball_stream is Option<NonNull<_>>, NOT Box (back-pointer + concurrent alias + self-free). (NetworkTask.zig:28-358; TarballStream.zig:27-816)
11.4 Extraction
ExtractTarball.run(): integrity verify → ISIZE peek (last 4B, cap 64MiB) → one-shot libdeflate.gzip else fallback Zlib.ZlibReaderArrayList (vendored C zlib, NOT miniz_oxide) → Archiver.extractToDir. Custom readDataIntoFd loops archive_read_data_block + pwrite() first (fallback lseek+write); on Linux, fallocate for entries >1MB. BodyPool = ObjectPool(MutableString, init2048, threadsafe=true, 8) — per-thread threadlocal freelist, no mutex. (extract_tarball.zig:13-313; pool.zig:110-145; libarchive.zig:603-734)
BundleV2 owns graph: Graph and linker: LinkerContext by value. Bundle-thread arena = ThreadLocalArena (mimalloc heap) passed intoinit, stored at graph.heap; BundleV2.allocator() and LinkerContext.allocator() return the same heap. Separately, each ThreadPool.Worker owns its ownheap: ThreadLocalArena — AST nodes allocated on per-worker heaps; cross-thread READS allowed, cross-thread ALLOCATION forbidden. (bundle_v2.zig:11-30, 107-1007; Graph.zig:3-14; ThreadPool.zig:205-301)
Rust: BundleAlloc(NonNull<mi_heap_t>)Copy raw handle (NOT &'a Bump — would force self-ref). Graph { heap: MiHeap, pool: *mut ThreadPool, input_files: MultiArrayList<InputFile>, ast: MultiArrayList<BundledAst>, ... }.
12.2 ParseTask flow
Per file: read → parse into JSAst using worker allocator → heap-alloc ONE ParseTask.Result via bun.default_allocator (NOT worker arena) → enqueueTaskConcurrent to bundle thread. Result freed on bundle thread; JSAst interior pointers still point into worker heap, moved by-value into graph.ast. unsafe impl Send for BundledAst. (ParseTask.zig:13-1432; bundle_v2.zig:4149-4296)
12.3 LinkerGraph clone
LinkerGraph.ast is a shallow clone of Graph.ast (NOT alias — comment is stale): MultiArrayList.clone allocates fresh column storage + memcpy. module_scope.generated and per-source symbols BabyLists deep-cloned onto linker allocator. Only inner BabyLists reached through shallow-copied columns (parts, symbol_uses) still point into worker heaps; appended-to with linker allocator (relies on mimalloc cross-heap realloc/free). (bundle_v2.zig:1210-1232; LinkerGraph.zig:14-434)
12.4 Scan-phase concurrency
pending_items: u32 plain (NOT atomic — only mutated on bundle thread). Workers post to MPSC; onParseTaskComplete runs single-threaded under thread_lock, decrements, may enqueue more. (Graph.zig:22-34; bundle_v2.zig:474-491, 4168-4175)
12.5 *_core / *_jsc split + monomorphization
Six non-plugin JSC coupling points (bundle_v2.zig:50, 1525, 2259, 2939, 3564, 5047, 5083): hot-reloader cycle, HardcodedModule.Alias (per-import hot path → relocate to bun_resolve_builtinsphf::Map), NodeFS.writeFile, BuildArtifact.OutputKind, AnyEventLoop, Bun__setupLazyMetafile. Invert via trait BundleHost { type Watcher; fn setup_lazy_metafile(...); }; BundleV2<H: BundleHost> monomorphized in bun_runtime, ships NoHost for CLI. JSBundleCompletionTask (bundle_v2.zig:1928-2450) → bun_bundler_jsc. js_printer.zig:397RuntimeTranspilerCache → Option<&dyn TranspilerCache> (cold).
Hardest. AnyRequestContext is opaque u64 — Rust Request/Response interop with Zig RequestContext until this lands. Port together with Request or land bidirectional C-ABI accessors first (Bun__AnyRequestContext__*, Bun__Request__*)
15.1 No CMakeLists.txt — TypeScript-generated Ninja
Register crates/ as scripts/build/deps/bun-rs.ts returning { kind: "cargo", manifestDir: ".", libName: "bun_rs", source: { kind: "in-tree", path: "crates" } } — follows the lolhtml precedent (deps/lolhtml.ts). emitCargo() pushes <buildDir>/deps/bun-rs/<triple>/<profile>/libbun_rs.a into depLibs, which link() already consumes (bun.ts:466-472).
Extend EmitCargoInput with alwaysBuild?: boolean (set for in-tree, mirroring emitNestedCmake's n.always() pattern) and extraImplicitInputs?: string[] + extraEnv (for BUN_CODEGEN_DIR). dep_cargo rule has restat=1 so cargo's own fingerprint prunes link when .a mtime unchanged. (source.ts:585-605, 716-828, 1280-1418)
15.2 CI split
Add cfg.mode === "rs-only" sibling of zig-only; per-target-OS agents (no Linux→darwin/msvc cargo cross precedent). libbun_rs.a is a peer artifact like archive/zigObjects — NOT in allDeps (would make every cpp-only agent redundantly cargo-build). getLinkBunStepdepends_on includes ${key}-build-rs. Runs concurrently with cpp-only/zig-only. (.buildkite/ci.mjs:555-609; bun.ts:156-612)
15.3 Codegen pipeline
Add 9th output ZigGeneratedClasses.rs to emitGeneratedClasses (codegen.ts:567-598); emit via writeIfNotChanged so restat=1 prunes. One pub mod <snake_type> per class to avoid Rust E0428 collisions. Consumer crate include!(concat!(env!("BUN_CODEGEN_DIR"), "/ZigGeneratedClasses.rs")).
15.4 Layout assertion harness
Add b.addExecutable("emit-layouts") in build.zig (precedent: :563,634,771) emitting <codegenDir>/zig-layouts.json with @sizeOf/@alignOf/@offsetOf for every extern struct. crates/bun-ffi/build.rs reads it, emits OUT_DIR/layout_asserts.rs with const _: () = assert!(size_of::<T>() == N && offset_of!(T, f) == O); for every #[repr(C)] mirror. All compile-time; zero binary bytes.
15.5 Allocator / panic
bun_alloc::Mi calls extern "C" mi_malloc_aligned/mi_free/mi_realloc_aligned — symbols resolve at final link against existing static.c.o (mimalloc.ts). Test: assert!(mi_is_in_heap_region(Box::into_raw(Box::new(0u8)) as _)). bun_panic::install() sets panic::set_hook → extern "C" Bun__crashHandler (crash_handler.zig:2313). bun-rs.ts mirrors lolhtml's rustflags: -Cpanic=abort -Cforce-unwind-tables=no (unix), -Zbuild-std=std,panic_abort -Cpanic=immediate-abort (release).
15.6 LTO (optional)
When cfg.lto: rustflags.push("-Clinker-plugin-lto", "-Cembed-bitcode=yes", "-Ccodegen-units=1"). Pin rust-toolchain.toml to nightly with LLVM major.minor matching LLVM_VERSION_RANGE; verify in tools.ts via rustc -vV. (build.zig:214,827-832; flags.ts:398-731; zig.ts:39-48)
16. Rust Rules & Lints
16.1 Language features (Rust 2024 edition)
Feature
Status
Use
unsafe extern { safe fn ... }
stable 1.82
bun_jsc::sys marks pure getters safe fn → hundreds fewer unsafe {}
Strict provenance (ptr.map_addr)
stable 1.84
ZigString bit-masking, TaggedPointer — miri-clean
core::hint::assert_unchecked
stable 1.81
Replaces Zig @setRuntimeSafety(false) in lexer/parser hot loops
mitata gate trips on >30% of PRs → 10× ratio assumption wrong
win-x64 sysv64 or layout-asserts fighting on day 2
Human review is the bottleneck at 20 agents
Deferred
Item
Why
Per-API enumeration (every .classes.ts proto method, every node_* binding shape)
→ follow-up doc
shell/ (22K, 180 comptimes)
Comptime-generated builtin dispatch; low churn
bake/ (12K)
Product surface still moving
Hand-written DOMJIT (JSBuffer.cpp, FFIObject.zig)
Stays C++/Zig until last
deps/ shims (uws, picohttp)
Keep as FFI
19. Why Rust Over C++
Analysis of the last 50 merged bugfix PRs (categorized by class):
Class
Count
C++ (RAII+move+unique_ptr) solves
Rust solves
Missing cleanup on error path
14
14
14
UAF / stale pointer
8
~3 (ownership)
~6 (+aliasing via borrowck)
Uninit / wrong-tag / type confusion
4
~1
4
Bounds / overflow
10
~0
10 (panic instead of UB)
Logic / platform
14
0
0
C++ ≈ 36%. Rust ≈ 52–60%. Destructors alone buy ~65% of the Rust win. The remaining ~35% is borrowck (aliasing UAFs), no-uninit, exhaustive match, checked indexing.
C++ advantages: no third toolchain; native WTF::/JSC:: types (no #[repr(C)] mirror tax); generate-classes.ts already emits C++ — could put WriteBarrier<> fields directly on impl class, eliminating *SetCachedValue FFI hop.
Rust advantages: borrowck-only bug class (≈5/50); bounds+uninit default-on (≈14/50); mechanically lintable for a 20-agent autonomous fleet — clippy + dylint + #![forbid(unsafe_code)] give compile-time gates clang-tidy can't match. For autonomous porting at scale, "wrong code doesn't compile" > "wrong code gets a sanitizer hit if the test covers it."
A defensible middle (not pursued here): port bun.js/ runtime to C++ (where native JSC types eliminate the mirror tax) and the JSC-free Layer 1 (http/install/bundler/ast/threadpool) to Rust. The crate-layer boundary in §14 is exactly where that split would be clean.
Appendix A — Shared #[repr(C)] ABI catalog (bun_jsc::abi)
These cross the FFI by value today (headers-handwritten.h:19-236; URL.zig:4-19 returns BunString by value via sret):
Write the comprehensive ARCHITECTURE & PRIMITIVES plan for incrementally rewriting Bun's Zig in Rust. You are given ONLY adversarially-verified claims (each survived 3-vote refutation against source). Do not add unverified facts; do not hedge.
VERIFIED CLAIMS:
JSC GC primitives → Rust crate bun_jsc
[bun_jsc::value] JSC-01-jsvalue-repr
FACT: Zig's jsc.JSValue is pub const JSValue = enum(i64) — a transparent 64-bit signed integer ABI-identical to JSC::EncodedJSValue, passed by value through every extern fn (e.g. JSC__JSValue__coerceToInt32(this, globalThis)). Sentinel variants (js_undefined = 0xa, null = 0x2, true, false, zero = 0) are encoded in the integer; there is no heap indirection and no destructor.
RUST: In crate bun_jsc module value: #[repr(transparent)] #[derive(Clone, Copy, PartialEq, Eq)] pub struct JSValue(pub i64); with pub const UNDEFINED: JSValue = JSValue(0xa) etc. Implement !Send + !Sync via a PhantomData<*const ()> newtype wrapper OR a negative-impl marker (impl !Send for JSValue {} on nightly / unsafe impl opt-out via auto-trait blocker) — JSValues are only valid on the VM's mutator thread. No Drop. All FFI signatures take/return JSValue directly (it lowers to i64 in the C ABI exactly like Zig's enum(i64)). The unsafe boundary is only at the extern "C" declarations in bun_jsc::ffi; JSValue itself is a safe POD.
PERF: size_of::<JSValue>() == 8, align_of::<JSValue>() == 8, and JSValue must be Copy so it stays in registers across calls (no implicit memcpy/drop-glue). Verify with a const _: () = assert!(size_of::<JSValue>() == 8) static assertion and by inspecting cargo asm of a hot path (e.g. JSValue::is_cell) — must compile to the same single-compare/shift as JSValue.isCell in Zig. Benchmark: bench/ffi/ffi-overhead.js call-loop latency must not regress.
FACT: JSValue.ensureStillAlive() is an inline function that, only when isCell(), calls std.mem.doNotOptimizeAway(this.asEncoded().asPtr). It exists because JSC's conservative stack scan (ConservativeRoots::genericAddPointer, run during the Cs constraint with the world stopped) treats any aligned word on the native stack pointing into a MarkedBlock/PreciseAllocation as a root — but the compiler may DSE the local after its last visible use. The function emits no code other than an optimization barrier.
RUST: In bun_jsc::value: impl JSValue { #[inline(always)] pub fn ensure_still_alive(self) { if self.is_cell() { core::hint::black_box(self.0); } } }. Also provide pub struct EnsureStillAliveScope(JSValue); impl Drop for EnsureStillAliveScope { #[inline(always)] fn drop(&mut self) { core::hint::black_box(self.0.0); } } for RAII parity with C++'s JSC::EnsureStillAliveScope. #[inline(always)] is mandatory so the black-boxed value lives in the caller's stack frame (cross-crate inlining must not be left to LTO). No unsafe needed.
PERF: ensure_still_alive must compile to ≤2 instructions (cell-tag test + asm barrier) and must not spill to memory more than the Zig version. Measure: cargo asm bun_jsc::JSValue::ensure_still_alive shows no call instruction. Correctness gate: BUN_JSC_collectContinuously=1 bun bd test test/js/bun/gc/ must pass with the Rust build — a missing barrier shows up as release-only UAF there.
FACT: jsc.Strong is struct { impl: *Impl } where Impl is an opaque pointer that is literally a JSC::JSValue*HandleSlot allocated by vm.heap.handleSet()->allocate(). Bun__StrongRef__new allocates the slot, calls handleSet->writeBarrier<false>(slot, value) then stores; Bun__StrongRef__set re-barriers; Bun__StrongRef__delete calls HandleSet::heapFor(slot)->deallocate(slot). Critically, Strong.Impl.get() does no FFI — it @ptrCasts the opaque to *jsc.DecodedJSValue and reads it inline. Strong.Optional is struct { impl: ?*Impl } (8 bytes, nullable pointer).
RUST: In bun_jsc::strong: #[repr(transparent)] pub struct Strong(core::ptr::NonNull<DecodedJSValue>); and pub type StrongOptional = Option<Strong>; (niche-optimized to 8 bytes). impl Strong { pub fn create(v: JSValue, g: *mut JSGlobalObject) -> Self { unsafe { Strong(NonNull::new_unchecked(ffi::Bun__StrongRef__new(g, v))) } } #[inline(always)] pub fn get(&self) -> JSValue { unsafe { (*self.0.as_ptr()).encode() } } pub fn set(&mut self, g: *mut JSGlobalObject, v: JSValue) { unsafe { ffi::Bun__StrongRef__set(self.0.as_ptr(), g, v) } } }. impl Drop for Strong { fn drop(&mut self) { unsafe { ffi::Bun__StrongRef__delete(self.0.as_ptr()) } } }. Unsafe is confined to the four extern "C" calls + the raw deref in get(). Strong: !Send + !Sync (HandleSet is per-VM, mutator-thread-only).
PERF: size_of::<Strong>() == 8, size_of::<Option<Strong>>() == 8 (static-assert). Strong::get() must inline to a single 8-byte load + encode (no FFI call) — verify with cargo asm. Regression test: bench/snippets/response.ts and any HTTP keepalive bench where Strong is on the per-request hot path; p50 latency must match Zig within noise.
FACT: jsc.JSRef is a Zig union(enum) { weak: jsc.JSValue, strong: jsc.Strong.Optional, finalized: void }. The .weak arm is a bare 64-bit JSValue, NOT a JSC::Weak<T> — there is no WeakImpl allocation, no WeakHandleOwner, no entry in any WeakBlock. Soundness depends on the codegen'd C++ destructor calling the Zig finalize(), which flips the state to .finalized so tryGet() returns null instead of a dangling cell. downgrade() reads the strong slot, calls value.ensureStillAlive(), deinits the strong, then stores .weak = value — the conservative stack scan keeps the value alive across the HandleSet deallocate.
RUST: In bun_jsc::jsref: a 16-byte tagged enum laid out identically to Zig: #[repr(C, u8)] pub enum JSRef { Weak(JSValue) = 0, Strong(StrongOptional) = 1, Finalized = 2 } (or, to guarantee Zig-layout parity, #[repr(C)] struct JSRef { payload: JSRefPayload /* untagged union, 8B */, tag: JSRefTag /* u8 */ }). Methods init_weak, init_strong, try_get() -> Option<JSValue>, upgrade(&mut self, g), downgrade(&mut self), set_strong, set_weak, finalize(&mut self). downgrade must call value.ensure_still_alive() between reading the slot and dropping the Strong. No Drop impl (or Drop only debug-asserts matches!(self, Finalized | Weak(UNDEFINED))): destruction is driven by the JSC finalizer calling .finalize(), matching Zig's manual deinit/finalize discipline. Unsafe: none beyond what Strong already encapsulates.
PERF: size_of::<JSRef>() == 16 (8B payload + 1B tag + padding) — static-assert against @sizeOf(jsc.JSRef). try_get() on .weak must be a tag-compare + 8B load, no FFI. upgrade/downgrade cost = exactly one Bun__StrongRef__new/__delete FFI call, same as Zig. Measure: WebSocket echo benchmark (bench/websocket-server) — ServerWebSocket flips weak↔strong per-connection; throughput must match Zig.
SRC: /root/bun-5/src/bun.js/bindings/JSRef.zig (lines 92-95: pub const JSRef = union(enum) { weak: jsc.JSValue, strong: jsc.Strong.Optional, finalized: void, } — .weak is a bare JSValue, not JSC::Weak); /root/bun-5/src/bun.js/bindings/JSRef.zig (lines 152-162 downgrade(): const value = strong.trySwap() orelse .js_undefined; value.ensureStillAlive(); strong.deinit(); this.* = .{ .weak = value }; — relies on conservative scan during the gap); /root/bun-5/src/bun.js/bindings/JSRef.zig (lines 191-195 finalize(): this.deinit(); this.* = .{ .finalized = {} }; — terminal state set by GC finalizer, makes bare-JSValue .weak sound)
[bun_jsc::value] JSC-05-protect-legacy
FACT: JSValue.protect()/unprotect() FFI to Bun__JSValue__protect/unprotect, which check value.isCell() then call gcProtect(cell)/gcUnprotect(cell). These mutate Heap::m_protectedValues (a ref-counted HashMap<JSCell*, unsigned>), visited by the Msr constraint. unprotect additionally takes JSLockHolder lock(cell->vm()) because it may run inside a finalizer. There is no RAII; pairing is manual.
RUST: In bun_jsc::value: impl JSValue { #[deprecated = "use bun_jsc::Strong or bun_jsc::JSRef; gcProtect is a leak-prone global root"] pub unsafe fn protect(self) { ffi::Bun__JSValue__protect(self) } #[deprecated] pub unsafe fn unprotect(self) { ffi::Bun__JSValue__unprotect(self) } }. Mark both unsafe because correctness requires manual 1:1 pairing the type system cannot enforce. Do NOT provide a ProtectGuard RAII wrapper — that would encourage new usage; the migration goal is zero new call sites. Existing Zig call sites port 1:1 with #[allow(deprecated)] until refactored to Strong/JSRef.
PERF: Each call is exactly one FFI hop + HashMap insert/remove, identical to Zig. Since this is a deprecated cold path, the invariant is no additional overhead (no allocation, no guard struct). Verify by counting m_protectedValues size via bun:jsc heapStats() before/after a test that uses the legacy API — counts must match Zig build.
SRC: /root/bun-5/src/bun.js/bindings/JSValue.zig (lines 519-534: pub fn protect(this: JSValue) void { Bun__JSValue__protect(this); } / pub fn unprotect(this: JSValue) void { Bun__JSValue__unprotect(this); } with doc 'A value may be protected multiple times and must be unprotected an equal number of times'); /root/bun-5/src/bun.js/bindings/bindings.cpp (lines 6180-6200: Bun__JSValue__unprotect checks value.isCell(), takes JSLockHolder lock(cell->vm()), calls gcUnprotect(cell); Bun__JSValue__protect calls gcProtect(cell)); /root/bun-5/vendor/WebKit/Source/JavaScriptCore/heap/Heap.cpp (lines 752-763: m_protectedValues.add(k.asCell()) / .remove(k.asCell()); lines 3053-3056: Msr constraint visits for (auto& pair : m_protectedValues) visitor.appendUnbarriered(pair.key))
FACT: Bun has two extra-memory paths. (1) The unpaired legacy path: Zig VM.reportExtraMemory(size) → JSC__VM__reportExtraMemory → vm->heap.deprecatedReportExtraMemory(size) (no visit-side counterpart; used for transient buffers in node/types.zig, h2_frame_parser.zig). (2) The paired path via codegen: when .classes.ts has estimatedSize: true, the generated constructor calls vm.heap.reportExtraMemoryAllocated(instance, size) (a GC safepoint) and the generated visitChildrenImpl calls visitor.reportExtraMemoryVisited(size) with size = ${TypeName}__estimatedSize(ptr). Forgetting the visit-side half causes the GC death-spiral.
RUST: In bun_jsc::vm: impl VM { pub fn report_extra_memory_deprecated(&self, size: usize) { unsafe { ffi::JSC__VM__reportExtraMemory(self.as_ptr(), size) } } } — keep the existing FFI for the unpaired path. For the paired path, in bun_jsc::class_traits: pub trait EstimatedSize { fn estimated_size(&self) -> usize; }. The define_js_class! macro, when estimated_size = true, emits #[no_mangle] extern "C" fn ${TypeName}__estimatedSize(ptr: *mut c_void) -> usize and the C++ codegen continues to emit BOTH reportExtraMemoryAllocated(instance, size) in create() and visitor.reportExtraMemoryVisited(size) in visitChildrenImpl. The macro enforces pairing at compile time — you cannot opt into one half. report_extra_memory_allocated is documented as a GC safepoint: callers must not hold unrooted interior pointers across it.
PERF: estimated_size() runs on the GC marking thread inside visitChildren — must be &self only, no locks, no allocation, ideally a single field read. Measure: heap-growth test (test/js/bun/util/heap-snapshot.test.ts or BUN_JSC_logGC=2 on a Blob-heavy workload) — GC trigger cadence and heapSize curve must match Zig within 5%. A regression here manifests as either OOM (missing allocated-side) or back-to-back full GCs (missing visited-side).
FACT: Zig's MarkedArgumentBuffer is opaque {} exposed only via MarkedArgumentBuffer.run(T, ctx, callback) → FFI MarkedArgumentBuffer__run(ctx, cb), which stack-allocates a JSC::MarkedArgumentBuffer args in C++ (annotated SUPPRESS_ASAN so the inline buffer stays on the real stack for conservative scanning) and invokes callback(ctx, &args). Inside the callback, append(value) FFIs to MarkedArgumentBuffer__append. The buffer roots its contents two ways: the first 8 inline entries via conservative stack scan, and on overflow via Heap::m_markListSet visited in the Msr constraint (MarkedVectorBase::markLists). The buffer is destroyed when run returns — it is strictly scope-bound.
RUST: In bun_jsc::args: #[repr(transparent)] pub struct MarkedArgumentBuffer<'a>(NonNull<c_void>, PhantomData<*mut &'a ()>); (the *mut makes it !Send + !Sync; 'a ties it to the closure scope). Public entry: pub fn with_marked_args<R>(f: impl FnOnce(&mut MarkedArgumentBuffer<'_>) -> R) -> R which builds a #[repr(C)] struct Ctx<F,R>{ f: ManuallyDrop<F>, out: MaybeUninit<R> }, passes &mut ctx + an extern "C" fn trampoline(ctx: *mut c_void, buf: *mut c_void) to ffi::MarkedArgumentBuffer__run, and returns ctx.out.assume_init(). impl MarkedArgumentBuffer<'_> { #[inline] pub fn append(&mut self, v: JSValue) { unsafe { ffi::MarkedArgumentBuffer__append(self.0.as_ptr(), v) } } }. No Drop (C++ owns lifetime). Unsafe is confined to the trampoline + two FFI calls; the 'a lifetime statically prevents the buffer escaping the closure (impossible in Zig, which relies on convention).
PERF: with_marked_args must add zero heap allocations beyond what JSC::MarkedArgumentBuffer itself does (inline 8 → heap on overflow). The trampoline must be a plain extern "C" fn pointer — no Box<dyn FnOnce>. Verify with cargo asm that the closure is monomorphized and the only call is MarkedArgumentBuffer__run. Benchmark: Bun.YAML.parse / Markdown.renderReact (current Zig users) throughput must match.
SRC: /root/bun-5/src/bun.js/bindings/MarkedArgumentBuffer.zig (lines 1-33: opaque {} with extern fn MarkedArgumentBuffer__run(ctx: *anyopaque, cb) and pub fn run(comptime T, ctx, func) callback-scoped API; wrap() builds a JSHostFn that stack-allocates a Context and calls run); /root/bun-5/src/bun.js/bindings/MarkedArgumentBufferBinding.cpp (lines 4-20: SUPPRESS_ASAN void MarkedArgumentBuffer__run(...) { JSC::MarkedArgumentBuffer args; callback(ctx, &args); } with comment explaining inline buffer relies on conservative stack scan); /root/bun-5/vendor/WebKit/Source/JavaScriptCore/heap/Heap.cpp (lines 3058-3061: if (!m_markListSet.isEmpty()) { ... MarkedVectorBase::markLists(visitor, m_markListSet); } — overflow buffers rooted via Msr constraint)
FACT: All Zig GC primitives (JSValue, Strong, JSRef, MarkedArgumentBuffer, VM.reportExtraMemory) are implicitly mutator-thread-only: Bun__StrongRef__new calls globalObject->vm() and vm.heap.handleSet()->allocate() without locking; gcProtect mutates m_protectedValues (a non-thread-safe HashMap); MarkedArgumentBuffer lives on the JS thread's stack. The single cross-thread surface is hasPendingActivity, which is called from the GC thread but only reads the native struct. Zig has no type-level enforcement of this.
RUST: In bun_jsc root: define pub(crate) type NotSendSync = PhantomData<*const ()>; and embed it in JSValue, Strong, JSRef, MarkedArgumentBuffer<'_>, *mut JSGlobalObject newtype, and VM handle. This makes accidental tokio::spawn/rayon movement a compile error — a safety upgrade over Zig at zero runtime cost. The HasPendingActivity trait is the documented exception: its &self receiver may be aliased from the GC thread, hence the unsafe trait bound and atomic-only rule from JSC-06.
PERF: PhantomData is zero-sized; static-assert all primitive sizes unchanged (JSValue=8, Strong=8, JSRef=16). No runtime check is added. This is a compile-time-only invariant; measure by confirming size_of and that no .clone() of these types appears in hot-path asm.
SRC: /root/bun-5/src/bun.js/bindings/StrongRef.cpp (lines 15-27: Bun__StrongRef__new does auto& vm = globalObject->vm(); JSC::HandleSet* handleSet = vm.heap.handleSet(); JSC::HandleSlot handleSlot = handleSet->allocate(); — no lock, mutator-thread assumption); /root/bun-5/src/bun.js/bindings/bindings.cpp (lines 6180-6190: Bun__JSValue__unprotect explicitly takes JSLockHolder lock(cell->vm()) because it can run from a finalizer — the exception that proves protect/unprotect otherwise assume the JS lock); /root/bun-5/src/bun.js/bindings/MarkedArgumentBufferBinding.cpp (lines 4-14: SUPPRESS_ASAN comment — buffer must live on the mutator thread's real stack for conservative scan)
FACT: When .classes.ts sets hasPendingActivity: true, codegen emits (a) JSC::Weak<${name}> m_weakThis plus a static nested Owner final : public JSC::WeakHandleOwner whose isReachableFromOpaqueRoots calls ${name}::hasPendingActivity(controller->wrapped()); m_weakThis = JSC::Weak<${name}>(this, getOwner()); is assigned in the generated C++ constructor body (NOT in finishCreation); (b) an extern declared as extern JSC_CALLCONV bool JSC_HOST_CALL_ATTRIBUTES ${TypeName}__hasPendingActivity(void* ptr); where JSC_CALLCONV is "C" on POSIX but "C" SYSV_ABI (= __attribute__((sysv_abi))) on Windows x64, and the Zig side exports it as pub fn ${TypeName}__hasPendingActivity(thisValue: *${typeName}) callconv(jsc.conv) bool where jsc.conv is .x86_64_sysv on Windows x64 and .c elsewhere. isReachableFromOpaqueRoots is invoked from WeakBlock::visit() during the Ws ("Weak Sets") constraint, which runs with ConstraintParallelism::Parallel — i.e. on a GC marking helper thread concurrently with the mutator — so the Zig hasPendingActivity must be side-effect-free, allocation-free, and read only atomics or immutable data. The codegen comment confirms hasPendingActivity does NOT need visitChildren; liveness is decided entirely by the WeakHandleOwner predicate.
RUST: In bun_jsc::class_traits: pub unsafe trait HasPendingActivity { unsafe fn has_pending_activity(this: *const Self) -> bool; } — unsafe trait because the implementer promises GC-thread safety (no allocation, no JS heap access, atomic loads only), and the method takes *const Self rather than &self so that creating a &T on a GC helper thread cannot alias a &mut T held by the mutator (Zig has no such aliasing model; passing the raw pointer through preserves equivalence). The define_js_class! proc-macro in bun_jsc::codegen, when has_pending_activity = true, emits a cfg-gated symbol that mirrors jsc.conv / JSC_CALLCONV exactly:
(Preferably factored through a jsc_extern! helper macro the rest of bun_jsc reuses for every callconv(jsc.conv) symbol.) The macro continues to emit the C++ side (JSC::Weak<> m_weakThis assigned in the constructor + Owner : WeakHandleOwner) unchanged. Recommended impl pattern: struct Foo { pending: AtomicU32, ... } unsafe impl HasPendingActivity for Foo { unsafe fn has_pending_activity(this: *const Self) -> bool { (*core::ptr::addr_of!((*this).pending)).load(Ordering::Acquire) > 0 } }.
PERF: The exported function must compile to a leaf (one atomic load + cmp + ret) with no landing pad, identical to the Zig @call(bun.callmod_inline, T.hasPendingActivity, .{thisValue}) output. Achieve this by building the bun_jsc crate with panic = "abort" (Bun never unwinds across FFI) so rustc emits nounwind and drops the personality/landing-pad — do NOT rely on #[inline(never)] for this. Measure: (1) llvm-objdump -d the symbol on linux-x64 and windows-x64 and diff against the Zig build's symbol — instruction count and lack of __gxx_personality/.gcc_except_table must match; (2) run test/js/bun/gc/ and any hasPendingActivity-covered test under BUN_FORCE_GC=1 with --heap-prof and confirm Ws-constraint wall time is within noise of the Zig baseline.
SRC: /root/bun-5/src/codegen/generate-classes.ts (lines 1404-1434: when obj.hasPendingActivity emits weakInit = "m_weakThis = JSC::Weak<${name}>(this, getOwner());" and class Owner final : public JSC::WeakHandleOwner { bool isReachableFromOpaqueRoots(...) { auto* controller = uncheckedDowncast<${name}>(handle.slot()->asCell()); if (${name}::hasPendingActivity(controller->wrapped())) { ... return true; } return false; } }); /root/bun-5/src/codegen/generate-classes.ts (lines 1511-1516 (and 1524, 1535): weakInit is interpolated into the generated constructor body${name}(JSC::VM& vm, JSC::Structure* structure, void* sinkPtr) : Base(vm, structure) { m_ctx = sinkPtr; ${weakInit} }; finishCreation(JSC::VM&) is declared separately at line 1539 and does not contain weakInit); /root/bun-5/src/codegen/generate-classes.ts (lines 1640-1643 comment: 'hasPendingActivity does not need visitChildren at all: liveness is decided by the WeakHandleOwner calling hasPendingActivity(wrapped()) directly during weak processing.'); /root/bun-5/src/codegen/generate-classes.ts (lines 1682-1690: emits extern JSC_CALLCONV bool JSC_HOST_CALL_ATTRIBUTES ${TypeName}__hasPendingActivity(void* ptr); and the C++ shim bool ${name}::hasPendingActivity(void* ctx) { return ${TypeName}__hasPendingActivity(ctx); }); /root/bun-5/src/codegen/generate-classes.ts (lines 2643-2647: #if !OS(WINDOWS) #define JSC_CALLCONV "C" #else #define JSC_CALLCONV "C" SYSV_ABI #endif — the C++ caller uses SysV ABI on Windows); /root/bun-5/src/codegen/generate-classes.ts (lines 2180-2186: Zig export pub fn ${TypeName}__hasPendingActivity(thisValue: *${typeName}) callconv(jsc.conv) bool { return @call(bun.callmod_inline, ${typeName}.hasPendingActivity, .{thisValue}); }); /root/bun-5/src/bun.js/jsc.zig (lines 9-12: pub const conv: std.builtin.CallingConvention = if (bun.Environment.isWindows and bun.Environment.isX64) .{ .x86_64_sysv = .{} } else .c; — Zig side matches JSC_CALLCONV; Rust must emit extern "sysv64" on Windows x64 and extern "C" elsewhere); /root/bun-5/vendor/WebKit/Source/WTF/wtf/PlatformCallingConventions.h (line 37: #define SYSV_ABI __attribute__((sysv_abi)) (on Windows x64); line 39 defines it empty elsewhere); /root/bun-5/vendor/WebKit/Source/JavaScriptCore/heap/WeakBlock.cpp (line 129: if (!weakHandleOwner->isReachableFromOpaqueRoots(Handle<Unknown>::wrapSlot(...), weakImpl->context(), visitor, reasonPtr)) — called from WeakBlock::visit()); /root/bun-5/vendor/WebKit/Source/JavaScriptCore/heap/Heap.cpp (lines 3104-3111: registers the "Ws", "Weak Sets" constraint that runs m_objectSpace.forEachWeakInParallel(visitor) with ConstraintParallelism::Parallel — confirms isReachableFromOpaqueRoots executes on parallel GC marking threads)
FACT: Bun's DOMGCOutputConstraint (BunGCOutputConstraint.cpp:104-137) is registered with tag "Domo", ConstraintVolatility::SeldomGreyed, ConstraintConcurrency::Concurrent, ConstraintParallelism::Parallel; on each fire it returns early unless heap.mutatorExecutionVersion() advanced, then iterates m_heapData.forEachOutputConstraintSpace and calls cell->methodTable()->visitOutputConstraints(cell, visitor) on every marked cell in parallel. Membership in m_outputConstraintSpaces is decided lazily at first subspaceFor<T>() call by a runtime function-pointer compare in WebCore::subspaceForImpl (BunClientData.h:193-196): the subspace is appended iff T::visitOutputConstraints != JSC::JSCell::visitOutputConstraints. The .classes.ts codegen NEVER overrides visitOutputConstraints (only mentions are comment lines 1618/1621), so ZERO generated classes are ever enrolled in the Domo loop. The predicate at generate-classes.ts:1644 (DEFINE_VISIT_CHILDREN_LIST.length || estimatedSize || values.length || obj.valuesArray — where DEFINE_VISIT_CHILDREN_LIST includes cache:true fields AND callbacks, lines 1602-1610) gates emission of visitChildrenImpl + DEFINE_VISIT_CHILDREN(${name}) ONLY; it has no bearing on output-constraint enrollment. hasPendingActivity requires neither visitChildren nor visitOutputConstraints (comment 1640-1643).
RUST: DOMGCOutputConstraint, JSHeapData::m_outputConstraintSpaces, and subspaceForImpl remain unmodified C++ in the bun-bindings crate — bun_jsc does NOT reimplement or wrap them. The Rust define_js_class! macro (in bun_jsc::codegen) mirrors generate-classes.ts exactly: (a) compute needs_visit_children = has_cached_writebarrier_fields || has_callbacks || has_values || has_values_array || has_estimated_size; when true, emit C++ template<typename Visitor> void ${Name}::visitChildrenImpl(JSCell*, Visitor&) + DEFINE_VISIT_CHILDREN(${Name}) that appends each WriteBarrier<Unknown> field, calls visitor.reportExtraMemoryVisited(${TypeName}__estimatedSize(ptr)) if applicable, and loops jsvalueArray; (b) NEVER emit a visitOutputConstraints override or DECLARE_VISIT_OUTPUT_CONSTRAINTS for any macro-generated class — the inherited JSCell::visitOutputConstraints must remain the resolved symbol so the pointer-compare in subspaceForImpl stays equal and the type is kept out of m_outputConstraintSpaces. Rust's only FFI surface here is #[no_mangle] extern "C" fn ${TypeName}__estimatedSize(ptr: *mut Self) -> usize plus the cached-value setter shims that call through to WriteBarrier::set on the C++ side; no Rust code runs inside the GC constraint loop.
PERF: The set of subspaces enrolled in JSHeapData::m_outputConstraintSpaces must be byte-for-byte identical between the Zig-codegen build and the Rust-codegen build — specifically, NO define_js_class!-generated type may appear (the Zig build contributes zero). Over-enrolling even one generated type makes every incremental GC re-walk every live instance of that type via forEachMarkedCellInParallel, the exact "pure overhead" called out at generate-classes.ts:1636-1638. Measure: add a debug-only extern "C" hook that dumps heapData.outputConstraintSpaces().size() and the ClassInfo->className of each entry after warmup; assert the list matches the Zig baseline (only hand-written WebCore-style classes that explicitly override visitOutputConstraints). There is no static list in ZigGeneratedClasses.cpp to diff — verification must be at runtime.
SRC: /root/bun-5/src/bun.js/bindings/BunGCOutputConstraint.cpp (lines 104-105: ctor passes "Domo", "DOM Output", ConstraintVolatility::SeldomGreyed, ConstraintConcurrency::Concurrent, ConstraintParallelism::Parallel; lines 117-137: executeImplImpl early-returns if heap.mutatorExecutionVersion() == m_lastExecutionVersion, else iterates m_heapData.forEachOutputConstraintSpace and for each subspace builds subspace.forEachMarkedCellInParallel task invoking cell->methodTable()->visitOutputConstraints(cell, visitor).); /root/bun-5/src/bun.js/bindings/BunClientData.h (lines 54-61: JSHeapData::outputConstraintSpaces() returns Vector<JSC::IsoSubspace*>&; forEachOutputConstraintSpace is a runtime loop over that vector. lines 193-196 (inside subspaceForImpl): void (*myVisitOutputConstraint)(...) = T::visitOutputConstraints; void (*jsCellVisitOutputConstraint)(...) = JSC::JSCell::visitOutputConstraints; if (myVisitOutputConstraint != jsCellVisitOutputConstraint) heapData.outputConstraintSpaces().append(space); — enrollment is a dynamic function-pointer compare, not codegen-driven.); /root/bun-5/src/codegen/generate-classes.ts (lines 1618-1619: // Generated classes intentionally do NOT override visitOutputConstraints (and therefore // do not get enrolled in BunGCOutputConstraint / m_outputConstraintSpaces). — grep confirms visitOutputConstraints appears only in these comment lines, never in emitted code. lines 1602-1610: DEFINE_VISIT_CHILDREN_LIST is built from cache:true fields AND callbacks (visitor.appendHidden(thisObject->m_callback_${name})). line 1644: if (DEFINE_VISIT_CHILDREN_LIST.length || estimatedSize || values.length || obj.valuesArray) gates the block at 1645-1672 which emits ONLY visitChildrenImpl + DEFINE_VISIT_CHILDREN(${name}) — no visitOutputConstraints override. lines 1636-1638: re-walking every live instance of every generated type after each mutator yield is pure overhead. Only visitChildren is needed. lines 1640-1643: hasPendingActivity does not need visitChildren at all.)
FACT: Every native↔JSC boundary symbol emitted by generate-classes.ts uses callconv(jsc.conv) on the Zig side and extern JSC_CALLCONV ... JSC_HOST_CALL_ATTRIBUTES on the C++ side. jsc.conv resolves to .x86_64_sysv on Windows-x64 and .c everywhere else (jsc.zig:9-12); JSC_CALLCONV is #defined in the generated .cpp as extern "C" on POSIX and extern "C" SYSV_ABI on Windows (generate-classes.ts:2642-2647); JSC_HOST_CALL_ATTRIBUTES is __attribute__((sysv_abi)) on win-x64 (PlatformCallingConventions.h:37-46). This is required because JSC's JIT emits SysV calls even on Windows.
RUST: In crates/bun-jsc-sys/src/abi.rs define #[cfg(all(windows, target_arch="x86_64"))] pub use sysv as jsc_conv; else pub use c as jsc_conv;, exposed via attribute macro #[bun_jsc::host_fn] that expands to #[unsafe(no_mangle)] pub extern "sysv64" fn ... on win-x64 and extern "C" otherwise. All generated extern blocks use extern "sysv64" / extern "C" selected by the same cfg. JSValue is #[repr(transparent)] pub struct JSValue(pub u64) so it is passed in a single integer register under both ABIs (matches EncodedJSValue = int64_t).
PERF: Zero ABI translation shims — the Rust extern symbol IS the function the C++ thunk calls (no trampoline). Verify: nm build/release/bun | grep <TypeName>Prototype__ shows the symbol resolves into the Rust object file, and objdump -d of a generated *Callback shows a direct call (not via PLT thunk on win-x64). Microbench: bench/snippets/crypto-hasher.mjs (heavy proto-method dispatch) must match Zig within ±1%.
FACT: The entire C++ layer is language-agnostic about what implements m_ctx: JS${T} : JSDestructibleObject holds only void* m_ctx plus WriteBarrier<Unknown> fields for cache/values/callbacks; the *GetterWrap/*SetterWrap/*Callback thunks do the dynamicDowncast/EnsureStillAliveScope/DECLARE_THROW_SCOPE work and then call the extern with thisObject->wrapped(). IsoSubspace registration (subspaceFor<> + emitted ZigGeneratedClasses+DOMIsoSubspaces.h/+DOMClientIsoSubspaces.h), visitChildren, analyzeHeap, LazyClassStructure init, HashTableValue[] tables, and DOMJIT::Signature constants are ALL generated into C++ and stay in C++ unmodified.
RUST: No Rust changes here — the Rust port replaces ONLY the implementer of the extern symbols. generateImpl/generateHeader/generateClassHeader continue emitting ZigGeneratedClasses.{h,cpp} byte-identically. The Rust emitter is a new sibling output (generated_classes.rs) gated per-class by a lang: "rust" flag added to ClassDefinition in src/codegen/class-definitions.ts, defaulting to "zig" so migration is incremental: a class moves by flipping one flag, C++ output is invariant.
PERF: ZigGeneratedClasses.cpp SHA must not change when a class flips zig→rust. Measure: sha256sum build/debug/codegen/ZigGeneratedClasses.cpp before/after flipping lang on one class. Any diff = perf risk (changed thunk inlining, lost DOMJIT signature, etc.).
SRC: /root/bun-5/src/codegen/generate-classes.ts (lines 1443-1551: generateClassHeader emits class JS${T} : JSDestructibleObject { void* m_ctx; ... WriteBarrier<Unknown> m_X; subspaceFor<...> }); /root/bun-5/src/codegen/generate-classes.ts (lines 1270-1340: *Callback thunk: dynamicDowncast, EnsureStillAliveScope, then ${sym}(thisObject->wrapped(), ...) — wrapped() is the only native dependency); /root/bun-5/src/codegen/generate-classes.ts (lines 3026-3037: writes ZigGeneratedClasses+DOMIsoSubspaces.h / +DOMClientIsoSubspaces.h / +lazyStructureImpl.h — pure C++ artifacts)
FACT: When a class has neither estimatedSize nor memoryCost, the native side must export a link-time constant ${T}__ZigStructSize: usize = @sizeOf(${T}) which C++ references as extern "C" const size_t ${T}__ZigStructSize for JS${T}::memoryCost() (heap snapshot accounting).
RUST: Rust emitter writes #[unsafe(no_mangle)] pub static ${T}__ZigStructSize: usize = core::mem::size_of::<$T>(); (note: static, not const, so it gets a symbol). Keep the Zig infix in the symbol name — it is load-bearing for the C++ extern; renaming would require touching ZigGeneratedClasses.cpp. Lives in the per-class block of build/codegen/generated_classes.rs.
PERF: Link succeeds without undefined reference to ${T}__ZigStructSize. Heap-snapshot test (test/js/bun/util/heap-snapshot.test.ts) reports same byte counts ±sizeof-padding-diff between Zig and Rust struct layouts.
SRC: /root/bun-5/src/codegen/generate-classes.ts (lines 1735-1737 + 1751-1755: C++ extern "C" const size_t ${T}__ZigStructSize consumed by memoryCost()); /root/bun-5/src/codegen/generate-classes.ts (lines 2152-2154: Zig side export const ${T}__ZigStructSize: usize = @sizeOf(${T}))
[src/codegen (which generator to extend)] GC-09-bindgenv2-scope
FACT: bindgenv2 (src/codegen/bindgenv2/) is a WebIDL-style TYPE marshaller: it reads *.bindv2.ts files exporting dictionary/union/enumeration/Array/primitive NamedTypes and emits per-type Generated${Name}.{h,cpp} + bindgen_generated/${ns}.zig for converting JS values ↔ native structs. It has NO concept of JSCell, IsoSubspace, prototype, WriteBarrier, or m_ctx. generate-classes.ts is the JSCell-wrapper generator and is the correct place to add Rust output.
RUST: Extend generate-classes.ts: add generateRust(typeName, def) as a sibling of generateZig() (line ~2028) and a new writeIfNotChanged("${outBase}/generated_classes.rs", ...) block next to line ~2889. Add lang?: "zig" | "rust" to ClassDefinition in src/codegen/class-definitions.ts. The 29 existing *.classes.ts files remain the single source of truth; per-class migration = change one field. bindgenv2 can later grow a Rust backend independently for argument-dictionary marshalling, but it is orthogonal to the JSCell wrapper contract.
PERF: Build-time only; no runtime impact. CI invariant: bun run build with mixed zig+rust classes produces a single ZigGeneratedClasses.cpp identical to today's, plus a new .rs file consumed by the bun-runtime crate's build.rs via include!(concat!(env!("BUN_CODEGEN_DIR"), "/generated_classes.rs")).
SRC: /root/bun-5/src/codegen/bindgenv2/lib.ts (exports only type combinators: bool/u8/.../String/optional/union/dictionary/enumeration/Array/ArrayBuffer/Blob — no class/prototype machinery); /root/bun-5/src/codegen/bindgenv2/script.ts (lines 72-140: generate() walks NamedType exports → writes Generated${name}.h/.cpp + bindgen_generated/${ns}.zig; nothing JSCell-related); /root/bun-5/src/codegen/generate-classes.ts (lines 2889-2904 + 3011-3041: single emitter writes ZigGeneratedClasses.{zig,h,cpp,d.ts} + IsoSubspaces/LazyStructure headers — the integration point for a .rs sibling)
FACT: Per .classes.ts Field kind, the C++ side declares fixed extern shapes that the native side MUST export with these exact unmangled names and signatures: proto getter → ${T}Prototype__${getter}(void* ptr, [EncodedJSValue thisValue,] JSGlobalObject*) -> EncodedJSValue; proto setter → ${T}Prototype__${setter}(void* ptr, [EncodedJSValue thisValue,] JSGlobalObject*, EncodedJSValue value) -> bool; proto fn → ${T}Prototype__${fn}(void* ptr, JSGlobalObject*, CallFrame* [, EncodedJSValue thisValue]) -> EncodedJSValue; klass fn → ${T}Class__${fn}(JSGlobalObject*, CallFrame*) -> EncodedJSValue (raw JSC_DECLARE_HOST_FUNCTION); klass getter → ${T}Class__${getter}(JSGlobalObject*, EncodedJSValue thisValue, PropertyName) -> EncodedJSValue (raw JSC_DECLARE_CUSTOM_GETTER, 3 args); klass setter → ${T}Class__${setter}(JSGlobalObject*, EncodedJSValue thisValue, EncodedJSValue value, PropertyName) -> bool (raw JSC_DECLARE_CUSTOM_SETTER, 4 args); construct → ${T}Class__construct(JSGlobalObject*, CallFrame* [, EncodedJSValue]) -> void*; finalize → ${T}Class__finalize(void*) -> void; hasPendingActivity → ${T}__hasPendingActivity(void*) -> bool; estimatedSize/memoryCost → ${T}__estimatedSize(void*) -> size_t / ${T}__memoryCost(void*) -> size_t.
RUST: The Rust emitter in src/codegen/generate-classes.ts::generateRust() writes one #[unsafe(no_mangle)] pub extern "<JSC_CALLCONV>" fn per shape into build/codegen/generated_classes.rs, each calling #[inline(always)] into the user's impl Foo via a generated trait bun_jsc::NativeClass. First parameter void* ptr becomes this: *mut Foo (Rust struct lives in crates/bun-runtime/src/...); JSGlobalObject*/CallFrame*/PropertyName are #[repr(transparent)] newtypes around NonNull<c_void> from bun-jsc-sys; EncodedJSValue is #[repr(transparent)] struct(i64). Klass-static getters emit the 3-arg form (global, this_value, prop) -> EncodedJSValue; klass-static setters emit the 4-arg form (global, this_value, value, prop) -> bool to match JSC_DECLARE_CUSTOM_SETTER exactly. The this: true / passThis / constructNeedsThis flags add an EncodedJSValue parameter at the position dictated by generate-classes.ts:925-965 / :413 — the emitter must mirror that ordering byte-for-byte.
PERF: ZigGeneratedClasses.cpp / ZigGeneratedClasses.h remain bit-identical regardless of whether the native side is Zig or Rust (verified by diff of codegen output). Each generated Rust thunk compiles to a tail-jump (#[inline(always)] body, no panic landing pad via -C panic=abort) so the extern → impl hop adds zero stack frames vs Zig's callconv(JSC.conv) exports — confirmed by objdump -d showing direct jmp/branch and by bun bench bench/ffi parity within noise.
SRC: /root/bun-5/src/codegen/generate-classes.ts (lines 25-35: symbol naming — symbolName → ${T}__${name}, protoSymbolName → ${T}Prototype__${name}, classSymbolName → ${T}Class__${name}); /root/bun-5/src/codegen/generate-classes.ts (lines 920-965 (renderDecls): proto getter extern EncodedJSValue ${sym}(void* ptr, [EncodedJSValue thisValue,] JSGlobalObject*); proto setter extern bool ${sym}(void* ptr, [EncodedJSValue thisValue,] JSGlobalObject*, EncodedJSValue value); proto fn extern EncodedJSValue ${sym}(void* ptr, JSGlobalObject*, CallFrame* [, EncodedJSValue thisValue])); /root/bun-5/src/codegen/generate-classes.ts (lines 996-1020 (renderStaticDecls): klass getter via raw JSC_DECLARE_CUSTOM_GETTER, klass setter via raw JSC_DECLARE_CUSTOM_SETTER, klass fn via raw JSC_DECLARE_HOST_FUNCTION — no void* ptr first arg); /root/bun-5/vendor/WebKit/Source/WTF/wtf/PlatformCallingConventions.h (line 145: JSC_DECLARE_CUSTOM_GETTER = EncodedJSValue (JSGlobalObject*, EncodedJSValue, PropertyName) (3 args); line 146: JSC_DECLARE_CUSTOM_SETTER = bool (JSGlobalObject*, EncodedJSValue, EncodedJSValue, PropertyName) (4 args)); /root/bun-5/src/codegen/generate-classes.ts (lines 410-421: construct extern void* ${T}Class__construct(JSGlobalObject*, CallFrame*) at :418, or with trailing EncodedJSValue at :413 when constructNeedsThis; line 440: finalize extern void ${T}Class__finalize(void*)); /root/bun-5/src/codegen/generate-classes.ts (line 1381: extern size_t ${T}__estimatedSize(void* ptr); line 1684: extern bool ${T}__hasPendingActivity(void* ptr); line 1740: extern size_t ${T}__memoryCost(void* ptr))
FACT: The exception protocol at the Zig↔C++ FFI boundary is sentinel-based, not Result-based: a returned EncodedJSValue == 0 (JSValue::zero / empty JSValue) iff vm.hasException(). Zig's bun.JSError = error{JSError, OutOfMemory, JSTerminated} is flattened by toJSHostFnResult: JSError/JSTerminated → return .zero; OutOfMemory → globalThis.throwOutOfMemoryValue() (which calls extern JSGlobalObject__throwOutOfMemoryError then returns .zero). Debug builds assert the full biconditional (value == .zero) == globalThis.hasException() via debugExceptionAssertion / ExceptionValidationScope.assertExceptionPresenceMatches. Setters return bool (false = exception). Constructors return void* == nullptr on exception, and the generated C++ ASSERT_WITH_MESSAGE(!ptr, …) when scope.exception() is set.
RUST: In crates/bun-jsc/src/error.rs: #[repr(u8)] pub enum JsError { JSError, OutOfMemory, JSTerminated } and pub type JsResult<T> = Result<T, JsError>. Generated extern "C" thunks call #[inline(always)] bun_jsc::host_fn_result(global, || user_fn(...)) -> EncodedJSValue which mirrors toJSHostFnResult: on Ok(v) use v.0; on Err(JSError)|Err(JSTerminated) use 0; on Err(OutOfMemory) call extern "C" fn JSGlobalObject__throwOutOfMemoryError(*mut JSGlobalObject) (the real exported symbol, bindings.cpp:2232) then use 0. After computing the final encoded value ret, in cfg(debug_assertions) call extern "C" fn JSGlobalObject__hasException(*mut JSGlobalObject) -> bool (bindings.cpp:6041) and debug_assert_eq!(ret == 0, has_exc) — checking BOTH arms, exactly matching host_fn.zig:68. Setter variant host_setter_result(global, ...) -> bool mirrors toJSHostSetterValue (false on any Err, throwing OOM first). Constructor variant returns *mut T (null on Err, with the same biconditional debug assert against (ptr.is_null()) == has_exc). Panic policy is explicit: the bun-jsc crate is built with panic = "abort" (matching Zig's no-unwind semantics), so no landing pads are emitted and no unwind crosses into JSC C++; the generated thunk contains no catch_unwind. User code stays in safe fn(&mut JSGlobalObject, &CallFrame) -> JsResult<JSValue>; only the codegen-emitted extern "C" shim touches raw pointers.
PERF: Release codegen of host_fn_result must be branch-count-equal to Zig's toJSHostFnResult: one discriminant test on the Result (Ok vs Err), and on the Err path a 3-way match on a u8 discriminant — identical to Zig's catch |err| switch. #[inline(always)] on the wrapper plus panic=abort (no landing pads) yields a thunk whose --emit=asm contains no _Unwind_Resume/__rust_panic references and no extra spills vs the Zig callconv(jsc.conv) wrapper. The debug-only JSGlobalObject__hasException call is gated behind cfg(debug_assertions) so release has zero VM probes. Measure: (a) objdump -d instruction-count diff of one generated getter thunk vs the Zig equivalent in build/debug/codegen/ZigGeneratedClasses.cpp's caller; (b) BUN_JSC_validateExceptionChecks=1 test suite passes under the Rust thunks (the biconditional assert is now actually exercised).
SRC: /root/bun-5/src/bun.js/jsc/host_fn.zig (lines 31-46: toJSHostFnResult maps error.JSError/.JSTerminated → .zero, error.OutOfMemory → globalThis.throwOutOfMemoryValue(); line 68: bun.assert((value == .zero) == globalThis.hasException()); lines 84-104: toJSHostCall opens ExceptionValidationScope and calls scope.assertExceptionPresenceMatches(normal == .zero)); /root/bun-5/src/bun.js/jsc/host_fn.zig (lines 71-81: toJSHostSetterValue(globalThis, value: error{OutOfMemory,JSError,JSTerminated}!void) bool returns false on any error variant (calling throwOutOfMemoryValue() for OOM), true on success); /root/bun-5/src/bun.zig (lines 155-163: pub const JSError = error{ JSError, OutOfMemory, JSTerminated };); /root/bun-5/src/bun.js/bindings/JSGlobalObject.zig (line 10: extern fn JSGlobalObject__throwOutOfMemoryError(this: *JSGlobalObject) void; lines 21-24: throwOutOfMemoryValue is a Zig-only wrapper calling that extern then returning .zero; lines 539-540 + 858: hasException wraps extern fn JSGlobalObject__hasException(*JSGlobalObject) bool); /root/bun-5/src/bun.js/bindings/bindings.cpp (line 2232: void JSGlobalObject__throwOutOfMemoryError(JSC::JSGlobalObject*) — the real C symbol the Rust thunk must link against; line 6041: extern "C" bool JSGlobalObject__hasException(JSC::JSGlobalObject*) — used for the debug biconditional assert); /root/bun-5/src/codegen/generate-classes.ts (lines 683, 736, 740, 748: constructor path if (scope.exception()) { ASSERT_WITH_MESSAGE(!ptr, "Memory leak detected: …"); return jsUndefined(); } enforcing nullptr-on-exception; line 1308: fn callback ASSERT_WITH_MESSAGE(!JSValue::decode(result).isEmpty() or DECLARE_TOP_EXCEPTION_SCOPE(vm).exception() != 0, …) enforcing empty⇒exception)
[crates/bun-jsc (proposed) — class codegen / GC edge contract] codegen-contract
FACT: Generated classes deliberately do NOT override visitOutputConstraints and are NOT enrolled in BunGCOutputConstraint/m_outputConstraintSpaces. The comment at generate-classes.ts:1618-1643 explains why: every GC-visible edge from a generated class is either (a) a WriteBarrier<Unknown> field on the JSCell mutated only via .set(vm, thisObject, v) — which fires vm.writeBarrier and re-greys the owner — through the emitted *SetCachedValue externs, or (b) jsvalueArray, a FixedVector<WriteBarrier<>> populated before allocateCell() and never resized. Only visitChildren is emitted. Consequently native (Zig today, Rust tomorrow) MUST NOT stash raw JSValues inside the m_ctx struct expecting GC to find them; the only sanctioned native→GC-heap edge is the generated *SetCachedValue/*GetCachedValue FFI pair.
RUST: PROPOSED (no crates/ exists yet): in crates/bun-jsc/src/class.rs, the NativeClass derive/trait MUST NOT expose a visit_children or visit_output_constraints hook. JSValue storage from Rust is ONLY via generated mod gc { pub fn <field>_set(this: JSValue, global: *mut JSGlobalObject, v: JSValue) } wrappers that call the existing C++ extern "C" <Type>Prototype__<field>SetCachedValue (which does thisObject->m_<field>.set(vm, thisObject, ...)) — i.e. the exact same extern the Zig ${name}SetCached wrapper calls today. Enforce statically: the #[derive(NativeClass)] macro rejects any struct field of type JSValue/EncodedJSValue with a compile error directing the author to declare a cache: true property in the .classes.ts instead. JSC::Strong/JSC::Weak handles remain C++-side only; Rust never owns them inline.
PERF: The Rust port must emit ZERO visitOutputConstraints overrides and add ZERO entries to m_outputConstraintSpaces for codegen classes — verifiable by grep -c outputConstraint build/debug/codegen/ZigGeneratedClasses.h returning 0 (matches Zig today). Each gc::<field>_set call must compile to a single direct extern "C" call (identical symbol to the Zig path), so per-set cost is one FFI hop + one WriteBarrier::set — byte-for-byte the same as the current Zig ${name}SetCached inline wrapper at generate-classes.ts:2127-2130.
SRC: /root/bun-5/src/codegen/generate-classes.ts (lines 1618-1643: full comment explaining why generated classes skip visitOutputConstraints/BunGCOutputConstraint; lines 1628-1634 enumerate the only two edge kinds (WriteBarrier fields via *SetCachedValue, and immutable jsvalueArray); line 1638: "Only visitChildren is needed."); /root/bun-5/src/codegen/generate-classes.ts (lines 1025-1042: writeBarrier() emits the C++ *SetCachedValue extern body thisObject->${cacheName}.set(vm, thisObject, JSValue::decode(value)) and the matching *GetCachedValue — the ONLY sanctioned native→GC-heap mutation path.); /root/bun-5/src/codegen/generate-classes.ts (lines 2121-2136: Zig-side extern fn ...SetCachedValue/GetCachedValue declarations and the thin pub fn ${name}SetCached(...) wrapper that just forwards to the C++ extern — the Rust gc::set wrappers must mirror this exactly (same extern symbol, no extra hop).)
[bun-codegen / bun-jsc-ffi] codegen-contract
FACT: generate-classes.ts emits, per class, a fixed set of C++-defined externs that the native (Zig) side imports with callconv(jsc.conv): ${T}__fromJS(JSValue) -> ?*T, ${T}__fromJSDirect(JSValue) -> ?*T, ${T}__create(*JSGlobalObject, ?*T) -> JSValue, ${T}__getConstructor(*JSGlobalObject) -> JSValue, ${T}__dangerouslySetPtr(JSValue, ?*T) -> bool, plus per-cached-field ${T}Prototype__${name}SetCachedValue(JSValue, *JSGlobalObject, JSValue) -> void and ...GetCachedValue(JSValue) -> JSValue. jsc.conv is SysV on Windows-x64 and C elsewhere. The Zig wrappers (fromJS, fromJSDirect, toJS, getConstructor, detachPtr, the gc enum's get/set/clear) are thin pass-throughs that return raw ?*T / JSValue with no aliasing or lifetime guarantees — ownership of *T is transferred to JSC at __create and reclaimed in the separately-generated finalize export, not by the caller of toJS.
RUST: The Rust emitter in bun-codegen generates, per class, a #[cfg_attr(all(windows, target_arch = "x86_64"), link(...))]-guarded extern block: #[cfg(all(windows, target_arch="x86_64"))] extern "sysv64" { ... } #[cfg(not(...))] extern "C" { ... } declaring fn ${T}__fromJS(v: JSValue) -> Option<NonNull<$T>>, __fromJSDirect, __create(g: *mut JSGlobalObject, p: Option<NonNull<$T>>) -> JSValue, __getConstructor(g: *mut JSGlobalObject) -> JSValue, __dangerouslySetPtr(v: JSValue, p: Option<NonNull<$T>>) -> bool, and the cached-value pair. Option<NonNull<T>> is ABI-identical to Zig ?*T / C void* via the guaranteed null-pointer niche. The wrapper surface in bun-jsc-ffi mirrors Zig's raw-pointer semantics, NOT Rust ownership: impl $T { #[inline] pub fn from_js(v: JSValue) -> Option<NonNull<Self>>; #[inline] pub fn from_js_direct(v: JSValue) -> Option<NonNull<Self>>; #[inline] pub unsafe fn from_js_mut<'a>(v: JSValue) -> Option<&'a mut Self> /* caller asserts exclusive access for 'a and that the JS wrapper outlives 'a */; #[inline] pub fn to_js(this: NonNull<Self>, g: *mut JSGlobalObject) -> JSValue /* transfers ownership to JSC; freed via generated finalize export, NOT Box drop */; #[inline] pub fn get_constructor(g: *mut JSGlobalObject) -> JSValue; #[inline] pub fn detach_ptr(v: JSValue) /* asserts __dangerouslySetPtr(v, None) */; }. The gc enum becomes #[repr(u8)] pub enum Gc { $Field, ... } with #[inline] get(self, this: JSValue) -> Option<JSValue> (maps .zero -> None), set(self, this: JSValue, g: *mut JSGlobalObject, v: JSValue), clear(...) dispatching via match self to the per-field externs — identical codegen to the Zig switch (field) since field is comptime-known there and self is monomorphized/inlined here.
PERF: All wrappers are #[inline] one-liners over a single extern call; from_js/to_js compile to call + ret with no prologue, matching the Zig pub fn fromJS body which is a single tail call after a comptime-gated log. Verify with cargo asm / llvm-objdump that from_js contains exactly one call ${T}__fromJS and no stack frame; verify Option<NonNull<T>> return is passed in rax (SysV) identically to the Zig ?*T by diffing nm/disasm of a sample class against the current Zig build. The Gc::get/setmatch must lower to a direct call (no jump table) when the variant is a compile-time constant, as it is at every call site today.
SRC: /root/bun-5/src/codegen/generate-classes.ts (lines 2534-2546: Zig extern fn ${T}__fromJS / __fromJSDirect / __getConstructor / __create / ${T}__dangerouslySetPtr declarations, all callconv(jsc.conv), returning ?*${typeName} / jsc.JSValue / bool); /root/bun-5/src/codegen/generate-classes.ts (lines 1820-1860: C++ extern JSC_CALLCONV void* ${T}__fromJS(...) returns object->wrapped() (raw m_ctx, no aliasing contract); ${T}__fromJSDirect; ${T}__dangerouslySetPtr writes object->m_ctx = ptr); /root/bun-5/src/codegen/generate-classes.ts (line 1905: C++ extern JSC_CALLCONV EncodedJSValue ${T}__create(Zig::GlobalObject*, void* ptr) — allocateCell + finishCreation, no JS exception path); /root/bun-5/src/codegen/generate-classes.ts (line 772: C++ extern JSC_CALLCONV EncodedJSValue ${T}__getConstructor(Zig::GlobalObject*) — defined separately from the 1820-1860 block); /root/bun-5/src/codegen/generate-classes.ts (lines 1025-1039 (writeBarrier): C++ ${symbol}SetCachedValue / GetCachedValue definitions using WriteBarrier::set/get); /root/bun-5/src/codegen/generate-classes.ts (lines 2121-2144: Zig extern fn ...SetCachedValue/GetCachedValue callconv(jsc.conv) plus thin ${name}SetCached/${name}GetCached wrappers mapping .zero -> null); /root/bun-5/src/codegen/generate-classes.ts (lines 2083-2114: Zig pub const gc = enum(u8) { ... get/set/clear } dispatching on comptime field via switch to the per-field externs); /root/bun-5/src/codegen/generate-classes.ts (lines 2423-2467: Zig pub fn fromJS(value) ?*${typeName} returns the extern result directly (raw nullable pointer, no &mut-style exclusivity); pub fn toJS(this: *${typeName}, globalObject) JSValue calls __create(globalObject, this) — this is a raw heap pointer whose deallocation is the generated finalize's responsibility, not the caller's); /root/bun-5/src/bun.js/jsc.zig (lines 9-12: pub const conv = if (isWindows and isX64) .{ .x86_64_sysv = .{} } else .c; — Rust emitter must emit extern "sysv64" under cfg(all(windows, target_arch="x86_64")) and extern "C" otherwise)
FACT: WTF::StringImpl's in-memory shape is exactly 4 fields in this order: m_refCount: u32 (atomic; bit 0 = static-string flag, increment step = 0x2), m_length: u32, an 8-byte pointer union { m_data8: *const Latin1Char | m_data16: *const u16 }, and m_hashAndFlags: u32 whose bit 2 (s_hashFlag8BitBuffer = 1<<2) selects which arm of the union is valid. Bun's Zig mirror (WTFStringImplStruct in src/string/wtf.zig) hard-codes the same layout and bit constants and reads them directly without FFI for hot accessors (is8Bit, length, latin1Slice, utf16Slice, refCount, byteLength).
RUST: In crate bun_str: #[repr(C)] pub struct WTFStringImpl { m_ref_count: AtomicU32, m_length: u32, m_ptr: WTFStringPtr, m_hash_and_flags: UnsafeCell<u32> } with #[repr(C)] union WTFStringPtr { latin1: *const u8, utf16: *const u16 }. Constants const S_REFCOUNT_STATIC: u32 = 0x1; const S_REFCOUNT_INCREMENT: u32 = 0x2; const S_HASH_FLAG_8BIT: u32 = 1<<2; const S_HASH_MASK_BUFFER_OWNERSHIP: u32 = 0b11;. Inline methods is_8bit(), len(), latin1(), utf16() read fields directly (no FFI). Unsafe boundary: all accessors take NonNull<WTFStringImpl> (the type is never owned by Rust, only borrowed), and latin1()/utf16() are unsafe fn documented to require the matching is_8bit() precondition. Add const _: () = assert!(size_of::<WTFStringImpl>() == 24) and a startup C++ assert static_assert(offsetof(StringImplShape, m_hashAndFlags) == 16) in BunString.cpp.
PERF: is_8bit(), len(), latin1() must compile to a single field load + mask (no FFI call, no branch on ownership). Measure: cargo asm bun_str::WTFStringImpl::is_8bit must show ≤3 instructions; microbench iterating 10M is_8bit()+len() on a hot StringImpl must match Zig within ±2% (compare bun bd -e 'Bun.nanoseconds()' loop vs equivalent Rust criterion bench).
FACT: Bun never increments/decrements m_refCount from Zig directly — WTFStringImplStruct.ref()/deref() always FFI to Bun__WTFStringImpl__ref/Bun__WTFStringImpl__deref (BunString.cpp:53-60), because StringImpl::~StringImpl (StringImpl.cpp:122-159) dispatches on bufferOwnership() (bits 0-1 of m_hashAndFlags) across 4 cases — BufferInternal, BufferOwned (StringImplMalloc::free), BufferExternal (calls ExternalStringImpl::freeExternalBuffer + destroys std::function), BufferSubstring (derefs base) — and also unregisters from AtomStringImpl/SymbolRegistry. This destruction path is C++-only state that Rust cannot replicate.
RUST: In crate bun_str: extern "C" { fn Bun__WTFStringImpl__ref(p: NonNull<WTFStringImpl>); fn Bun__WTFStringImpl__deref(p: NonNull<WTFStringImpl>); }. Wrap in #[repr(transparent)] pub struct WTFString(NonNull<WTFStringImpl>) with impl Clone { fn clone(&self){ unsafe{ Bun__WTFStringImpl__ref(self.0) }; Self(self.0) } } and impl Drop { fn drop(&mut self){ unsafe{ Bun__WTFStringImpl__deref(self.0) } } }. NEVER write m_ref_count from Rust. Unsafe boundary: provide unsafe fn from_raw(NonNull<WTFStringImpl>) -> WTFString (assumes caller donated +1 ref) and fn into_raw(self) -> NonNull<WTFStringImpl> (mem::forget, leaks +1). Do NOT attempt a Rust-side fast-path on the atomic — the Zig code already chose FFI here and a Rust-side decrement would skip AtomString/BufferOwnership cleanup.
PERF: ref/deref cost = 1 non-inlined extern-C call each, identical to Zig today. Measure: count Bun__WTFStringImpl__deref calls under perf record on test/js/bun/util/inspect.test.ts — Rust build must show same call count ±0 vs Zig build (any extra deref = leak or double-free risk, not just perf).
FACT: ZigString is a 16-byte extern struct { _unsafe_ptr_do_not_use: [*]const u8, len: usize } where the pointer's high bits are tag flags: bit 63 = UTF-16, bit 62 = globally-allocated (mimalloc-freeable), bit 61 = UTF-8 (needs transcoding). untagged() truncates to the low 53 bits (@truncate(u53)), and the C++ mirror Zig::untag() clears bits 60-63. Both Zig and C++ read these bits identically (helpers.h:34-64).
RUST: In crate bun_str: #[repr(C)] pub struct ZigString { ptr: TaggedPtr, len: usize } where #[repr(transparent)] struct TaggedPtr(usize). const TAG_UTF16: usize = 1<<63; const TAG_GLOBAL: usize = 1<<62; const TAG_UTF8: usize = 1<<61; const PTR_MASK: usize = (1<<53)-1; (matches Zig's @truncate(u53) — NOT just !(63|62|61|60); using the C++ 4-bit mask would be a behavior divergence on garbage high bits, so prefer the Zig 53-bit mask which is stricter). Methods: fn is_16bit(&self)->bool, fn is_utf8(&self)->bool, fn is_global(&self)->bool, fn untagged(&self)->*const u8 { (self.ptr.0 & PTR_MASK) as *const u8 }. No unsafe needed for tag reads; unsafe fn slice_latin1()/slice_utf16() require caller to have checked the tag. Mark ZigString as #[deprecated] in Rust with doc pointing to BunString (mirrors Zig comment 'Prefer using bun.String').
PERF: Tag check + untag must be branchless bit-ops (AND + CMP). cargo asm of ZigString::is_16bit must be 2 instructions. Passing ZigString by value across FFI must use 2 registers (System V: rdi+rsi) — verify with extern "C" fn test that round-trips through existing C++ Zig::toString(ZigString) and compares pointer identity.
SRC: /root/bun-5/src/bun.js/bindings/ZigString.zig (lines 2-7: extern struct { _unsafe_ptr_do_not_use: [*]const u8, len: usize }; lines 430-431: is16Bit = bit 63; lines 580-589: isUTF8 bit 61, markUTF16 bit 63; lines 597-606: isGloballyAllocated/markGlobal bit 62; lines 629-632: untagged = @truncate(u53)); /root/bun-5/src/bun.js/bindings/helpers.h (lines 34-64: Zig::untag clears bits 60-63; isTaggedUTF16Ptr bit 63; isTaggedUTF8Ptr bit 61; isTaggedExternalPtr bit 62); /root/bun-5/src/bun.js/bindings/headers-handwritten.h (lines 19-22: typedef struct ZigString { const unsigned char* ptr; size_t len; } ZigString;)
[bun_str] STR-04
FACT: BunString is NOT a Rust-style tagged enum: it is a C struct { BunStringTag tag; BunStringImpl impl; } where BunStringTag is a u8 (Dead=0, WTFStringImpl=1, ZigString=2, StaticZigString=3, Empty=4) and impl is a union { ZigString zig; WTF::StringImpl* wtf; }. C++ mutates tag and impl as independent lvalues — BunString__toWTFString (BunString.cpp:537-556) writes bunString->impl.wtf = impl.leakRef(); bunString->tag = BunStringTag::WTFStringImpl; on a live struct, and constructors like BunString__fromLatin1Unitialized return { BunStringTag::WTFStringImpl, { .wtf = impl.leakRef() } } by aggregate-init. A #[repr(u8)] enum in Rust would forbid this (niche layout, no field-address stability).
RUST: In crate bun_str: #[repr(C)] pub struct BunString { pub tag: BunStringTag, pub value: BunStringImpl } with #[repr(u8)] pub enum BunStringTag { Dead=0, WTFStringImpl=1, ZigString=2, StaticZigString=3, Empty=4 } and #[repr(C)] pub union BunStringImpl { pub zig: ZigString, pub wtf: *mut WTFStringImpl }. CRITICAL: do NOT use #[repr(C, u8)] enum BunString { ... } — even though it has defined layout, C++ writes impl.wtf then tag separately on aliased memory, which is UB on a Rust enum (invalid discriminant window). Provide a safe view: impl BunString { pub fn as_ref(&self) -> BunStringRef<'_> } returning a borrow-checked enum BunStringRef<'a> { Dead, Empty, Wtf(&'a WTFStringImpl), Zig(ZigString), Static(ZigString) } — match on this for ergonomics, but the FFI type stays the raw struct+union. impl Drop for BunString calls deref() only when tag == WTFStringImpl (mirrors headers-handwritten.h:487-491).
PERF: size_of::<BunString>() == 24 (8-byte tag slot due to union alignment + 16-byte union) on x86*64 — must equal sizeof(BunString) from C++. Verify with static_assert(sizeof(BunString)==24) already implied by ResolvedSource layout; add Rust const *: () = assert!(size_of::<BunString>()==24 && align_of::<BunString>()==8). Round-trip test: call BunString**fromLatin1from Rust, asserttag==WTFStringImpl, then BunString**toJS — must not crash.
SRC: /root/bun-5/src/bun.js/bindings/headers-handwritten.h (lines 37-48: union BunStringImpl { ZigString zig; WTF::StringImpl* wtf; } and enum class BunStringTag : uint8_t { Dead=0, WTFStringImpl=1, ZigString=2, StaticZigString=3, Empty=4 }; lines 58-60: struct BunString { BunStringTag tag; BunStringImpl impl; }); /root/bun-5/src/bun.js/bindings/BunString.cpp (lines 537-556: BunString__toWTFString writes bunString->impl.wtf = impl.leakRef(); then bunString->tag = BunStringTag::WTFStringImpl; — independent field mutation on a BunString* argument); /root/bun-5/src/bun.js/bindings/BunString.cpp (lines 383-402: BunString__fromUTF16Unitialized/BunString__fromLatin1Unitialized return { BunStringTag::WTFStringImpl, { .wtf = impl.leakRef() } } by C aggregate); /root/bun-5/src/string.zig (lines 13-46: Tag = enum(u8){Dead=0..Empty=4}; String = extern struct { tag: Tag, value: StringImpl })
[bun_str] STR-06
FACT: UTF-8 conversion is zero-copy iff is8Bit() && isAllASCII(latin1Slice()). WTFStringImplStruct.toUTF8 (wtf.zig:125-138) calls bun.strings.toUTF8FromLatin1 which returns null when isAllASCII(latin1) is true (unicode.zig:371-373), and on null the caller returns a borrowing ZigString.Slice over the original buffer (via toLatin1Slice() which ref()s the StringImpl, or fromUTF8NeverFree in toUTF8WithoutRef). Only non-ASCII Latin1 or UTF-16 trigger allocation.
RUST: In crate bun_str: pub enum Utf8Cow<'a> { Borrowed(&'a [u8]), Owned(Vec<u8>) } (NOT std::borrow::Cow<str> — output may contain WTF-8 from unpaired surrogates, so &str invariant doesn't hold). impl WTFString { pub fn to_utf8(&self) -> Utf8Cow<'_> }: if is_8bit() → call is_all_ascii(latin1) → if true return Borrowed(latin1), else allocate+transcode Latin1→UTF-8; if 16-bit → allocate+transcode UTF-16→WTF-8. The '_ lifetime borrows self: &WTFString, which holds the +1 ref, so no extra ref/deref in the ASCII fast path (matches toUTF8WithoutRef semantics, not toUTF8). Unsafe boundary: transcoding writes into Vec<u8> via set_len after FFI fills it.
PERF: ASCII fast path = 1 SIMD scan + 0 allocations + 0 ref/deref. Measure: heaptrack/--track-allocations on bun bd -e 'for(let i=0;i<1e6;i++) Bun.inspect("hello")' must show 0 allocations from to_utf8 for ASCII inputs, matching current Zig.
SRC: /root/bun-5/src/string/wtf.zig (lines 125-155: toUTF8/toUTF8WithoutRef — if toUTF8FromLatin1 returns null, return borrowed slice (toLatin1Slice() or fromUTF8NeverFree)); /root/bun-5/src/string/immutable/unicode.zig (lines 371-377: toUTF8FromLatin1 returns null when isAllASCII(latin1) — caller treats null as zero-copy); /root/bun-5/src/string/wtf.zig (lines 184-186: canUseAsUTF8 = is8Bit() && bun.strings.isAllASCII(latin1Slice()))
[bun_str] STR-08
FACT: BunString::ref()/deref() are no-ops unless tag == WTFStringImpl (headers-handwritten.h:481-491). ZigString/StaticZigString/Empty/Dead variants carry no ownership — their bytes are either static, stack-borrowed, or globally-allocated-and-freed-elsewhere (via the bit-62 tag, freed by ZigString__freeGlobal). Therefore Drop for BunString must branch on tag, and only the WTF arm crosses FFI.
RUST: In crate bun_str: impl Drop for BunString { fn drop(&mut self){ if self.tag == BunStringTag::WTFStringImpl { unsafe { Bun__WTFStringImpl__deref(NonNull::new_unchecked(self.value.wtf)) } } } } and impl Clone mirrors with __ref. Provide pub const EMPTY: BunString = BunString{ tag: Empty, value: BunStringImpl{ zig: ZigString::EMPTY } } and pub const DEAD: BunString as const (no Drop work). For passing to C++ out-params (e.g. BunString__toWTFString(bunString: *mut BunString)), provide pub fn as_mut_ptr(&mut self) -> *mut BunString and document that C++ may rewrite tag+value in place — caller must not hold a BunStringRef<'_> across such a call.
PERF: Dropping a Dead/Empty BunString must be a single compare+branch, no FFI. Verify with cargo asm '<bun_str::BunString as Drop>::drop' — must show cmp byte ptr [rdi], 1; jne .ret. Hot paths that construct transient BunString::dead() (e.g. error returns from BunString__fromLatin1Unitialized) must not regress vs Zig's String.dead constant.
SRC: /root/bun-5/src/bun.js/bindings/headers-handwritten.h (lines 481-491: ALWAYS_INLINE void BunString::ref()/deref() only act when tag == BunStringTag::WTFStringImpl); /root/bun-5/src/string.zig (lines 48-50: pub const empty/pub const dead are compile-time constants with no ref-count); /root/bun-5/src/bun.js/bindings/BunString.cpp (lines 389-433: failure paths return { .tag = BunStringTag::Dead } with uninitialized impl — Drop must tolerate this)
[bun_str] STR-09
FACT: Zig implements a fake std.mem.Allocator (StringImplAllocator, wtf.zig:230-265) whose alloc just ref()s the StringImpl and returns its existing m_ptr.latin1, and whose freederef()s — letting a ZigString.Slice{ptr,len,allocator} carry a WTFStringImpl ref-count without a separate handle. This is how toLatin1Slice() (wtf.zig:115-118) achieves zero-copy borrow with correct lifetime.
RUST: In crate bun_str: do NOT replicate the fake-allocator hack — Rust has real lifetimes. Model ZigString.Slice as pub struct OwnedSlice { ptr: NonNull<u8>, len: usize, owner: SliceOwner } with enum SliceOwner { None, Wtf(WTFString), Heap(unsafe fn(*mut u8, usize)) }. Drop matches on owner. For the common case, prefer returning WTFSlice<'a> borrowed from &'a WTFString (STR-05) and only materialize OwnedSlice at FFI boundaries that need a self-contained {ptr,len} (e.g. passing to async C code). This is a deliberate divergence: Rust gets compile-time lifetime checking instead of runtime vtable dispatch.
PERF: Borrowed path (WTFSlice<'a>) = 0 ref/deref vs Zig's 1 ref + 1 deref in toLatin1Slice() — Rust should be strictly faster. Measure: count Bun__WTFStringImpl__ref calls in perf stat on test/js/bun/http/serve.test.ts header-parsing path; Rust must show ≤ Zig count.
FACT: A borrowed view of a WTFStringImpl's character data is a 2-variant union selected by is8Bit() (tests m_hashAndFlags & s_hashFlag8BitBuffer, where s_hashFlag8BitBuffer = 1<<2): Latin1 []const u8 or UTF-16 []const u16, both sliced from m_ptr.{latin1,utf16}[0..m_length]. Zig models the borrowed-view union as ZigString.ByteString = union(enum){ latin1: []const u8, utf16: []const u16 }, produced by ZigString.as(); WTFStringImplStruct.toZigString() performs the same is8Bit() branch but returns a ZigString (tagged-pointer extern struct), not a ByteString. The Latin1 arm is NOT guaranteed UTF-8 — bytes 0x80-0xFF may appear (single-byte non-ASCII); utf8Slice() is a separate accessor that asserts canUseAsUTF8() before reinterpreting.
RUST: In crate bun_str: #[derive(Clone, Copy)] pub enum WTFSlice<'a> { Latin1(&'a [u8]), Utf16(&'a [u16]) } with impl<'a> WTFSlice<'a> { #[inline] pub fn len(&self)->usize; #[inline] pub fn is_empty(&self)->bool }. The Latin1 arm is &[u8], NOT &str (no UTF-8 invariant). The accessor lives on the ref-count-holding smart pointer so the borrow checker ties 'a to a live reference: impl WTFString { #[inline] pub fn as_slice(&self) -> WTFSlice<'_> { let r = unsafe { self.0.as_ref() }; let len = r.m_length as usize; if r.m_hash_and_flags & S_HASH_FLAG_8BIT_BUFFER != 0 { WTFSlice::Latin1(unsafe { core::slice::from_raw_parts(r.m_ptr.latin1, len) }) } else { WTFSlice::Utf16(unsafe { core::slice::from_raw_parts(r.m_ptr.utf16, len) }) } } } where WTFString is the #[repr(transparent)] struct WTFString(NonNull<WTFStringImpl>) with Drop calling deref. Analogously impl BunString { pub fn as_slice(&self) -> Option<WTFSlice<'_>> } (None for Dead/Empty). For raw-impl callers that already hold a ref by other means, provide pub unsafe fn slice_of<'a>(p: NonNull<WTFStringImpl>) -> WTFSlice<'a> — unsafe because the caller must assert 'a does not outlive the ref-count. Constant pub const S_HASH_FLAG_8BIT_BUFFER: u32 = 1 << 2; mirrors WTF::StringImpl.
PERF: as_slice() must be a single m_hash_and_flags load + bit-test + m_length load + m_ptr load, fully inlined, zero allocation, zero FFI calls — identical to Zig's inline latin1Slice()/utf16Slice(). Measure: cargo asm bun_str::WTFString::as_slice shows no call instructions and ≤~6 instructions on the hot path; microbench as_slice() in a 1M-iter loop within ±2% of an equivalent Zig latin1Slice()/utf16Slice() loop.
SRC: /root/bun-5/src/string/wtf.zig (lines 64-71: utf16Slice() asserts !is8Bit() and returns m_ptr.utf16[0..length()]; latin1Slice() asserts is8Bit() and returns m_ptr.latin1[0..length()]); /root/bun-5/src/string/wtf.zig (lines 21, 56-58: s_hashFlag8BitBuffer: u32 = 1 << 2; is8Bit() returns (m_hashAndFlags & s_hashFlag8BitBuffer) != 0); /root/bun-5/src/string/wtf.zig (lines 82-88: toZigString() branches on is8Bit() and returns ZigString.init(latin1Slice()) or ZigString.initUTF16(utf16Slice()) — return type is ZigString, not ByteString); /root/bun-5/src/string/wtf.zig (lines 74-79: utf8Slice() is a distinct accessor that asserts canUseAsUTF8() — confirms Latin1 arm is not UTF-8-safe by default); /root/bun-5/src/bun.js/bindings/ZigString.zig (lines 9-12: pub const ByteString = union(enum){ latin1: []const u8, utf16: []const u16 }); /root/bun-5/src/bun.js/bindings/ZigString.zig (lines 22-24: pub inline fn as(this: ZigString) ByteString — the function that actually produces the 2-variant union (branches on is16Bit()))
[bun_str / bun_simd (FFI crate)] string-abi
FACT: isAllASCII (the gate for zero-copy UTF-8) delegates at runtime to the C-ABI shim simdutf__validate_ascii (double underscore — defined in src/bun.js/bindings/bun-simdutf.cpp wrapping the C++-mangled simdutf::validate_ascii). Adjacent hot string scans (indexOfChar, indexOfNewlineOrNonASCII, containsNewlineOrNonASCIIOrQuote, copyU16ToU8) FFI to extern "c" highway_* functions implemented in src/bun.js/bindings/highway_strings.cpp using Google Highway SIMD. Bun does not implement these scans in Zig — it always calls the C ABI; the Zig wrappers only add a len==0 early-return and map the result==len sentinel to null. copyU16ToU8's input is []align(1) const u16 (1-byte-aligned UTF-16, because JSC string buffers may be unaligned).
RUST: In crate bun_simd (re-exported from bun_str::simd): declare extern "C" { fn simdutf__validate_ascii(buf: *const u8, len: usize) -> bool; fn highway_index_of_char(haystack: *const u8, len: usize, needle: u8) -> usize; fn highway_index_of_newline_or_non_ascii(text: *const u8, len: usize) -> usize; fn highway_index_of_space_or_newline_or_non_ascii(text: *const u8, len: usize) -> usize; fn highway_contains_newline_or_non_ascii_or_quote(text: *const u8, len: usize) -> bool; fn highway_index_of_needs_escape_for_javascript_string(text: *const u8, len: usize, quote: u8) -> usize; fn highway_index_of_any_char(text: *const u8, text_len: usize, chars: *const u8, chars_len: usize) -> usize; fn highway_char_frequency(text: *const u8, len: usize, freqs: *mut i32, delta: i32); fn highway_fill_with_skip_mask(mask: *const u8, mask_len: usize, output: *mut u8, input: *const u8, len: usize, skip_mask: bool); fn highway_copy_u16_to_u8(input: *const u16, count: usize, output: *mut u8); } — symbol names copied byte-for-byte from src/highway.zig:1-65 + 231-235 and bun-simdutf.zig:44 (note the DOUBLE underscore in simdutf__*). Wrap each in a safe #[inline] pub fn that (a) early-returns on len == 0 and (b) maps result == len → None exactly as src/highway.zig:82-170 does. For copy_u16_to_u8, mirror Zig's unaligned contract: expose pub unsafe fn copy_u16_to_u8(input: *const u16 /* may be 1-byte aligned */, count: usize, output: *mut u8) (or a safe wrapper taking &[u8] of even length and casting), NOT &[u16], because &[u16] would impose 2-byte alignment Zig does not require. Do NOT reimplement in core::arch/std::simd — link the existing object files (highway_strings.cpp.o, bun-simdutf.cpp.o). Unsafe boundary: only the extern "C" block and the unaligned-pointer wrapper; all index-returning safe wrappers take &[u8] and return Option<usize>.
PERF: Same object code on the hot path (FFI to identical C symbols) ⇒ perf identical by construction. Verify: nm <bun-rust-binary> | grep -E 'simdutf__validate_ascii|highway_index_of_char' shows the symbols resolved (not Undefined-unresolved); objdump -d of bun_simd::index_of_char shows a direct call highway_index_of_char with no extra branches beyond the len==0 check. Microbench is_all_ascii and index_of_char on 4 KiB / 64 KiB ASCII buffers vs. the Zig build — must be within ±1% (noise floor).
SRC: /root/bun-5/src/string/immutable/unicode.zig (lines 1460-1471: pub fn isAllASCII(slice: []const u8) bool { if (@inComptime()) {...} return bun.simdutf.validate.ascii(slice); } — runtime path always calls simdutf); /root/bun-5/src/bun.js/bindings/bun-simdutf.cpp (line 26: bool simdutf__validate_ascii(const char* buf, size_t len) { return simdutf::validate_ascii(buf, len); } — the ONLY C-ABI symbol; note DOUBLE underscore); /root/bun-5/src/bun.js/bindings/bun-simdutf.zig (line 44: pub extern fn simdutf__validate_ascii(buf: [*]const u8, len: usize) bool; — Zig consumes the double-underscore symbol); /root/bun-5/src/highway.zig (lines 1-65: extern "c" fn highway_char_frequency / highway_index_of_char / highway_index_of_interesting_character_in_string_literal / highway_index_of_newline_or_non_ascii / highway_index_of_newline_or_non_ascii_or_ansi / highway_index_of_newline_or_non_ascii_or_hash_or_at / highway_index_of_space_or_newline_or_non_ascii / highway_contains_newline_or_non_ascii_or_quote / highway_index_of_needs_escape_for_javascript_string / highway_index_of_any_char / highway_fill_with_skip_mask); /root/bun-5/src/highway.zig (lines 231-239: extern "c" fn highway_copy_u16_to_u8(input: [*]align(1) const u16, count: usize, output: [*]u8) void; and pub fn copyU16ToU8(input: []align(1) const u16, output: []u8) — input is 1-byte-aligned u16); /root/bun-5/src/highway.zig (lines 82-100 (indexOfChar): if (haystack.len == 0) return null; const result = highway_index_of_char(...); if (result == haystack.len) return null; return result; — the len==0 / result==len→None pattern the Rust wrappers must replicate); /root/bun-5/src/string/immutable.zig (lines 1193-1322: indexOfSpaceOrNewlineOrNonASCII/indexOfNewlineOrNonASCIICheckStart/containsNewlineOrNonASCIIOrQuote/indexOfNeedsEscapeForJavaScriptString/indexOfCharUsize/indexOfCharPos all delegate to bun.highway.*; line 2300: pub const isAllASCII = unicode.isAllASCII;); /root/bun-5/src/bun.js/bindings/JSBuffer.cpp (lines 88-90: extern "C" void* highway_memmem(...); extern "C" size_t highway_index_of_char(...); — same C ABI consumed from C++, confirming the symbols are stable C linkage)
FACT: Every bun.sys function returns Maybe(T), a 2-variant tagged union union(enum){ err: Error, result: T } defined at src/bun.js/node.zig:61-72 and aliased at src/sys.zig:337-338. It is returned by value, never heap-allocated, and carries helper constructors (errnoSys, errnoSysFd, errnoSysP, errnoSysFP) that build the error variant inline from the raw syscall return code without allocating.
RUST: In crate bun_sys: pub type Maybe<T> = core::result::Result<T, SysError>; (do NOT invent a new union — Rust's Result already gives the tagged-union layout and ? ergonomics). Port the helper constructors as #[inline(always)] free fns: fn errno_sys<T>(rc: isize, tag: Tag) -> Option<SysError>, fn errno_sys_fd, fn errno_sys_p, fn errno_sys_fp. These live in bun_sys::maybe and are re-exported at crate root. Unsafe boundary: none — these are pure value constructors.
PERF: size_of::<Maybe<Fd>>() and size_of::<Maybe<usize>>() must be ≤ the Zig equivalents (Zig's tagged union is 1 + max(sizeof(Error), sizeof(T)) + padding). Success path must compile to a single move of the result into the return slot with no branch on construction. Measure: cargo asm bun_sys::read — the Ok(n) arm must be a mov+ret; Criterion microbench of 1M read(fd, &mut [0;1]) on /dev/zero must match Zig within ±2% wall-clock.
SRC: /root/bun-5/src/bun.js/node.zig (lines 61-72: pub fn Maybe(...) { return union(Tag) { err: ErrorType, result: ReturnType, pub const Tag = enum { err, result }; ... } }); /root/bun-5/src/sys.zig (lines 337-338: pub fn Maybe(comptime ReturnTypeT: type) type { return bun.api.node.Maybe(ReturnTypeT, Error); }); /root/bun-5/src/bun.js/node.zig (lines 224-310: errnoSys / errnoSysFd / errnoSysP / errnoSysFP helpers that switch on sys.getErrno(rc) and build .err inline)
[bun_sys::error] SYS-02-ERROR-STRUCT
FACT: bun.sys.Error (src/sys/Error.zig:11-21) is a plain struct: errno: u16, fd: bun.FD, from_libuv: bool (Windows only, void on posix), path: []const u8 = "", syscall: Tag (u8), dest: []const u8 = "". The path/dest slices are BORROWED (not owned) by default — only .clone() (lines 23-28) heap-dupes them. This means constructing an Error on the hot path is zero-alloc; the caller controls when/if the path is copied.
RUST: In bun_sys::error: #[repr(C)] pub struct SysError { pub errno: u16, pub syscall: Tag, pub fd: Fd, #[cfg(windows)] pub from_libuv: bool, pub path: RawSlice, pub dest: RawSlice } where #[repr(C)] pub struct RawSlice { ptr: *const u8, len: usize } (a borrow-erased slice; lifetime is documented as 'valid until the caller's path buffer is dropped'). Provide .clone_owning(&self, alloc) -> OwnedSysError for the rare case that needs to outlive the buffer. Unsafe boundary: RawSlice::as_str() is unsafe fn — caller asserts the pointed-to buffer is still live. This mirrors Zig's borrow-without-lifetime exactly; do NOT use String/Box<str> here or every failed syscall allocates.
PERF: size_of::<SysError>() ≤ 48 bytes on posix-64 (matching Zig: 2+1+pad+4+16+16 ≈ 40+pad). Constructing a SysError must perform zero heap allocations. Measure: run heaptrack ./bun-rust -e 'for(i=0;i<1e5;i++)try{require("fs").openSync("/nope")}catch{}' and assert allocation count from bun_sys::* frames == 0.
SRC: /root/bun-5/src/sys/Error.zig (lines 11-21: field layout errno: Int(u16), fd, from_libuv, path: []const u8 = "", syscall: sys.Tag, dest: []const u8 = ""); /root/bun-5/src/sys/Error.zig (lines 23-28: clone() is the only place that allocator.dupes path/dest); /root/bun-5/src/sys/Error.zig (lines 82-91: withPath stores bun.span(path) — a borrow, not a copy)
[bun_sys::fd] SYS-03-FD-PACKED
FACT: bun.FD is packed struct(c_int) on POSIX (kind is enum(u0) — zero bits, so FD == raw fd_t) and packed struct(u64) on Windows where bit 63 is kind: enum(u1){system=0, uv=1} and bits 0-62 are a packed union {as_system: u63 (HANDLE-as-int), as_uv: uv_file}. Zig packed structs are LSB-first, so value occupies the low bits and kind the high bit. FD.invalid is minInt(as_system).
RUST: In bun_sys::fd: #[cfg(unix)] #[repr(transparent)] #[derive(Copy,Clone,Eq,PartialEq)] pub struct Fd(libc::c_int); — bit-identical to the Zig POSIX layout. #[cfg(windows)] #[repr(transparent)] #[derive(Copy,Clone,Eq,PartialEq)] pub struct Fd(u64); with const KIND_BIT: u64 = 1<<63; fn kind(self)->Kind { if self.0 & KIND_BIT !=0 {Kind::Uv} else {Kind::System} }, fn as_system(self)->HANDLE { (self.0 & !KIND_BIT) as usize as HANDLE }, fn as_uv(self)->c_int { self.0 as i32 }. pub const INVALID: Fd. No Drop impl — close is explicit (fn close(self)), matching Zig. Unsafe boundary: from_native(HANDLE)/from_uv(c_int) are safe constructors; native() returning the raw HANDLE/fd_t is safe (just an int). FFI: because it's repr(transparent), Fd can be passed directly across the Zig↔Rust boundary as c_int/u64.
PERF: size_of::<Fd>() == size_of::<c_int>() on unix, == 8 on windows. Must be Copy and passed in a single register (verify with cargo asm that fn taking Fd uses edi/rcx, not a pointer). The kind check on Windows must compile to a single test reg, imm — no branch table.
FACT: FD.Optional is enum(backing_int) { none = @bitCast(invalid), _ } — a niche-packed optional so ?FD costs zero extra bytes. none reuses the invalid bit-pattern (minInt of the system-handle field).
RUST: In bun_sys::fd: on unix, define #[repr(transparent)] pub struct Fd(NonMinusOneI32) where NonMinusOneI32 is a niche newtype (#[rustc_layout_scalar_valid_range_end] is unstable, so practically: define #[repr(transparent)] pub struct OptionalFd(c_int) with const NONE: Self = Self(c_int::MIN) and fn unwrap(self)->Option<Fd>). On windows, #[repr(transparent)] pub struct OptionalFd(u64) with NONE = bit-pattern of INVALID. Expose From<Fd>/From<Option<Fd>>. Do NOT use Option<Fd> directly across FFI — the explicit OptionalFd keeps ABI identical to Zig's FD.Optional.
PERF: size_of::<OptionalFd>() == size_of::<Fd>(). Static-assert this with const _: () = assert!(...). No runtime cost beyond one integer compare for unwrap().
FACT: The syscall backend is selected at compile time per OS: Linux → std.os.linux (raw syscall instruction, errno encoded in the return value); macOS & FreeBSD → libc (std.c); Windows → mix of ntdll.NtCreateFile/kernel32.ReadFile and libuv (sys_uv.zig). There is no runtime vtable — Environment.os is a comptime switch.
RUST: Crate bun_sys with #[cfg(target_os=...)] backend modules: bun_sys::linux uses raw syscalls via core::arch::asm! or the sc/linux-raw-sys crate (NOT libc, NOT std::fs) so errno comes from the return register; bun_sys::darwin and bun_sys::freebsd link libc via the libc crate; bun_sys::windows links windows-sys (features Win32_Storage_FileSystem, Wdk_Storage_FileSystem for NtCreateFile) plus extern "C" bindings to libuv in a sibling bun_uv-sys crate. Each backend re-exports the same pub fn read/write/openat/... signatures so the crate root does pub use backend::*. Unsafe boundary: each backend fn body is unsafe { raw_call(...) } wrapped in a safe signature returning Maybe<T>.
PERF: On Linux, bun_sys::read must compile to a direct syscall instruction with no PLT indirection (no call read@PLT). Verify with cargo asm --target x86_64-unknown-linux-gnu bun_sys::linux::read — must contain syscall and not callq. Benchmark: hyperfine 1M-iteration read loop vs Zig build, ≤2% delta.
FACT: Linux/FreeBSD wrappers loop while(true) and continue on E.INTR (e.g. openatOSPath lines 1696-1712, read lines 2117-2127, fchmod/stat/etc throughout). macOS instead links the private $NOCANCEL libc symbols (openat$NOCANCEL, read$NOCANCEL, write$NOCANCEL, poll$NOCANCEL, recvfrom$NOCANCEL, etc.) so the syscall is not a pthread cancellation point and EINTR retry is unnecessary on the read path.
RUST: In bun_sys: a macro_rules! retry_eintr { ($body:expr) => { loop { match $body { Err(e) if e.errno == E::INTR as u16 => continue, r => break r } } } } used by linux/freebsd backends. Darwin backend declares extern "C" { #[link_name="read$NOCANCEL"] fn read_nocancel(fd:c_int,buf:*mut u8,n:usize)->isize; ... } (and similarly for openat/write/pread/pwrite/readv/writev/preadv/pwritev/poll/ppoll/recvfrom/sendto) in bun_sys::darwin::nocancel. Unsafe boundary: the extern decls; wrapper fns are safe.
PERF: Retry loop must fully inline (no extra call frame). On macOS, nm on the built dylib must show _read$NOCANCEL (not plain _read). Measure: dtruss -c on a 100k-read loop — syscall count must equal loop iterations (no doubled calls from spurious retry logic).
SRC: /root/bun-5/src/sys.zig (lines 1696-1712: linux openatOSPathwhile(true){ ... .INTR => continue }); /root/bun-5/src/sys.zig (lines 2117-2127: linux read retry loop on .INTR); /root/bun-5/src/sys.zig (lines 1676, 1821, 2107, 2182, 2221, 2265, 4579: darwin_nocancel.@"...$NOCANCEL" symbol usage)
[bun_sys::tag] SYS-08-TAG-ENUM
FACT: sys.Tag is enum(u8) listing ~100 syscall identifiers (open, read, NtCreateFile, uv_spawn, …) stored in every Error so the JS-side SystemError can report syscall: 'open'. Tag.isWindows() partitions the enum by ordinal (everything after WriteFile is Windows-only).
RUST: In bun_sys::tag: #[repr(u8)] #[derive(Copy,Clone,Eq,PartialEq)] pub enum Tag { Todo=0, Dup, Access, ..., WriteFile, NtQueryDirectoryFile, ... } with a const fn name(self) -> &'static str generated by a macro_rules! table (or strum). fn is_windows(self) -> bool { self as u8 > Tag::WriteFile as u8 }. Keep ordinal values identical to Zig so an FFI'd SysError round-trips. Unsafe: none.
PERF: size_of::<Tag>() == 1. name() must be a static-table lookup (no formatting). Static-assert Tag::SetEndOfFile as u8 == <zig ordinal> via a generated test to keep ABI sync.
FACT: Path-taking syscalls accept [:0]const u8 (null-terminated slice) so .ptr is passed straight to libc/kernel with no copy: openat(dirfd, file_path: [:0]const u8, ...), open(file_path: [:0]const u8, ...), File.open(path: [:0]const u8, ...). The non-sentinel variant openatA(file_path: []const u8) exists but copies into a stack PathBuffer via std.posix.toPosixPath first — it is the slow fallback.
RUST: In bun_sys: primary signatures take &CStr (or a #[repr(transparent)] pub struct OsPathZ<'a>(*const c_char, PhantomData<&'a [u8]>) newtype) so .as_ptr() goes directly to the syscall. Provide *_a(&[u8]) variants that stack-copy into [u8; PATH_MAX+1] and null-terminate. On Windows the primary type is &[u16] (WTF-16) with sentinel handled inside normalize_path_windows. Do NOT accept &Path/PathBuf from std — they force OsStr→bytes conversion and on Windows force a separate UTF-16 alloc. Unsafe boundary: none for &CStr; the raw-ptr newtype constructor is unsafe fn from_ptr.
PERF: Hot openat path must perform zero memcpy of the path bytes when caller already has a NUL-terminated buffer. Verify with perf record -e cache-misses + flamegraph: no memcpy frame under bun_sys::openat. Compare strace -e openat argument pointer == caller's buffer address in a debug assertion test.
SRC: /root/bun-5/src/sys.zig (line 1741: pub fn openat(dirfd: bun.FD, file_path: [:0]const u8, flags: i32, perm: bun.Mode) Maybe(bun.FD)); /root/bun-5/src/sys.zig (lines 1763-1781: openatA copies non-sentinel slice via std.posix.toPosixPath into stack buffer before calling openatOSPath); /root/bun-5/src/sys/File.zig (lines 13-21: File.openat/File.open take [:0]const u8)
FACT: On Windows, normalizePathWindows (src/sys.zig:980-1069) converts a UTF-8 or UTF-16 path + optional dir FD into a \??\-prefixed NT object path written into a CALLER-PROVIDED *bun.WPathBuffer ([32768]u16). Scratch buffers come from a thread-local w_path_buffer_pool that keeps up to 4 buffers per thread (src/paths/path_buffer_pool.zig:1-27) — so steady-state path translation is zero-alloc. Relative paths are resolved by calling GetFinalPathNameByHandle on the dir fd. Special-cases \\.\pipe, \??\, NUL.
RUST: In bun_sys::windows: pub fn normalize_path<'o>(dir: Fd, path: PathInput<'_>, out: &'o mut WPathBuffer) -> Maybe<&'o [u16]> where WPathBuffer = [u16; 32768] and PathInput is an enum over &[u8]/&[u16]. Thread-local pool: thread_local! { static W_POOL: RefCell<ArrayVec<Box<WPathBuffer>,4>> = ... } with get()/put() mirroring the Zig ObjectPool (allocate via mimalloc thread-local heap so it's freed on thread exit). UTF-8→UTF-16 conversion uses a scratch buffer from the pool, never String/Vec. Calls windows_sys::Win32::Storage::FileSystem::GetFinalPathNameByHandleW and Wdk::...::NtCreateFile. Unsafe boundary: the windows-sys calls and the pool's Box::new_uninit for the 64KB buffer.
PERF: After 4 warm-up calls per thread, normalize_path must perform zero heap allocations. Measure: instrument mimalloc (mi_stats_print) around 100k openat calls on Windows — malloc count delta must be 0. End-to-end fs.openSync microbench on Windows must match Zig ±3%.
FACT: On Windows, I/O dispatches at runtime on fd.kind: read() does if (fd.kind == .uv) sys_uv.read(fd,buf) else kernel32.ReadFile(fd.native(),...) (src/sys.zig:2129-2151). close() does .uv => uv_fs_close, .windows => NtClose (src/fd.zig:278-299). This is why the FD tag bit exists — a single FD value carries which backend owns it.
RUST: In bun_sys::windows: every I/O fn matches on fd.decode(): match fd.decode() { Decoded::Uv(n) => unsafe { uv::read(n, buf) }, Decoded::System(h) => unsafe { ReadFile(h, ...) } }. Decoded is a 2-variant enum produced by a single bit-test on the u64. libuv calls go through extern "C" bindings in a bun_uv_sys crate (NOT the libuv crates.io crate — must match Bun's vendored libuv ABI). Unsafe boundary: each arm body; the match itself is safe.
PERF: The kind dispatch must be a single test rax, 0x8000000000000000; jnz — no function-pointer indirection, no trait object. Verify with cargo asm bun_sys::windows::read. Benchmark ReadFile arm against Zig with a 4KB read loop on a regular file: ±2%.
SRC: /root/bun-5/src/sys.zig (lines 2129-2151: Windows read branches if (fd.kind == .uv) sys_uv.read(...) else kernel32.ReadFile(fd.native(),...)); /root/bun-5/src/fd.zig (lines 278-299: closeAllowingStandardIo Windows arm: .uv => libuv.uv_fs_close(...), .windows => bun.c.NtClose(handle)); /root/bun-5/src/sys*uv.zig (lines 1-40: sys_uv polyfills open/read/etc via uv.uv_fs*\* with synchronous null callback)
[bun_sys (constants)] SYS-12-MAX-COUNT-CLAMP
FACT: read/write clamp the buffer length to a per-OS max_count: Linux 0x7ffff000, macOS/iOS i32::MAX, Windows u32::MAX, else isize::MAX. This avoids EINVAL on large buffers and matches kernel limits.
RUST: In bun_sys: pub const MAX_COUNT: usize = if cfg!(target_os="linux") {0x7ffff000} else if cfg!(target_vendor="apple") {i32::MAX as usize} else if cfg!(windows) {u32::MAX as usize} else {isize::MAX as usize}; and every read/write does let n = buf.len().min(MAX_COUNT); as the first line. Trivial, but std::io does NOT do this, which is another reason not to use it.
PERF: Clamp is a single cmp+cmov. No behavioral regression: a 4GB write on Linux must succeed in one call returning ≤0x7ffff000, not EINVAL. Test: write 3GB buffer to /dev/null and assert Ok(n) where n == 0x7ffff000.
FACT: getErrno is type-dispatched at comptime: on Linux, when rc is usize (raw std.os.linux.* syscall), errno is decoded from the return value itself (signed = @bitCast(rc); if signed > -4096 and signed < 0 then @enumFromInt(-signed) else .SUCCESS) — no thread-local read at all. When rc is i32/c_int/u32/isize/i64 (libc wrapper), it reads std.c._errno().* only if rc == -1, so the success path is TLS-free in both branches. The result type E = std.posix.E is a Zig non-exhaustiveenum(u16){…, _}, so @enumFromInt is well-defined for any kernel-returned value in 1..4095 even if not a named variant.
RUST: In crate bun-sys, module errno: define #[repr(transparent)] #[derive(Copy, Clone, PartialEq, Eq)] pub struct E(pub u16); with associated consts (pub const SUCCESS: E = E(0); pub const PERM: E = E(1); …) — this mirrors Zig's non-exhaustive enum and makes unknown kernel errnos sound (no invalid-discriminant UB; a #[repr(u16)] enum + transmute would be UB here). Raw-syscall path (Linux only): #[inline(always)] pub fn get_errno_raw(rc: usize) -> E { let s = rc as isize; if s > -4096 && s < 0 { E((-s) as u16) } else { E::SUCCESS } } — fully safe, zero unsafe. Libc path: #[inline(always)] pub fn get_errno_libc(rc: impl Into<i64>) -> E { if rc.into() == -1 { E(unsafe { *errno_location() } as u16) } else { E::SUCCESS } } where errno_location() is libc::__errno_location (linux), libc::__error (darwin), libc::_errno (windows-crt). The *errno_location() deref is the ONLY unsafe line in this module (justified: libc guarantees a valid thread-local pointer). Do NOT use std::io::Error::last_os_error() — not because it allocates (it does not; Repr::Os(i32) is bitpacked inline) but because it reads TLS errno unconditionally regardless of rc, violating the zero-TLS-on-success invariant, and returns io::Error instead of our E.
PERF: Success path of every syscall wrapper performs zero thread-local reads and zero heap allocations. Measure: (a) cargo asm bun_sys::errno::get_errno_raw — must contain no call/load to __errno_location/%fs:; (b) cargo asm of get_errno_libc — __errno_location call must be dominated by the cmp rc, -1; jne branch (not on the fallthrough); (c) microbench stat()-in-loop on a hot file: Rust bun-sys must match Zig bun.sys.stat ns/op ±2% on linux-x64; (d) #[repr(transparent)] on E ensures size_of::<E>() == 2 and size_of::<Maybe<T>> matches Zig's tagged-union layout.
SRC: /root/bun-5/src/errno/linux*errno.zig (lines 227-249: pub fn getErrno(rc: anytype) E — usize arm: const signed: isize = @bitCast(rc); const int = if (signed > -4096 and signed < 0) -signed else 0; return @enumFromInt(int);. i32, c_int, u32, isize, i64 arm: if (rc == -1) @enumFromInt(std.c.\_errno().*) else .SUCCESS. Line 2: pub const E = std.posix.E;); /root/bun-5/vendor/zig/lib/std/os/linux.zig (line 2822: pub const E = switch (native_arch) { … else => enum(u16) { …, \*, } }— the trailing\_,(e.g. line 2961/3106 in the arch arms) makes E a non-exhaustive enum, so@enumFromInton any u16 is defined behavior in Zig. Rust enums have no equivalent, hence#[repr(transparent)] struct E(u16)is the sound port.); /root/bun-5/src/bun.js/node.zig (lines 224-239:pub fn errnoSys(rc: anytype, syscall: sys.Tag) ?@This()callssys.getErrno(rc), switches once: .SUCCESS => null, else => |e| .{ .err = .{ .errno = translateToErrInt(e), .syscall = syscall } }— single switch, no allocation, returnsnullon success so caller's hot path isorelse-free.)
HTTP/1/2/3 client architecture → crate bun_http
[bun_http::thread] http-thread-singleton
FACT: All HTTP client I/O runs on exactly one dedicated OS thread, spawned once via bun.once(initOnce) which calls std.Thread.spawn and never joins (detached). The thread owns a jsc.MiniEventLoop (uSockets loop, no JSC VM) and runs processEvents() noreturn which loops forever doing drainEvents(); loop.tick(). The global bun.http.http_thread: HTTPThread is a process-static singleton holding two embedded NewHTTPContext(false)/NewHTTPContext(true) values (plain http and https), a timer, and the cross-thread queues.
RUST: In crate bun_http: pub struct HttpThread { loop_: *mut bun_uws_ffi::Loop, http_ctx: HttpContext<Plain>, https_ctx: HttpContext<Tls>, timer: Instant, queued_tasks: mpsc::UnboundedQueue<AsyncHttp>, deferred: Vec<*mut AsyncHttp>, ... } stored in a static HTTP_THREAD: SyncUnsafeCell<MaybeUninit<HttpThread>> (or OnceLock<Box<HttpThread>> if address-stability via heap is acceptable). pub fn init(opts: &InitOpts) guarded by std::sync::Once. The thread entry extern "C" fn on_start(opts: InitOpts) -> ! calls bun_uws_ffi::loop_tick() in an infinite loop. No tokio/async runtime — the loop is the uSockets C loop driven via FFI from bun_uws_ffi. All fields except the cross-thread queues are !Sync and accessed only from the http thread (enforce with a zero-sized HttpThreadToken passed to methods, or debug_assert!(is_http_thread())).
PERF: Exactly one OS thread for all fetch traffic; thread spawn happens once (verify with std::sync::Once + a counter under cfg(debug_assertions)). The hot loop body must compile to drain_events(); us_loop_run_bun_tick(loop) with no per-tick allocation when queues are empty. Measure: run BUN_CONFIG_MAX_HTTP_REQUESTS=256 with 10k keep-alive GETs to a local server and assert getrusage voluntary-ctx-switches and wall time match Zig within 2%; assert no heap alloc inside process_events when queued_tasks is empty (mimalloc stats diff == 0).
SRC: /root/bun-5/src/http/HTTPThread.zig (lines 16-18: loop: *jsc.MiniEventLoop, http_context: NewHTTPContext(false), https_context: NewHTTPContext(true) — two embedded contexts on the singleton); /root/bun-5/src/http/HTTPThread.zig (lines 194-219 initOnce: builds http_thread static, calls std.Thread.spawn(..., onStart, ...) then thread.detach(); guarded by bun.once); /root/bun-5/src/http/HTTPThread.zig (lines 604-650 processEvents() noreturn: while(true){ drainEvents(); loop.inc(); loop.tick(); loop.dec(); }); /root/bun-5/src/http.zig (line 6: pub var http_thread: HTTPThread = undefined; — process-global singleton)
FACT: Requests are handed from arbitrary threads to the HTTP thread via a lock-free intrusive MPSC queue: Queue = UnboundedQueue(AsyncHTTP, .next) where AsyncHTTP.next: ?*AsyncHTTP is the intrusive link. schedule(batch) pushes each *AsyncHTTP onto queued_tasks then calls loop.wakeup() (uSockets eventfd/pipe write). The consumer side (drainEvents) pops on the http thread only. Secondary cross-thread channels (queued_shutdowns, queued_writes, queued_response_body_drains) use a plain Mutex + ArrayListUnmanaged swap-and-drain pattern (lock, swap list with empty, unlock, process locally).
RUST: Port UnboundedQueue to bun_collections (or bun_threading) as pub struct IntrusiveMpsc<T, const OFFSET: usize> using the Vyukov MPSC algorithm with cache-line-padded head/tail (#[repr(align(128))] struct CachePadded<T>). The node is the AsyncHttp itself: #[repr(C)] pub struct AsyncHttp { ..., next: AtomicPtr<AsyncHttp>, ... }. fn push(&self, *mut AsyncHttp) is unsafe (caller guarantees node lives until popped). In bun_http::HttpThread: queued_tasks: IntrusiveMpsc<AsyncHttp>. For shutdown/write/drain side-channels: parking_lot::Mutex<Vec<ShutdownMessage>> with the same swap-out pattern (mem::take under lock, process outside). wakeup() is unsafe { bun_uws_ffi::us_wakeup_loop(self.loop_) }.
PERF: Producer-side schedule() must be O(1) wait-free with at most one atomic XCHG + one atomic store per push, zero heap allocation (the *AsyncHttp IS the node). Measure: cargo asm / objdump of IntrusiveMpsc::push shows no call to allocator; microbench 1M pushes from 4 threads matches Zig UnboundedQueue throughput ±5%. Mutex side-channels must hold the lock only for the mem::take (no I/O under lock) — assert lock hold time < 1µs via lock-contention profiling.
SRC: /root/bun-5/src/http/HTTPThread.zig (line 718: pub const Queue = UnboundedQueue(AsyncHTTP, .next); — intrusive on the .next field); /root/bun-5/src/http/HTTPThread.zig (lines 702-716 schedule(batch): queued_tasks.push(http) per item then loop.wakeup()); /root/bun-5/src/http/AsyncHTTP.zig (line 15: next: ?*AsyncHTTP = null — intrusive link field); /root/bun-5/src/threading/unbounded_queue.zig (lines 1-8: cache_line_length = 128 on x86_64/aarch64; pub fn UnboundedQueue(comptime T, comptime next_field) — generic intrusive Vyukov queue); /root/bun-5/src/http/HTTPThread.zig (lines 368-425 drainQueuedShutdowns: lock, swap queued_shutdowns with empty list, unlock, then iterate — the swap-and-drain pattern)
[bun_http::context] active-socket-tagged-ptr
FACT: The per-socket user-data slot (uSockets ext) holds a single machine word: a TaggedPointerUnion(.{DeadSocket, HTTPClient, PooledSocket, H2.ClientSession}). All uSockets callbacks (onOpen/onData/onWritable/onClose/...) read this word, dispatch on the tag, and cast to the concrete type — there is no vtable, no hashmap lookup. Transitioning a socket between 'active request' and 'parked in pool' is a single store of the tagged word into socket.ext(**anyopaque).
RUST: In bun_http::context: #[repr(transparent)] pub struct ActiveSocket(usize); with low-bit (or high-short) tag encoding identical to src/ptr/tagged_pointer.zig so size_of == usize. enum ActiveSocketTag { Dead=0, Client, Pooled, H2Session }. Provide unsafe fn from_ext(ext: *mut *mut c_void) -> ActiveSocket and fn get<T>(&self) -> Option<NonNull<T>>. The uSockets handler shim is one extern "C" function per event in bun_http::context::handler that does match tag { Client => (*p).on_data(sock, slice), Pooled => ..., H2Session => ..., Dead => {} }. Register these via bun_uws_ffi::us_socket_context_on_*. The unsafe boundary is exactly the extern "C" handler functions + the tagged-pointer cast; everything downstream takes &mut HTTPClient / &mut PooledSocket.
PERF: Socket-event dispatch is one load + one mask + one jump-table; no dyn Trait, no HashMap::get. Verify with cargo asm that on_data compiles to a switch on the tag with direct calls. Bench: 100k 1-byte echo responses — per-onData overhead must be within noise (±3%) of Zig as measured by perf stat -e instructions.
FACT: NewHTTPContext(comptime ssl: bool) is a comptime-generic that produces two distinct types; the entire HTTP/1 send/receive path (onWritable, onData, connect, releaseSocket, the Handler callbacks) is monomorphized over is_ssl so there is no runtime branch on TLS vs plain in the hot loop. HTTPSocket = uws.NewSocketHandler(ssl) is likewise a distinct type per variant.
RUST: In bun_http: pub trait SslMode: sealed::Sealed { const IS_SSL: bool; type Socket: UwsSocket; } with pub struct Plain; pub struct Tls;. pub struct HttpContext<S: SslMode> { ref_count: RefCount, pending_sockets: HiveArray<PooledSocket,64>, group: uws::SocketGroup, secure: Option<NonNull<boring::SSL_CTX>>, active_h2: Vec<*mut h2::ClientSession>, pending_h2: Vec<*mut h2::PendingConnect>, _m: PhantomData<S> }. All methods are impl<S: SslMode> HttpContext<S> so rustc monomorphizes two copies. HTTPClient::on_data<S: SslMode>(&mut self, sock: S::Socket, data: &[u8]) etc. The extern "C" uws handler trampolines are generated once per S via a register_handlers::<S>() that passes monomorphized extern fn pointers to bun_uws_ffi.
PERF: No if is_ssl branch in the per-byte path. Verify by inspecting on_data::<Plain> disassembly contains no call into BoringSSL and on_data::<Tls> contains no dead plain-socket code. Regression gate: hyperfine of 50k plain-http GETs vs 50k https GETs — instruction count delta between Rust and Zig builds must be ≤2% for each variant independently.
FACT: The HTTP/1.1 request/response state machine is two enums on InternalState: request_stage: HTTPStage and response_stage: HTTPStage with variants {pending, opened, headers, body, body_chunk, fail, done, proxy_handshake, proxy_headers, proxy_body}. Response-header parsing calls picohttp.Response.parseParts (FFI to phr_parse_response) writing into a process-global shared_response_headers_buf: [256]picohttp.Header; chunked decoding uses picohttp.phr_decode_chunked with a per-request phr_chunked_decoder struct stored inline in InternalState. Request-header serialization writes into a process-global shared_request_headers_buf: [256]picohttp.Header. These globals are safe because only the single HTTP thread touches them.
RUST: In bun_http::client: #[repr(u8)] pub enum HttpStage { Pending, Opened, Headers, Body, BodyChunk, Fail, Done, ProxyHandshake, ProxyHeaders, ProxyBody }. #[repr(C)] pub struct InternalState { request_stage: HttpStage, response_stage: HttpStage, chunked_decoder: pico::phr_chunked_decoder, content_length: Option<usize>, ... }. In bun_http::pico (sub-module, FFI to vendored vendor/picohttpparser): extern "C" { fn phr_parse_response(...); fn phr_decode_chunked(...); } plus #[repr(C)] struct phr_header { name: *const u8, name_len: usize, value: *const u8, value_len: usize } and #[repr(C)] struct phr_chunked_decoder. Thread-local-equivalent shared buffers live as fields on HttpThread (NOT Rust static mut): shared_request_headers: [pico::Header; 256], shared_response_headers: [pico::Header; 256] — passed by &mut into build_request / parse_response so borrow-ck proves single-writer.
PERF: Header parsing must not heap-allocate: the 256-slot scratch arrays are reused for every request. Chunked decode is in-place (picohttpparser mutates the input buffer). Measure: heap-profile a loop of 10k responses with 30 headers each — zero allocations attributed to parse_response. phr_parse_response must be the same C symbol Zig links (compare nm | grep phr_), guaranteeing byte-identical SIMD parsing performance.
SRC: /root/bun-5/src/http/InternalState.zig (lines 222-236 HTTPStage enum: pending/opened/headers/body/body_chunk/fail/done/proxy_handshake/proxy_headers/proxy_body); /root/bun-5/src/http/InternalState.zig (line 22: chunked_decoder: picohttp.phr_chunked_decoder = .{} — inline C struct, no heap); /root/bun-5/src/http.zig (lines 33-37: var shared_request_headers_buf: [256]picohttp.Header and shared_response_headers_buf: [256]picohttp.Header — process-global scratch reused because single-threaded); /root/bun-5/src/http.zig (line 1871: picohttp.Response.parseParts(...) and lines 2655-2656/2734-2735: picohttp.phr_decode_chunked(...) (in-place)); /root/bun-5/src/deps/picohttp.zig (line 297 c.phr_parse_response, line 367 phr_chunked_decoder = c.phr_chunked_decoder — direct C FFI, no reimplementation)
[bun_http::h2] h2-lshpack-own-framing
FACT: HTTP/2 is implemented as a ClientSession that owns the TLS socket once ALPN selects "h2" and becomes the ActiveSocket variant. It holds hpack: *lshpack.HPACK (FFI to vendored vendor/lshpack/lshpack.c), a write_buffer: bun.io.StreamBuffer for outbound whole frames, a read_buffer that accumulates until a full 9-byte header + payload is available, and streams: AutoArrayHashMap(u31, *Stream). Frame parsing/dispatch is hand-written in h2_client/dispatch.zig; HEADERS encoding in h2_client/encode.zig. After decoding, responses are fed into the SAME picohttp.Response / handleResponseBody code path as HTTP/1.1 so redirect/decompression/result-callback logic is shared.
RUST: Crate bun_http::h2: #[repr(C)] pub struct ClientSession { ref_count: RefCount, hpack: NonNull<lshpack_sys::HPACK>, socket: uws::TlsSocket, ctx: *mut HttpContext<Tls>, hostname: Box<[u8]>, port: u16, ssl_config: Option<SslConfigArc>, write_buffer: StreamBuffer, read_buffer: Vec<u8>, streams: IndexMap<u31, Box<Stream>>, next_stream_id: u31, expecting_continuation: u31, pending_attach: Vec<*mut HttpClient>, ... }. mod lshpack_sys in bun_http (or separate bun_lshpack_ffi) with extern "C" bindings to lshpack_enc_*/lshpack_dec_* and #[repr(C)] struct lsxpack_header. Frame parsing in h2::dispatch as a fn feed(&mut ClientSession, &[u8]) state machine over read_buffer; encoding in h2::encode. Decoded headers are materialized into the same pico::Header array type and handed to HttpClient::handle_response_metadata — one code path for h1/h2/h3 result delivery. Constants mirrored: LOCAL_INITIAL_WINDOW_SIZE: u31 = 1<<24, LOCAL_MAX_HEADER_LIST_SIZE: u32 = 256*1024, WRITE_BUFFER_HIGH_WATER: usize = 256*1024.
PERF: HPACK encode/decode goes through the C lshpack library unchanged (no Rust reimplementation), so per-header CPU is identical. Frame dispatch must not allocate per DATA frame — read_buffer grows monotonically and is drained in place. Measure: h2 multiplexed bench (100 concurrent streams, 1 MiB each) against nghttpd — throughput within 3% of Zig; perf record shows lshpack_dec_decode as the same fraction of samples.
FACT: HTTP/2 connection coalescing is done in HttpContext<Tls>: active_h2_sessions: ArrayListUnmanaged(*H2.ClientSession) lists sessions with ≥1 active stream available for adopt() if hasHeadroom(); pending_h2_connects: ArrayListUnmanaged(*H2.PendingConnect) lets a second h2-capable request to the same origin park on the leader's in-flight TLS connect instead of opening a new socket. connect() checks both lists before falling back to the keep-alive pool, and only then opens a fresh socket.
RUST: On HttpContext<Tls>: active_h2_sessions: Vec<NonNull<h2::ClientSession>>, pending_h2_connects: Vec<Box<h2::PendingConnect>>. fn connect(&mut self, client: &mut HttpClient, host: &str, port: u16) -> Result<Option<uws::TlsSocket>> order: (1) scan active_h2_sessions for matches(host,port,ssl_cfg) && has_headroom() → adopt(client), return None; (2) scan pending_h2_connects for match → push client to waiters, return None; (3) existing_socket() keep-alive pool; (4) uws::Socket::connect_group(...) and, if h2-capable, register a new PendingConnect. PendingConnect { hostname: Box<str>, port: u16, ssl_config: *const SslConfig, waiters: Vec<*mut HttpClient> }. All raw pointers are http-thread-confined; wrap in HttpThreadOnly<T>(*mut T) newtype that is !Send + !Sync.
PERF: N concurrent fetches to one h2 origin open exactly 1 TCP connection (after ALPN). Measure: integration test firing 50 parallel fetch() to one h2 server, assert server sees 1 connection; compare TLS-handshake count vs Zig (must be 1). Linear scan of active_h2_sessions is acceptable because list length is bounded by distinct origins × concurrency, same as Zig.
SRC: /root/bun-5/src/http/HTTPContext.zig (lines 96-103: active_h2_sessions: ArrayListUnmanaged(*H2.ClientSession) and pending_h2_connects: ArrayListUnmanaged(*H2.PendingConnect) with coalescing doc comments); /root/bun-5/src/http/HTTPContext.zig (lines 738-758 connect(): iterate active_h2_sessions → session.adopt(client); iterate pending_h2_connects → pc.waiters.append(client); both return null (no socket)); /root/bun-5/src/http/HTTPContext.zig (lines 814-827: after fresh connectGroup, if canOfferH2() create H2.PendingConnect and append to pending_h2_connects)
[bun_http::h3] h3-lsquic-via-usockets
FACT: HTTP/3 is layered on vendored lsquic through a C shim packages/bun-usockets/src/quic.c that integrates lsquic's engine with the uSockets loop (UDP polling + timer hooks). One process-global H3.ClientContext lazily creates the lsquic client engine bound to the HTTP thread's loop; it owns sessions: ArrayListUnmanaged(*ClientSession). Each ClientSession is one QUIC connection multiplexing Streams 1:1 with HTTPClients. Result delivery reuses handleResponseMetadata / handleResponseBody exactly like h1/h2. lsquic callbacks (on_hsk_done, on_stream_*) are registered in h3_client/callbacks.zig.
RUST: Crate bun_http::h3: pub struct ClientContext { qctx: NonNull<quic_sys::us_quic_socket_context_s>, sessions: Vec<Box<ClientSession>> } stored in static H3_INSTANCE: UnsafeCell<Option<Box<ClientContext>>> (http-thread-only, init via Once). pub struct ClientSession { qsocket: Option<NonNull<quic_sys::us_quic_socket_s>>, hostname: CString, port: u16, reject_unauthorized: bool, registry_index: u32, streams: Vec<Box<Stream>>, ... }. mod quic_sys = extern "C" bindings to packages/bun-usockets/src/quic.c (us_quic_*) — keep the C shim, do NOT bind lsquic directly. h3::callbacks defines extern "C" fn on_hsk_done(...), on_new_stream, on_read, on_close that cast the ext slot to *mut ClientSession / *mut Stream and forward into safe Rust. Ext sizes passed to createClient are size_of::<*mut ClientSession>() / size_of::<*mut Stream>().
PERF: QUIC packet I/O and QPACK stay in C (lsquic + lsqpack); Rust only orchestrates session/stream bookkeeping. Zero-copy: on_read hands the lsquic-provided buffer slice directly to handle_response_body without intermediate copy. Measure: h3 download of 100 MiB from a local lsquic server — throughput within 3% of Zig; perf shows lsquic_engine_process_conns dominating, with Rust glue <2% of samples.
SRC: /root/bun-5/src/http/H3Client.zig (lines 1-16 doc: one ClientContext per HTTP-thread loop wraps lsquic engine; ClientSession = one QUIC conn; result path shared with h1/h2 via handleResponseMetadata/handleResponseBody); /root/bun-5/src/http/h3_client/ClientContext.zig (lines 7-37: qctx: *quic.Context, sessions: ArrayListUnmanaged(*ClientSession); getOrCreate calls quic.Context.createClient(loop, 0, @sizeOf(*ClientSession), @sizeOf(*Stream)) then callbacks.register(qctx)); /root/bun-5/src/http/h3_client/ClientContext.zig (lines 41-79 connect(): linear scan sessions for reuse, else qctx.connect(host, port, sni, reject, session) returning .socket|.pending|.err); /root/bun-5/packages/bun-usockets/src/quic.c (lines 36-78: us_quic_socket_context_s integrates lsquic engine with the uws loop (loop->data.quic_head linked list))
FACT: AsyncHTTP is the caller-owned request handle: it embeds an HTTPClient by value, an intrusive next link, a ThreadPool.Task, an async_http_id: u32 (allocated from a global async_http_id_monotonic: atomic u32), and an AtomicState enum {pending, scheduled, sending, success, fail}. Global atomics active_requests_count and max_simultaneous_requests (default 256, overridable via BUN_CONFIG_MAX_HTTP_REQUESTS) gate how many requests drainEvents starts; over-limit pops go to deferred_tasks: ArrayListUnmanaged(*AsyncHTTP) (FIFO) on the http thread. When started, the handle is cloned into a heap ThreadlocalAsyncHTTP so the http-thread copy has a stable address while the caller's original may move; cloned.real = original links them. The id is registered in socket_async_http_abort_tracker: AutoArrayHashMap(u32, uws.AnySocket) so scheduleShutdown can find the live socket.
PERF: Starting a queued request costs one heap alloc (the ThreadlocalAsyncHttp clone) and one hashmap insert; the at-capacity fast path in drain_events is O(1) (if active >= max && !has_pending_queued_abort { return }). Measure: with max=256 and 10k queued requests, drain_events per-tick cost when saturated must be O(popped), not O(queued) — profile shows no scan of queued_tasks when the early-return fires. State must be a 4-byte atomic so cross-thread signals/state reads are a single ldar.
FACT: Each NewHTTPContext(ssl) owns a fixed-capacity keep-alive pool: PooledSocketHiveAllocator = bun.HiveArray(PooledSocket, 64) — an inline [64]PooledSocket array plus an IntegerBitSet(64) occupancy mask. PooledSocket (auto-layout, not extern) stores the uws socket handle, an inline hostname_buf: [MAX_KEEPALIVE_HOSTNAME=128]u8 + hostname_len: u8, port: u16, did_have_handshaking_error_...: bool, ssl_config: ?SSLConfig.SharedPtr, owner: *Context, proxy_tunnel: ?ProxyTunnel.RefPtr, heap-owned target_hostname: []const u8, target_port: u16, proxy_auth_hash: u64, h2_session: ?*H2.ClientSession. releaseSocket calls pending_sockets.get() (find-first-unset bit, O(1)) which returns a pointer to UNDEFINED storage, field-assigns into it, memcpy's the hostname into the inline buffer, stores ActiveSocket.init(pending).ptr() (a tagged raw pointer into the hive slot) into the uws socket ext word, and sets a 5-minute timeout; when a tunnel is present it heap-dupes target_hostname. existingSocket linearly iterates pending_sockets.used.iterator(.{.kind=.set}) (≤64 bits) matching port → ssl_config pointer-eq → ALPN/h2 → tunnel presence/auth-hash/target → hostname. Hostnames > 128 bytes are never pooled. put() writes undefined back into the slot and clears the bit.
RUST: In bun_collections: pub struct HiveArray<T, const N: usize> { buffer: Box<UnsafeCell<[MaybeUninit<T>; N]>>, used: Cell<u64> /* or BitSet<N> */ } (boxed so the array address is stable and *mut T slot pointers stored in uws ext survive moves of the owning context). API never materializes &mut T over uninit and never takes &mut self while raw slot pointers are outstanding: fn insert(&self, value: T) -> Option<NonNull<T>> = find first zero via used.get().trailing_ones(), ptr::write the fully-constructed value into buffer.get().cast::<T>().add(idx), set the bit, return NonNull; fn at(&self, idx: usize) -> NonNull<T>; fn index_of(&self, p: NonNull<T>) -> Option<usize> via (p - base) / size_of::<T>(); fn put(&self, p: NonNull<T>) = index_of, ptr::drop_in_place(p), clear bit; fn iter_set(&self) -> impl Iterator<Item = NonNull<T>> over set bits. UnsafeCell + raw-pointer-only access keeps the long-lived *PooledSocket stored in the uws ext valid across later pool ops under Stacked Borrows. In bun_http::context: pub struct PooledSocket { http_socket: uws::Socket, hostname_buf: [u8; MAX_KEEPALIVE_HOSTNAME], hostname_len: u8, port: u16, did_have_handshaking_error: bool, ssl_config: Option<SslConfigArc>, owner: NonNull<dyn HttpContextErased>, proxy_tunnel: Option<ProxyTunnelRef>, target_hostname: Box<[u8]> /* empty when no tunnel */, target_port: u16, proxy_auth_hash: u64, h2_session: Option<NonNull<h2::ClientSession>> } — default repr(Rust) (NOT repr(C)): the struct never crosses FFI; only its address (packed into ActiveSocket, a TaggedPointerUnion) is written to the uws ext word, so auto-layout is correct and lets rustc reorder for minimal padding like Zig does. release_socket builds a complete PooledSocket on the stack and calls pending_sockets.insert(ps); on Some(slot) it stores ActiveSocket::pooled(slot).ptr() into the ext, flushes, sets the 5-min timeout; on None it closes the socket and drops tunnel/h2. existing_socket iterates iter_set() applying the same filter chain, then put()s the slot after extracting http_socket/tunnel/h2_session. const MAX_KEEPALIVE_HOSTNAME: usize = 128; const POOL_SIZE: usize = 64;.
PERF: Direct (non-tunnel) keep-alive release performs zero heap allocation (one struct move + 128-byte inline memcpy) and existing_socket does at most 64 bitset iterations with no allocation; tunneled release allocates once for target_hostname exactly as Zig does. size_of::<PooledSocket>() ≤ Zig's @sizeOf(PooledSocket) (default repr(Rust) field-reordering, Option<NonNull<_>> and Option<Arc<_>> are 1 word via niche). Measure: static_assertions::const_assert! on the struct size; microbench release_socket+existing_socket round-trip on a warm 64-slot pool vs the Zig build (target: within noise, no allocator calls on the non-tunnel path under heaptrack/--features count-allocs).
SRC: /root/bun-5/src/http/HTTPContext.zig (line 3 const pool_size = 64;; line 72 pub const PooledSocketHiveAllocator = bun.HiveArray(PooledSocket, pool_size);); /root/bun-5/src/http/HTTPContext.zig (lines 4-32 PooledSocket (auto-layout struct, not extern): http_socket, hostname_buf: [MAX_KEEPALIVE_HOSTNAME]u8, hostname_len: u8, port: u16, did_have_handshaking_error_...: bool, ssl_config: ?SSLConfig.SharedPtr, owner: *Context, proxy_tunnel: ?ProxyTunnel.RefPtr, target_hostname: []const u8, target_port: u16, proxy_auth_hash: u64, h2_session: ?*H2.ClientSession); /root/bun-5/src/http/HTTPContext.zig (line 129 const MAX_KEEPALIVE_HOSTNAME = 128;); /root/bun-5/src/http/HTTPContext.zig (lines 115-120 const ActiveSocket = TaggedPointerUnion(.{ DeadSocket, HTTPClient, PooledSocket, H2.ClientSession }) — only the pointer to a PooledSocket (tagged) is stored in the uws ext word, never the struct layout, so no FFI/repr(C) requirement on PooledSocket); /root/bun-5/src/http/HTTPContext.zig (lines 287-358 releaseSocket: gate hostname.len <= MAX_KEEPALIVE_HOSTNAME; line 314 this.pending_sockets.get() returns *PooledSocket to undefined slot; line 316 stores ActiveSocket.init(pending).ptr() into socket.ext(**anyopaque); line 320 socket.setTimeoutMinutes(5); lines 322-340 field-assign into the slot incl. @memcpy(pending.hostname_buf[0..hostname.len], hostname); lines 335-338 heap-dupe target_hostname only when tunnel != null (non-tunnel path is alloc-free)); /root/bun-5/src/http/HTTPContext.zig (lines 615-712 existingSocket: early-out if hostname > MAX_KEEPALIVE_HOSTNAME; var iter = this.pending_sockets.used.iterator(.{ .kind = .set }); per-slot filter chain port → SSLConfig.rawPtr pointer-eq → handshaking-error/reject_unauthorized → h2/ALPN compatibility → tunnel presence + proxy_auth_hash + target_port + target_hostname → hostname eq; on hit: drop ssl_config ref, leak tunnel, free target_hostname, this.pending_sockets.put(socket)); /root/bun-5/src/collections/hive_array.zig (lines 4-35 HiveArray(T, capacity): buffer: [capacity]T (initialized undefined), used: IntegerBitSet(capacity); get() = used.findFirstUnset() → used.set(index) → return &buffer[index] (storage is undefined; caller field-initializes — Rust must NOT mirror this as &mut T)); /root/bun-5/src/collections/hive_array.zig (lines 65-76 put(self, value: *T): indexOf(value) via pointer-subtraction (ptr - base)/@sizeOf(T); value.* = undefined; used.unset(index) — Rust equivalent is ptr::drop_in_place then clear bit)
[bun_http::thread] http-thread-body-buffer-reuse
FACT: Request-body serialization uses a tiered reusable buffer owned by the HTTP thread: getRequestBodySendBuffer(estimated_size) returns a StackFallbackAllocator(32*1024) when estimated_size < 32 KiB, otherwise it takes ownership (via bun.take) of a lazily-allocated singleton HeapRequestBodyBuffer { buffer: [512*1024]u8, FixedBufferAllocator } from http_thread.lazy_request_body_buffer (allocating one if the slot is null). On RequestBodyBuffer.deinit() the heap variant calls put(), which resets the FixedBufferAllocator and re-parks the pointer in the slot if it is still null, else frees it. lazy_libdeflater is the same pattern: one lazily-created LibdeflateState { *Decompressor, shared_buffer: [512*1024]u8 } cached for the thread's lifetime.
RUST: In bun_http::thread: pub const REQUEST_BODY_STACK_SIZE: usize = 32*1024; pub const HEAP_BODY_BUF: usize = 512*1024;. pub struct HeapRequestBodyBuffer { buffer: [u8; HEAP_BODY_BUF], cursor: usize } (no #[repr(C)] — never crosses FFI). pub struct HttpThread { lazy_request_body_buffer: Option<Box<HeapRequestBodyBuffer>>, lazy_libdeflater: Option<Box<LibdeflateState>>, ... }. The buffer wrapper owns the heap allocation so it can be moved back: enum Inner { Stack(StackFallback<REQUEST_BODY_STACK_SIZE>), Heap(Box<HeapRequestBodyBuffer>) } and pub struct RequestBodyBuffer { inner: Option<Inner>, thread: *mut HttpThread } (raw ptr is sound: constructed and dropped only on the single HTTP thread; type is !Send + !Sync). HttpThread::get_request_body_send_buffer(&mut self, est: usize) does if est >= REQUEST_BODY_STACK_SIZE { Inner::Heap(self.lazy_request_body_buffer.take().unwrap_or_else(|| Box::new(HeapRequestBodyBuffer::new()))) } else { Inner::Stack(StackFallback::new()) } — Option::take() is the exact analogue of Zig's bun.take(). impl Drop for RequestBodyBuffer: if let Some(Inner::Heap(mut b)) = self.inner.take() { b.cursor = 0; let t = unsafe { &mut *self.thread }; if t.lazy_request_body_buffer.is_none() { t.lazy_request_body_buffer = Some(b); } /* else Box drops = free, mirrors put()'s 'hypothetically never happens' branch */ }. LibdeflateState { decompressor: NonNull<libdeflate_decompressor>, shared_buffer: [u8; HEAP_BODY_BUF] } is Box-allocated once via HttpThread::deflater(&mut self) -> &mut LibdeflateState { self.lazy_libdeflater.get_or_insert_with(...) }.
PERF: Steady state allocates the 512 KiB heap body buffer at most once per process; N consecutive large POSTs perform 1 allocation, not N. Measure: instrument Box::new(HeapRequestBodyBuffer::new()) with a counter and run 1000 sequential 100 KiB-body fetch() POSTs — counter must read 1. Body-serialization throughput (bytes/sec writing headers+body into the buffer) must match Zig within noise; the stack path must not heap-allocate at all for bodies < 32 KiB (verify with allocator tracing).
SRC: /root/bun-5/src/http/HTTPThread.zig (lines 46-47: lazy_libdeflater: ?*LibdeflateState = null and lazy_request_body_buffer: ?*HeapRequestBodyBuffer = null — the two thread-owned lazy singleton slots); /root/bun-5/src/http/HTTPThread.zig (lines 49-73: HeapRequestBodyBuffer = [512*1024]u8 + FixedBufferAllocator; put() resets the allocator and stores this into http_thread.lazy_request_body_buffer only if the slot is null, else calls deinit() (free)); /root/bun-5/src/http/HTTPThread.zig (lines 75-105: RequestBodyBuffer = union(enum){ heap: *HeapRequestBodyBuffer, stack: StackFallbackAllocator(32K) }; deinit() on .heap calls heap.put() — the put-back happens at buffer destruction, mirrored by Rust Drop); /root/bun-5/src/http/HTTPThread.zig (line 136: request_body_send_stack_buffer_size = 32 * 1024; lines 138-152: getRequestBodySendBuffer — >= 32KiB path uses bun.take(&this.lazy_request_body_buffer).? (ownership transfer out of the Option) or HeapRequestBodyBuffer.init(); else returns stackFallback(32K, default_allocator)); /root/bun-5/src/http/HTTPThread.zig (lines 124-134: LibdeflateState = *libdeflate.Decompressor + [512*1024]u8 shared_buffer; lines 154-162: deflater() lazy-inits and caches it on lazy_libdeflater)
[bun_http] http-client-custom-ssl-ctx-cache
FACT: Custom TLS configs (e.g. fetch tls: {...}) get their own heap-allocated NewHTTPContext(true) instances cached in a module-level var custom_ssl_context_map: std.AutoArrayHashMap(*SSLConfig, SslContextCacheEntry) keyed by interned-pointer identity (SSLConfig is interned via SSLConfig.GlobalRegistry.intern so *SSLConfig equality == value equality). Cache is bounded to 60 entries with a 30-minute TTL; on eviction the entry is swapRemoveAt'd and entry.ctx.deref() is called — the context is intrusively ref-counted (bun.ptr.RefCount) so in-flight HTTPClients that called setCustomSslCtx (which ref()s) keep it alive until they finish. Each cached context has its own 64-slot keep-alive PooledSocketHiveAllocator, so custom-TLS requests reuse connections.
RUST: In bun_http::thread: struct SslContextCacheEntry { ctx: RefPtr<HttpContext<Tls>>, last_used_ns: u64, config_ref: SslConfigShared } where RefPtr<T> is the bun_ptr intrusive-refcount smart pointer (clone = ref, drop = deref, last deref runs deinit + frees). NEVER Box<HttpContext<Tls>> — Box is unique-owning and dropping the map entry would free the context while in-flight clients still hold refs (UAF; this is exactly what the Zig RefCount at HTTPContext.zig:74-82 prevents). HttpContext<Tls> carries ref_count: bun_ptr::RefCount as its first field; HttpClient stores custom_ssl_ctx: Option<RefPtr<HttpContext<Tls>>> (clone on set_custom_ssl_ctx, drop on client deinit). Key wrapper for Send-safety: #[derive(Copy, Clone, Eq, PartialEq, Hash)] struct SslConfigKey(*const SslConfig); unsafe impl Send for SslConfigKey {} (sound: map is only touched on the single HTTP thread; pointee kept alive by config_ref). Map: custom_ssl_context_map: IndexMap<SslConfigKey, SslContextCacheEntry> as a field on HttpThread (Zig uses a module-level var; moving it onto the singleton struct is an intentional cleanup, not a parity claim). const SSL_CTX_CACHE_MAX: usize = 60; const SSL_CTX_CACHE_TTL_NS: u64 = 30 * 60 * 1_000_000_000;. Eviction = swap_remove_index(i) then let the entry drop (drops one strong ref on ctx and one on config_ref). Requires SslConfig interning ported in bun_tls so pointer-identity keys remain valid.
PERF: Lookup must be O(1) hashed (IndexMap::get, same as Zig's AutoArrayHashMap.getPtr — NOT a linear scan); only evict_oldest is O(n≤60). Cache hit must not allocate. Each cached context must own a 64-slot keep-alive pool so repeat custom-TLS fetches to the same host skip TCP+TLS handshake. Measure: loop await fetch(url, {tls: sameCfg}) 1000× against a local TLS server and assert (a) server sees ≤ a handful of accepts (connection reuse), (b) heap-snapshot shows ≤1 HttpContext<Tls> for that config, (c) p50 latency matches Zig within noise.
SRC: /root/bun-5/src/http/HTTPThread.zig (lines 6-14: SslContextCacheEntry { ctx: *NewHTTPContext(true), last_used_ns, config_ref: SSLConfig.SharedPtr }; ssl_context_cache_max_size = 60; ssl_context_cache_ttl_ns = 30 * std.time.ns_per_min; module-level var custom_ssl_context_map = std.AutoArrayHashMap(*SSLConfig, SslContextCacheEntry).init(...)); /root/bun-5/src/http/HTTPThread.zig (lines 244-310 connect(): custom_ssl_context_map.getPtr(requested_config) pointer-keyed O(1) lookup; cache hit updates last_used_ns and calls client.setCustomSslCtx(entry.ctx); miss does bun.default_allocator.create(NewHTTPContext(is_ssl)), initWithClientConfig, put(requested_config, .{ .ctx, .last_used_ns, .config_ref = tls.clone() }), then evictOldestSslContext() if count > max); /root/bun-5/src/http/HTTPThread.zig (lines 322-358 evictStaleSslContexts/evictOldestSslContext: swapRemoveAt(i) then entry.ctx.deref() + entry.config_ref.deinit() — map removal does NOT free the context; only the deref does, and only if it's the last ref); /root/bun-5/src/http/HTTPContext.zig (line 3 pool_size = 64; lines 74-82 RefCount = bun.ptr.RefCount(...) doc: cache holds 1 ref, each in-flight HTTPClient with custom_ssl_ctx = this holds 1, eviction drops cache ref but context survives until last client releases); /root/bun-5/src/http.zig (lines 657, 711-712, 821-824: custom_ssl_ctx: ?*NewHTTPContext(true); setCustomSslCtx refs new / derefs old; client deinit derefs — confirms in-flight clients hold independent strong refs that must outlive cache eviction); /root/bun-5/src/bun.js/api/server/SSLConfig.zig (lines 297-320 GlobalRegistry: ArrayHashMapUnmanaged(*SSLConfig, WeakPtr, ...) with contentHash/isSame; intern() returns SharedPtr so equal-by-value configs share one heap address — validates pointer-identity keying)
bun install architecture → crate bun_install
[bun_threading] INST-01-threadpool-intrusive-task
FACT: bun.ThreadPool (kprotty/zap derivative) uses an INTRUSIVE task model: Task = struct { node: Node, callback: *const fn(*Task) void } where Node = struct { next: ?*Node }. Tasks are never heap-allocated by the pool — callers embed Task inside their own structs (NetworkTask, PackageManagerTask) and recover the outer struct via @fieldParentPtr. Batch = struct { len: usize, head: ?*Task, tail: ?*Task } is a value-type intrusive singly-linked list that is built on the main thread and handed to schedule() in one shot.
RUST: Crate bun_threading. #[repr(C)] pub struct Node { next: AtomicPtr<Node> }, #[repr(C)] pub struct Task { node: Node, callback: unsafe fn(*mut Task) }, pub struct Batch { len: usize, head: *mut Task, tail: *mut Task } (Copy, !Send is fine — only crossed via schedule). Provide container_of! macro (memoffset-based) as the @fieldParentPtr replacement; this is the unsafe boundary. DO NOT model tasks as Box<dyn FnOnce()> / Arc<dyn Fn> — that adds an allocation + vtable indirection per task that the Zig design explicitly avoids. Embedders in bun_install write #[repr(C)] struct ResolveTask { tp_task: Task, ... } and the callback does let this = container_of!(task, ResolveTask, tp_task).
PERF: Zero allocations inside ThreadPool::schedule(batch) and inside the worker run loop. Measure with a mimalloc stats reset/diff around scheduling 100k tasks: heap-alloc count delta must be 0. End-to-end: hyperfine 'bun install --ignore-scripts' on a 2000-dep cold-cache project must match Zig ±2%.
FACT: All variable-length data in the lockfile is referenced via ExternalSlice(T) = extern struct { off: u32, len: u32 } (8 bytes) indexing into one of six flat Buffers arrays: trees, hoisted_dependencies, resolutions: []PackageID, dependencies: []Dependency, extern_strings, string_bytes. Package.dependencies: DependencySlice and Package.resolutions: PackageIDSlice are parallel ExternalSlices into these shared buffers — packages own zero heap pointers.
PERF: sizeof(ExternalSlice<T>) == 8 for all T. Dependency graph traversal (doFlushDependencyQueue) must be pure index arithmetic into contiguous slices — zero pointer-chasing, zero bounds-check in release (use get_unchecked behind debug_assert). Measure: resolve-phase wall time on warm-cache install must match Zig ±2%.
SRC: /root/bun-5/src/install/ExternalSlice.zig (L1-53: ExternalSlice(T) = extern struct { off: u32, len: u32 } with get()/mut() slicing into a backing buffer); /root/bun-5/src/install/lockfile/Buffers.zig (L3-13: six flat array fields backing all ExternalSlices); /root/bun-5/src/install/install.zig (L95-110: PackageID = u32, DependencyID = u32, invalid_package_id = maxInt(u32), PackageNameHash = u64)
FACT: PackageManager.preallocated_network_tasks: HiveArray(NetworkTask, 128).Fallback and preallocated_resolve_tasks: HiveArray(Task, 64).Fallback. HiveArray(T, N) is { buffer: [N]T, used: IntegerBitSet(N) } — get() finds first unset bit, returns stable *T into the inline array; .Fallback heap-allocates only when all N slots are used. getNetworkTask() is the hot allocator for every manifest fetch and tarball download.
RUST: Crate bun_collections. pub struct HiveArray<T, const N: usize> { buffer: [MaybeUninit<T>; N], used: BitArray<N> } with fn get(&mut self) -> Option<&mut T> (find-first-zero via u64::trailing_ones on word array). pub struct HiveFallback<T, const N: usize> { hive: Box<HiveArray<T, N>>, overflow: Vec<Box<T>> }. Returned references are raw *mut T (stable; Box ensures the array doesn't move). Used in bun_install::manager as preallocated_network_tasks: HiveFallback<NetworkTask, 128>. Unsafe boundary: get() returns &mut T with lifetime tied to &mut self but callers store the raw pointer in a Batch — document that the HiveArray outlives all scheduled tasks (it's a field of PackageManager).
PERF: First 128 concurrent network tasks and first 64 resolve tasks incur ZERO heap allocation. Measure: warm-cache bun install on a project with ≤100 deps must show 0 mallocs from getNetworkTask. Pointer stability: addresses returned by get() must not change for the HiveArray's lifetime (no Vec realloc).
FACT: ThreadPool's scheduler state Sync is a packed struct(u32) { idle: u14, spawned: u14, unused: bool, notified: bool, state: enum(u2) } stored in the pool as sync: Atomic(Sync) and mutated via cmpxchgWeak / fetchOr on the whole 32-bit word. Each worker Thread owns a bounded SPMC ring Node.Buffer { head: Atomic(u32), tail: Atomic(u32), array: [256]Atomic(*Node) } (capacity = 256, asserted power-of-two; slot writes/reads use .unordered, head/tail use .monotonic/.acquire/.release) plus an unbounded MPMC Node.Queue (Treiber stack whose stack: Atomic(usize) tags 2 low pointer bits HAS_CACHE=0b01 | IS_CONSUMING=0b10, PTR_MASK=~0b11, with a comptime assert that @alignOf(Node) >= 4). Work-stealing copies ceil(half) of a victim's buffer (buffer_size - buffer_size/2) and commits with a single cmpxchgStrong(.acq_rel, .monotonic) on the victim's head. The notify() fast-path is a single release-RMW self.sync.fetchOr(.{}, .release) that early-returns if notified was already set.
RUST: Crate bun_threading. #[repr(transparent)] struct Sync(u32) with const bit-shift accessors (idle = bits 0..14, spawned = 14..28, unused = 28, notified = 29, state = 30..32) wrapping an AtomicU32 field on ThreadPool — Zig's Atomic(Sync) over a packed struct(u32) is exactly AtomicU32 + bitfield helpers. struct Buffer { head: AtomicU32, tail: AtomicU32, array: [AtomicPtr<Node>; 256] } — port push/pop/steal/consume verbatim including wrapping arithmetic (tail.wrapping_sub(head), idx % 256). Map Zig's .unordered slot loads/stores to Ordering::Relaxed (Rust's core::sync::atomic::Ordering has no Unordered variant; Relaxed is the nearest, strictly-stronger LLVM monotonic, identical codegen to plain load/store on x86*64/aarch64), and map .monotonic→Relaxed, .acquire→Acquire, .release→Release, .acq_rel→AcqRel one-for-one. struct Queue { stack: AtomicUsize, cache: *mut Node }withconst HAS_CACHE: usize = 0b01; const IS_CONSUMING: usize = 0b10; const PTR_MASK: usize = !0b11;and aconst \*: () = assert!(align_of::<Node>() >= 4);. All of this is unsafeinternally; the safe surface isThreadPool::schedule(&self, Batch). Do NOT substitute crossbeam-deque (different steal heuristic, different layout) or rayon (join-based, not batch-based).
PERF: Scheduler-state update = exactly one 32-bit RMW (no mutex, no multi-word CAS); notify() fast-path when already-notified = one fetch_or(Release) + branch, no syscall. Buffer steal commit = one compare_exchange(AcqRel, Relaxed) on victim head, ceil(half) batch size. Buffer slot access = plain mov (Relaxed → no fence on x86_64/aarch64). Measure with perf stat -e cache-misses,instructions on bun install cold-cache against the Zig build; require ≤1% instruction-count delta in ThreadPool::schedule/notify and identical fence count in objdump -d of Buffer::push/steal.
FACT: Semver.String is extern struct { bytes: [8]u8 } (size 8, align 1) — a single 8-byte word encoding three states: all-zero = empty; bit 7 of bytes[7] clear = inline bytes (≤8, NUL-terminated if <8); bit 7 set = external Pointer { off: u32, len: u32 } into the lockfile's shared string_bytes: []u8 arena. The pointer is recovered by bitcasting to u64, truncating to u63 (clearing the tag bit), then bitcasting to Pointer — i.e. the tag occupies bit 31 of len, not a separate byte. slice() takes *const String (not by-value) because the inline branch returns a view into self.bytes. ExternalString = extern struct { value: String, hash: u64 } (size 16, align 8). Lockfile builder hashes via bun.Wyhash11.hash(0, buf) for the dedup pool; ExternalString.from uses bun.Wyhash. Every serialized string in the lockfile (names, versions, paths, URLs) uses this — no owned []const u8 appears in on-disk data.
RUST: Crate bun_semver. #[repr(C)] #[derive(Copy, Clone, Pod, Zeroable)] pub struct SemverString { bytes: [u8; 8] } (no align(8) — must stay align 1 to match Zig's extern struct { [8]u8 } wherever it embeds after non-8-aligned fields). Accessors: fn is_inline(&self) -> bool { self.bytes[7] & 0x80 == 0 }; fn ptr(&self) -> (u32, u32) { let w = u64::from_ne_bytes(self.bytes) & !(1u64 << 63); (w as u32, (w >> 32) as u32) } — masking bit 63 mirrors Zig's @as(u63, @truncate(...)). pub fn slice<'a>(&'a self, buf: &'a [u8]) -> &'a [u8] — lifetime ties to BOTH self and buf so the inline branch (which returns &self.bytes[..n]) is sound; returns &[u8] not &str (lockfile stores arbitrary bytes: paths, tarball URLs, git SHAs). Separate fn try_str<'a>(&'a self, buf: &'a [u8]) -> Option<&'a str> does from_utf8 for callers needing text. #[repr(C)] #[derive(Copy, Clone, Pod, Zeroable)] pub struct ExternalString { value: SemverString, hash: u64 }. Both are bytemuck::Pod (no padding: 8×u8; and 8+8 with u64 align), so lockfile arrays load via bytemuck::cast_slice directly from the mmap'd file. Hash: expose pub fn string_hash(buf: &[u8]) -> u64 = Wyhash11 seed 0 (matching Builder.stringHash) for dedup-pool compatibility; keep a separate wyhash_legacy for ExternalString::from parity. const_assert!(size_of::<SemverString>() == 8 && align_of::<SemverString>() == 1); const_assert!(size_of::<ExternalString>() == 16 && align_of::<ExternalString>() == 8);
PERF: size_of::() == 8, align_of == 1; size_of::() == 16, align_of == 8 (matching Zig C ABI). slice() is branch + at most one bounds-checked index into buf — no allocation, no copy, no UTF-8 validation on the hot path. Loading Vec<ExternalString> / Vec<SemverString> from the binary lockfile is a zero-copy bytemuck::cast_slice over the file bytes (parity with Zig's @memcpy of raw arrays). Measure: round-trip a real bun.lock.bin and assert byte-identical output; benchmark slice() in a tight loop over Buffers.extern_strings against the Zig build (target: ≤1.0× wall time, 0 allocations).
SRC: /root/bun-5/src/semver/SemverString.zig (L2-8: pub const String = extern struct { bytes: [max_inline_len]u8 = [8]u8{0,...} } with max_inline_len = 8; comment documents three-state encoding via 'final bit'. No align(8) annotation — C ABI gives align 1.); /root/bun-5/src/semver/SemverString.zig (L434-436: pub const Pointer = extern struct { off: u32 = 0, len: u32 = 0 }. L453-455: pub inline fn ptr(this: String) Pointer { return @bitCast(@as(u64, @as(u63, @truncate(@as(u64, @bitCast(this)))))); } — explicit u63 truncate clears the tag bit before reinterpreting as (off,len). L389: external strings constructed via ... | 1 << 63.); /root/bun-5/src/semver/SemverString.zig (L461-469: pub fn slice(this: *const String, buf: string) string with comment 'String must be a pointer because we reference it as a slice. It will become a dead pointer if it is copied.' — confirms inline branch borrows from self, so Rust slice must borrow &'a self. Return type is string (= []const u8), not UTF-8-validated.); /root/bun-5/src/semver/SemverString.zig (L496-498: pub inline fn stringHash(buf: []const u8) u64 { return bun.Wyhash11.hash(0, buf); } — lockfile Builder dedup pool uses Wyhash11 seed 0.); /root/bun-5/src/semver/ExternalString.zig (L1-3: pub const ExternalString = extern struct { value: String = String{}, hash: u64 = 0 } — size 16, align 8 via the u64 field. L16-20: from() uses bun.Wyhash.hash(0, in) (distinct from Builder's Wyhash11).); /root/bun-5/src/install/lockfile/Buffers.zig (L10-13: extern_strings: ExternalStringBuffer ('underlying buffer used for any Semver.ExternalString instance in the lockfile') and string_bytes: StringBuffer ('where all non-inlinable Semver.Strings are stored') — the two backing arenas every SemverString/ExternalString indexes into.)
[bun_install::lockfile] install-arch
FACT: Lockfile.Package is an extern struct (fields in declaration order: name, name_hash, resolution, dependencies, resolutions, meta, bin, scripts) stored in bun.MultiArrayList(Package) — struct-of-arrays backed by ONE [*]align(@alignOf(Package)) u8 slab. The binary serializer iterates inline for (FieldsEnum.fields) where FieldsEnum = @typeInfo(meta.FieldEnum(Package)) — i.e. it writes/reads each field COLUMN in DECLARATION order (not alignment order; Serializer.sizes is only used by byteSize()). save() writes each column as one sliceAsBytes blob EXCEPT resolution, which is written per-element via val.copy() to zero the inactive union bytes. load() (non-migration path) does one list.ensureTotalCapacity(list_len) then for each column one @memcpy from the stream buffer — zero per-package parsing. The migrate_from_v2 path allocates a second temporary list and converts per-package.
RUST: Crate bun_install::lockfile. Define #[repr(C)] pub struct Package { name: SemverString, name_hash: u64, resolution: Resolution, dependencies: ExternalSlice<Dependency>, resolutions: ExternalSlice<PackageID>, meta: Meta, bin: Bin, scripts: Scripts } — every field type #[repr(C)]. Resolution is #[repr(C)] struct { tag: u8, _pad: [u8;7], value: ResolutionValue } where ResolutionValue is #[repr(C)] union; derive bytemuck::Zeroable + AnyBitPattern (NOT Pod — union has uninit bytes). Implement PackageList as a hand-rolled SoA over a single slab allocated via std::alloc::alloc(Layout::from_size_align(total_bytes, align_of::<Package>()).unwrap()) (NOT Vec<u8> — alignment 1 would make &[u64] reinterpretation UB), partitioned into per-field column slices. Hard-code on-disk column order as const FIELD_ORDER: [&str;8] = ["name","name_hash","resolution","dependencies","resolutions","meta","bin","scripts"] (declaration order, matching Zig's FieldsEnum.fields) and lock it with a static-assert against a golden hash. Serializer::load reads (len:u64, align:u64, field_count:u64, begin:u64, end:u64) header, validates align == align_of::<*const Package>() and field_count ∈ {8,7}, allocates the slab once, then for each column in FIELD_ORDER does ptr::copy_nonoverlapping from the file buffer. Serializer::save writes columns in FIELD_ORDER; for resolution it iterates per-element, copying each into a Resolution::zeroed() scratch (mirroring Zig's val.copy()) before writing, so inactive union bytes are zero. The v2-migration branch allocates a second temp list and converts per-element — single-allocation invariant applies only to the non-migration fast path.
PERF: Loading the package list (non-migration path) is O(field_count) memcpy's, not O(packages) parsing; allocation count for the SoA slab == 1. Measure: load a 50k-package bun.lockb, assert PackageList::load does exactly 1 heap alloc (jemalloc hook / GlobalAlloc counter) and ≤8 copy_nonoverlapping calls. Round-trip: Zig-written bun.lockb → Rust load → Rust save → byte-for-byte identical (diff exit 0). This validates declaration-order column layout AND zeroed Resolution union padding.
SRC: /root/bun-5/src/install/lockfile/Package.zig (L1-39: extern struct { name, name_hash, resolution, dependencies, resolutions, meta, bin, scripts } — declaration order is the on-disk column order); /root/bun-5/src/install/lockfile/Package.zig (L2018: pub const List = bun.MultiArrayList(PackageType); L2067: const FieldsEnum = @typeInfo(List.Field).@"enum" — declaration-order field enum); /root/bun-5/src/install/lockfile/Package.zig (L2091-2111 save(): inline for (FieldsEnum.fields) writes columns in declaration order; L2102-2107 writes resolution per-element via val.copy() to zero union padding; other columns via sliceAsBytes); /root/bun-5/src/install/lockfile/Package.zig (L2021-2073: Serializer.sizes sorts by alignment desc but is consumed ONLY by byteSize() — does NOT govern wire order); /root/bun-5/src/install/lockfile/Package.zig (L2163: list.ensureTotalCapacity(allocator, list_len) single slab alloc; L2219-2227 loadFields: inline for (FieldsEnum.fields) + @memcpy per column; L2166-2204: migrate_from_v2 branch allocates second list + per-package convert loop); /root/bun-5/src/collections/multi_array_list.zig (L21: bytes: [*]align(@alignOf(T)) u8 — slab is over-aligned, Rust must match via Layout::from_size_align, not Vec<u8>; L64: pub const Field = meta.FieldEnum(Elem) — declaration order); /root/bun-5/src/install/resolution.zig (L4-8: extern struct { tag: Tag, _padding: [7]u8, value: Value }; L451: Value = extern union; L256: copy() re-inits via zeroed Value — Rust must replicate per-element zeroing on save; cannot derive Pod)
[bun_install::lockfile::lockb] install-arch
FACT: bun.lockb is NOT mmapped: loadFromDir does file.readToEnd(allocator) into a heap []u8, then Buffers.load iterates sizes.names and for each section calls readArray, which reads (u64 start, u64 end), bounds/monotonicity-checks, reinterprets stream.buffer[start..end] via bytesAsSlice(T, ...) + @alignCast, and allocator.dupes into an owned ArrayList — one memcpy per section. HOWEVER two sections then undergo per-element conversion: trees are read as []Tree.External (a [external_size]u8) and looped through Tree.toTree (pure field-copy) into a fresh Tree.List; dependencies are read as []Dependency.External ([26]u8) and looped through Dependency.toDependency → Version.toVersion → Dependency.parseWithTag(ctx.allocator, ...), which RE-PARSES each version literal string (slicing into string_bytes) into pointer-bearing semver Query structures using the allocator. The in-file comment states why: "not using pointers for Semver Range's make the code a lot more complex". Header layout: "#!/usr/bin/env bun\n" ++ "bun-lockfile-format-v0\n" then u32 format, [32]u8 meta_hash (Sha512T256 digest), u64 total_size; trailing optional sections are gated by 8-byte ASCII tags (wOrKsPaC, tRuStEDd, oVeRriDs, pAtChEdD, cAtAlOgS, etc.).
RUST: Crate bun_install::lockfile::lockb. pub fn load_from_bytes(buf: &[u8], log: &mut Log) -> Result<Lockfile>: validate HEADER_BYTES, read u32 format / [u8;32] meta_hash / u64 total_size. For each section read (u64 start, u64 end), replicate the Zig checks (!=0xDEADBEEF, !=0, start>=prev_pos, start<=end<=buf.len()), then bytemuck::pod_collect_to_vec::<u8, TExternal>(&buf[start..end]) — NOT try_cast_slice(...).to_vec(), because buf is a Vec<u8> with alloc-align 1 and try_cast_slice returns TargetAlignmentGreaterAndInputNotAligned for any T with align>1; pod_collect_to_vec does an unaligned read into a freshly aligned Vec<T>, exactly mirroring Zig's @alignCast + dupe. Define #[repr(C)] #[derive(Pod, Zeroable)] ON-DISK types only: TreeExternal, DependencyExternal([u8;26]), VersionExternal([u8;9]), ExternalString, PackageID=u32, etc., each with a const _: () = assert_no_padding::<T>() mirroring padding_checker.zig. The IN-MEMORY Dependency and semver::Query are NOT Pod (they hold parsed range pointers); after the flat copies, run two explicit per-element passes: trees = tree_externals.iter().map(Tree::from_external).collect() (field copy) and deps = dep_externals.iter().map(|e| Dependency::from_external(e, &string_bytes, alloc, log)).collect() which calls Version::from_external → dependency::parse_with_tag(alloc, alias, alias_hash, literal, tag, sliced, log). File I/O via bun_sys::read_to_end. Do NOT introduce serde/bincode for the array stage — but do NOT pretend the dep stage is parse-free.
PERF: Two-phase cost model, must match Zig on both: (1) Flat-array stage: one pod_collect_to_vec (memcpy) per section — O(sections) allocations, O(file_size) bytes copied; assert with a counting allocator that this stage does ≤ sizes.names.len()+2 allocations regardless of lockfile size. (2) Conversion stage: O(tree_count) field-copies (no alloc) + O(dependency_count) calls to parse_with_tag, which may allocate semver range nodes — allocations and wall time scale linearly with dependency count, NOT bounded by a constant. Benchmark separately: phase-1 throughput ≈ memcpy bandwidth on a large lockfile; phase-2 ns/dependency must be ≤ Zig's toDependency loop on the same file (measure both with std.time.Timer / Instant::now around the loop, same input bytes). Regression gate: total load_from_bytes wall time on test/cli/install fixture lockfiles within ±5% of the Zig build.
SRC: /root/bun-5/src/install/lockfile.zig (L283: const buf = file.readToEnd(allocator).unwrap() — heap read, not mmap. L2157: const MetaHash = [std.crypto.hash.sha2.Sha512T256.digest_length]u8 — 32-byte meta_hash.); /root/bun-5/src/install/lockfile/Buffers.zig (L79-139 readArray: reads u64 start/end, checks 0xDEADBEEF/0/monotonic/end<=buf.len, then std.mem.bytesAsSlice(PointerType, stream.buffer[start..end]) + @alignCast + allocator.dupe. L290-355 load: trees branch reads Tree.External then per-element Tree.toTree(from) into fresh Tree.List.initCapacity; dependencies branch reads Dependency.External then per-element Dependency.toDependency(external_deps[0], extern_context) loop; comment L333-334: "Dependencies are serialized separately. This is unfortunate. However, not using pointers for Semver Range's make the code a lot more complex."); /root/bun-5/src/install/dependency.zig (L128-141 toDependency: unpacks name/name_hash/behavior from [26]u8 then calls Version.toVersion. L380-401 Version.External=[9]u8; toVersion slices literal from ctx.buffer and calls Dependency.parseWithTag(ctx.allocator, alias, alias_hash, sliced.slice, tag, sliced, ctx.log, ctx.package_manager) — full re-parse with allocator, per dependency.); /root/bun-5/src/install/lockfile/Tree.zig (L15: pub const External = [external_size]u8. L37: pub fn toTree(out: External) Tree — per-element field-copy conversion (no allocation).); /root/bun-5/src/install/lockfile/bun.lockb.zig (L3-4: version = "bun-lockfile-format-v0\n"; header_bytes = "#!/usr/bin/env bun\n" ++ version. L6-12: 8-byte section tags pAtChEdD/wOrKsPaC/tRuStEDd/eMpTrUsT/oVeRriDs/cAtAlOgS/cNfGvRsN as @bitCast([8]u8).)
FACT: PackageManager accumulates work into FIVE ThreadPool.Batch fields on the main thread: task_batch (CPU: manifest parse, extract), network_resolve_batch, network_tarball_batch, patch_apply_batch, patch_calc_hash_batch. scheduleTasks() bumps pending_tasks: std.atomic.Value(u32) by the total count, posts the three CPU batches (patch_apply, patch_calc_hash, task) to manager.thread_pool, then merges network_tarball_batch into network_resolve_batch via Batch.push (O(1) 3-pointer splice) and posts the combined batch to the global HTTP.http_thread singleton — a SINGLE dedicated OS thread running a jsc.MiniEventLoop (uSockets async-I/O reactor), NOT a worker pool. HTTPThread.schedule() drains the batch task-by-task, recovers each *AsyncHTTP via @fieldParentPtr("task", task), pushes it onto an intrusive lock-free MPSC UnboundedQueue(AsyncHTTP, .next), then calls loop.wakeup(). All five batches are then reset to {}.
RUST: Crate bun_install::manager: pub struct PackageManager { thread_pool: bun_threading::ThreadPool, task_batch: Batch, network_resolve_batch: Batch, network_tarball_batch: Batch, patch_apply_batch: Batch, patch_calc_hash_batch: Batch, pending_tasks: AtomicU32, ... }. fn schedule_tasks(&mut self) -> usize ports runTasks.zig L1128-1142 verbatim. The Batch type lives in bun_threading (intrusive head: Option<NonNull<Task>>, tail: Option<NonNull<Task>>, len: usize) and is re-used by bun_http, so network_resolve_batch.push(mem::take(&mut network_tarball_batch)) is a 3-pointer splice. Crate bun_http: pub static HTTP_THREAD: HttpThread is NOT a bun_threading::ThreadPool. It is struct HttpThread { loop: *UsLoop /* MiniEventLoop port */, queued_tasks: UnboundedQueue<AsyncHttp, {offset_of!(AsyncHttp, next)}>, has_awoken: AtomicBool, ... } backed by ONE std::thread::spawn running the uSockets reactor (multiplexes thousands of non-blocking sockets concurrently). pub fn schedule(&self, mut batch: Batch) does: if batch.len == 0 { return; } while let Some(task) = batch.pop() { let http = unsafe { container_of!(task, AsyncHttp, task) }; self.queued_tasks.push(http); } if self.has_awoken.load(Relaxed) { self.loop.wakeup(); } — exact port of HTTPThread.zig L702-716. The unsafe boundary is the container_of! cast (Task is an intrusive field of AsyncHttp); UnboundedQueue is the same intrusive MPSC used elsewhere in bun_threading.
PERF: schedule_tasks() performs ZERO heap allocations (all linkage is intrusive). Batch::push (the network merge) and ThreadPool::schedule (CPU batches → run_queue) are O(1) head/tail list splices. HttpThread::schedule is O(N_network) intrusive MPSC pushes + one wakeup() — matching Zig exactly; do NOT claim O(1) here. Critical: network tasks MUST land on the dedicated event-loop reactor thread, never on the CPU worker pool — modeling HttpThread as a 1-worker ThreadPool would serialize HTTP behind blocking callbacks and destroy cold-install throughput. Measure: instrumented allocator asserts 0 allocs across flush_network_queue() + schedule_tasks(); hyperfine 'bun install' cold-cache on a 1000-dep monorepo within ±2% of Zig; assert HttpThread drives >256 concurrent in-flight requests (proves it is not serialized).
SRC: /root/bun-5/src/install/PackageManager.zig (L48-70: thread_pool: ThreadPool, task_batch: ThreadPool.Batch, network_tarball_batch, network_resolve_batch, patch_apply_batch, patch_calc_hash_batch as Batch fields; pending_tasks: std.atomic.Value(u32) = .init(0)); /root/bun-5/src/install/PackageManager/runTasks.zig (L1128-1142: scheduleTasks() — incrementPendingTasks(count); three manager.thread_pool.schedule(...) calls; network_resolve_batch.push(network_tarball_batch); HTTP.http_thread.schedule(network_resolve_batch); five = .{} resets. L1080-1086: flushNetworkQueue routes by callback == .extract into tarball vs resolve batch); /root/bun-5/src/http.zig (L6: pub var http_thread: HTTPThread = undefined; — process-global singleton); /root/bun-5/src/http/HTTPThread.zig (L16: loop: *jsc.MiniEventLoop (uSockets reactor, NOT a thread pool). L20: queued_tasks: Queue. L208-215: std.Thread.spawn(..., onStart, ...) — exactly one dedicated OS thread. L702-716: schedule(batch) — while (batch_.pop()) |task| { const http: *AsyncHTTP = @fieldParentPtr("task", task); this.queued_tasks.push(http); } then this.loop.loop.wakeup() — O(N) drain into MPSC queue. L718: pub const Queue = UnboundedQueue(AsyncHTTP, .next)); /root/bun-5/src/threading/ThreadPool.zig (L104-149: Batch { len, head: ?*Task, tail: ?*Task }; push() is a 3-pointer tail splice (O(1)). L231-270: scheduleImpl builds Node.List{head,tail} and self.run_queue.push(list) — O(1) intrusive splice into the worker pool's run queue)
[bun_install::network] install-arch
FACT: NetworkTask supports streaming tarball extraction. It carries tarball_stream: ?*TarballStream (a Mutex-guarded double-buffer, NOT a lock-free ring), a pre-created streaming_extract_task: ?*Task, and streaming_committed: bool. notify() runs ON THE HTTP THREAD once per body chunk; on the first 2xx chunk whose Content-Length >= TarballStream.minSize() (or chunked encoding) it sets streaming_committed=true, calls stream.onChunk(chunk, false, null), and response_buffer.reset()s for the next chunk. onChunk does mutex.lock(); pending.appendSlice(allocator, chunk); mutex.unlock() (a copying append that may grow the heap buffer), then — guarded by draining: AtomicBool — schedules drain_task: ThreadPool.Task on package_manager.thread_pool. The worker recovers *TarballStream via @fieldParentPtr("drain_task", task) and swaps pending↔reading under the mutex so libarchive's pull-based read callback can hand out reading.items[read_pos..] lock-free. On the final chunk, the worker's finish() frees network_task.response_buffer, then pushes the pre-created extract_task (a *Task, not a ThreadPool.Task) onto manager.resolve_tasks for the MAIN thread, which returns the NetworkTask to preallocated_network_tasks — so once committed, notify() never enqueues to async_network_task_queue and must not touch this after the final onChunk. status_code is a plain non-atomic u32 field on TarballStream, written/read only by the HTTP thread inside notify(). Non-2xx / small / failed responses fall through to the buffered path: bytes accumulate in response_buffer and the task is pushed to async_network_task_queue for main-thread retry handling.
RUST: Crate bun_install::network. #[repr(C)] pub struct NetworkTask { unsafe_http_client: bun_http::AsyncHTTP, response: HTTPClientResult, task_id: TaskId, response_buffer: MutableString, callback: NetworkCallback, tarball_stream: Option<NonNull<TarballStream>>, streaming_extract_task: Option<NonNull<Task>>, streaming_committed: bool, next: Option<NonNull<NetworkTask>>, ... }. tarball_stream MUST be a raw Option<NonNull<_>>, NOT Box: the TarballStream holds a back-pointer network_task: *mut NetworkTask, is concurrently aliased by the worker pool via container_of on drain_task, and self-frees in deinit() via bun::destroy(self) — Box would violate noalias and double-free when the pooled NetworkTask is recycled. notify is unsafe extern "C" fn(this: *mut NetworkTask, async_http: *mut AsyncHTTP, result: HTTPClientResult) registered through HTTPClientResult::Callback::new::<NetworkTask, notify>(). #[repr(C)] pub struct TarballStream { mutex: parking_lot::Mutex<()>, pending: Vec<u8>, closed: bool, http_err: Option<anyhow::Error>, status_code: u32 /* plain, HTTP-thread-only */, draining: AtomicBool, reading: Vec<u8>, read_pos: usize, archive: Option<NonNull<lib::Archive>>, phase: Phase, drain_task: ThreadPoolTask, extract_task: NonNull<Task>, network_task: NonNull<NetworkTask>, package_manager: NonNull<PackageManager>, ... } — the mutex guards pending/closed/http_err and the swap with reading; everything else is single-writer (drain side). The drain callback uses an intrusive container_of!(task, TarballStream, drain_task) (unsafe pointer arithmetic, no Arc) and schedule_drain() does if draining.swap(true, AcqRel) { return; } pool.schedule(Batch::from(&mut self.drain_task)). Ownership hand-off is documented invariant only: after streaming_committed && !has_more, the HTTP thread treats *mut NetworkTask as moved; finish() on the worker is the sole owner and publishes to resolve_tasks last.
PERF: Per-chunk hot path = one mutex lock/unlock + one Vec::extend_from_slice (amortized realloc; capacity reused across swaps because pending/reading are swapped, not freed) + one AtomicBool::swap(AcqRel) + at most one ThreadPool::schedule. NO Arc refcount traffic, NO additional atomics beyond draining (status_code stays non-atomic). response_buffer is reset() not freed, so the HTTP client reuses the same allocation per chunk. Measure: BUN_INSTALL_STREAMING_MIN_SIZE=0 bun install on a cold cache of a large monorepo (e.g. next.js repo) and compare wall-clock + peak RSS vs Zig; assert ≤1% wall-clock delta and identical syscall counts under strace -c -e write,pwrite64.
SRC: /root/bun-5/src/install/NetworkTask.zig (L28 tarball_stream: ?*TarballStream = null; L31 streaming_extract_task: ?*Task = null; L39 streaming_committed: bool = false — raw nullable pointers, not owned boxes.); /root/bun-5/src/install/NetworkTask.zig (L48-144 notify(): L59-62 caches stream.status_code = m.response.status_code (plain store, HTTP thread); L71 reads it back; L72-77 size gate via TarballStream.minSize(); L80-99 commit path sets streaming_committed=true, stream.onChunk(chunk,false,null), response_buffer.reset(); L108-122 final-chunk hand-off with explicit comment that finish() may free response_buffer and return NetworkTask to preallocated_network_tasks before this returns; L144 fallback push to async_network_task_queue.); /root/bun-5/src/install/NetworkTask.zig (L352-358 getCompletionCallback() wraps notify via HTTP.HTTPClientResult.Callback.New(*NetworkTask, notify); schedule() delegates to unsafe_http_client.schedule(allocator, batch).); /root/bun-5/src/install/TarballStream.zig (L27-58 cross-thread state: mutex: Mutex, pending: ArrayListUnmanaged(u8), closed: bool, http_err: ?anyerror, status_code: u32 (plain), draining: std.atomic.Value(bool), reading: ArrayListUnmanaged(u8) — Mutex-guarded double-buffer swap, NOT lock-free SPSC ring.); /root/bun-5/src/install/TarballStream.zig (L113 drain_task: ThreadPool.Task = .{ .callback = &drainCallback }; L117-118 back-pointers extract_task: *Task, network_task: *NetworkTask; L173 bun.destroy(this) self-free in deinit().); /root/bun-5/src/install/TarballStream.zig (L180-196 onChunk: mutex.lock(); bun.handleOom(pending.appendSlice(allocator, chunk)); mutex.unlock(); scheduleDrain() — copying append that allocates; scheduleDrain() does if (draining.swap(true,.acq_rel)) return; thread_pool.schedule(Batch.from(&drain_task)).); /root/bun-5/src/install/TarballStream.zig (L198-200 drainCallback: @fieldParentPtr("drain_task", task) to recover *TarballStream — intrusive container_of, no refcounting.); /root/bun-5/src/install/TarballStream.zig (L772-816 finish(): runs on worker, network.response_buffer.deinit() (L786), then manager.resolve_tasks.push(task) (L816) — the pre-created extract Task is published to the MAIN-thread queue here, not scheduled on the worker pool.)
FACT: ExtractTarball.run() (executed on a ThreadPool worker via manager.task_batch → manager.thread_pool.schedule) verifies Integrity.verify(bytes) BEFORE any decompression, then calls extract(). extract() (1) acquires a MutableString buffer from Npm.Registry.BodyPool — an ObjectPool(MutableString, init2048, threadsafe=true, max_count=8) whose threadsafe=true path is implemented as a PER-THREAD threadlocal var freelist (no mutex, capped at 8 nodes); (2) reads the last 4 bytes of the .tgz as gzip ISIZE and, if 16 < ISIZE < 64MiB, preallocates the output buffer to that size; (3) attempts one-shot bun.libdeflate.Decompressor.gzip(tgz_bytes, prealloc_slice), falling back to streaming Zlib.ZlibReaderArrayList (vendored C zlib) on any failure; (4) hands the fully-decompressed in-memory tar to bun.libarchive.Archiver.extractToDir(). extractToDir does NOT use libarchive's C archive_read_data_into_fd; it uses a custom Zig readDataIntoFd that loops archive_read_data_block and writes each block via pwrite() first (falling back to lseek+write, then zero-fill), and on Linux calls bun.sys.preallocate_file (fallocate) for entries whose size > 1_000_000 bytes before writing.
RUST: Crate bun_install::extract. pub fn run(&self, log: &mut Log, tgz: &[u8]) -> Result<ExtractData>: integrity check → self.extract(log, tgz). extract(): let buf = body_pool::get(); defer body_pool::release(buf); → ISIZE peek (u32::from_le_bytes(tgz[len-4..]), cap 64MiB) → bun_libdeflate::Decompressor::gzip(tgz, buf.spare_capacity_mut()) else fallback to bun_zlib::GzipReader (FFI over the SAME vendored zlib-ng/zlib C library Zig uses — NOT miniz_oxide; if using flate2, enable the zlib/zlib-ng C-backend feature) → bun_libarchive::Archiver::extract_to_dir(&buf, dest_fd, opts).
bun_libarchive FFI surface: archive_read_new, archive_read_support_format_tar, archive_read_support_format_gnutar, archive_read_open_memory, archive_read_next_header, archive_read_data_block (NOT archive_read_data_into_fd). Port readDataIntoFd as fn read_data_into_fd(a: *mut Archive, fd: BorrowedFd, can_pwrite: &mut bool, can_lseek: &mut bool) -> ArchiveResult that iterates archive_read_data_block and writes via libc::pwrite first, falling back to lseek+write then zero-fill, with a final ftruncate to final_offset. On #[cfg(target_os="linux")], call libc::fallocate(fd, 0, 0, size) before draining when entry size > 1_000_000.
body_pool lives in bun_collections::object_pool and is a lock-free per-thread freelist matching src/pool.zig: thread_local! { static BODY_POOL: Cell<Option<Box<PoolNode>>> = const { Cell::new(None) }; } with an intrusive singly-linked list of PoolNode { next: Option<Box<PoolNode>>, data: Vec<u8> }, get() pops or allocates (init capacity 2048), release() pushes only if count < 8. No Mutex, no global state. All types here are Send; no JSC types appear anywhere in this crate.
PERF: Three measurable invariants vs Zig: (1) strace -fc -e trace=pwrite64,write,lseek,fallocate bun install on a fixed 1000-tarball corpus must show the SAME syscall mix — pwrite-dominant file writes, fallocate present for >1MB entries, no extra lseek per block (regression indicator: write+lseek pairs replacing pwrite). (2) perf lock / contention profiling during parallel extraction across N=nproc workers must show ZERO lock acquisitions in body_pool::get/release (any mutex there is a regression). (3) End-to-end wall time for bun install --force on the corpus within ±3% of Zig build, AND the zlib-fallback path (force via corrupted ISIZE) must match Zig's vendored-zlib throughput ±5% (guards against miniz_oxide slipping in).
SRC: /root/bun-5/src/install/extract_tarball.zig (L13-26: run() checks this.integrity.verify(bytes) and returns error.IntegrityCheckFailed BEFORE calling this.extract(log, bytes).); /root/bun-5/src/install/extract_tarball.zig (L195-251: Npm.Registry.BodyPool.get/release; ISIZE read from last 4 bytes with 16<x<64MiB cap; bun.libdeflate.Decompressor.alloc() + .gzip(tgz_bytes, allocatedSlice()) fast-path; on miss, Zlib.ZlibReaderArrayList.init(...).readAll(true) fallback.); /root/bun-5/src/install/extract_tarball.zig (L273-313: Archiver.extractToDir(zlib_pool.data.list.items, extract_destination, ...) — libarchive consumes the fully-decompressed in-memory buffer.); /root/bun-5/src/install/npm.zig (L198-199: pub const BodyPool = ObjectPool(MutableString, MutableString.init2048, true, 8); — threadsafe=true, max_count=8.); /root/bun-5/src/pool.zig (L110-145: ObjectPool(..., threadsafe=true, ...) stores its freelist in threadlocal var data_threadlocal: DataStruct and data() returns &data_threadlocal — per-thread, lock-free, NOT a global mutex.); /root/bun-5/src/libarchive/libarchive-bindings.zig (L680 declares extern fn archive_read_data_into_fd but it is never called; L697-734 define custom readDataIntoFd that loops archive_read_data_block (via archive.next) and writes with file.pwriteAll(data, block.offset) first, falling back to lseek+write then zero-fill.); /root/bun-5/src/libarchive/libarchive.zig (L603-616: inside extractToDir, on Linux when entry size > 1_000_000 it calls bun.sys.preallocate_file(file_handle.cast(), 0, size) (fallocate) before archive.readDataIntoFd(file_handle, &use_pwrite, &use_lseek).); /root/bun-5/src/install/PackageManager/runTasks.zig (L576 pushes the extract task into manager.task_batch; L1134 manager.thread_pool.schedule(manager.task_batch) — extraction runs on ThreadPool workers.)
Bundler architecture → crate bun_bundler
[bun_ast::store] BUNDLER-03
FACT: AST nodes (E._ and S._ payloads) are NOT allocated via the general allocator. They go through Expr.Data.Store / Stmt.Data.Store, each a NewStore([types], 512): a threadlocal linked-list of fixed Blocks where Block.size = largest_size * 512 * 2 bytes. append(T, value) is a bump-pointer alloc inside the current block (alignForward + pointer write, no header). reset() rewinds current to the first block and zeroes bytes_used WITHOUT freeing blocks — memory is reused across files.
RUST: Crate bun_ast::store: pub struct NodeStore<const BLOCK_BYTES: usize> { current: NonNull<Block>, first: NonNull<Block> } with #[repr(C, align(16))] struct Block { buf: [MaybeUninit<u8>; BLOCK_BYTES], used: u32, next: Option<NonNull<Block>> }. #[inline(always)] pub fn alloc<T>(&mut self, v: T) -> &'store mut T does align_offset + ptr::write; cold path #[cold] fn new_block(). Expose pub fn reset(&mut self) that walks next chain setting used=0 (debug: memset 0xAA). Thread-local entry: thread_local! { static EXPR_STORE: RefCell<Option<NodeStore<..>>>; static STMT_STORE: ... }. Unsafe boundary: returned &mut T is transmuted to 'store lifetime tied to the Worker arena, NOT 'static — callers receive *mut T cast into NonNull<T> stored in the Expr/Stmt enum.
PERF: alloc<T> must compile to ≤ ~6 instructions on hot path (load used, align, cmp, store, add, ret) — verify with cargo asm. Block memory must be reused: parsing N files sequentially on one worker must allocate O(max_file_ast_size) blocks, not O(sum). Measure peak RSS on bun build of three.js: Rust ≤ Zig + 5%.
SRC: /root/bun-5/src/ast/NewStore.zig (lines 9-45: Block { buffer: [size]u8 align(largest*align), bytes_used, next }, size = largest_size * count _ 2; tryAlloc does alignForward + bounds check); /root/bun-5/src/ast/NewStore.zig (lines 100-115: reset() sets store.current = firstBlock(); current.bytes_used = 0 — does NOT free linked blocks); /root/bun-5/src/ast/Expr.zig (lines 3151-3183: NewStore(&.{E.Array, E.Arrow, ...}, 512) with threadlocal var instance: ?*StoreType and threadlocal var memory_allocator)
[bun_ast::store] BUNDLER-04
FACT: During bundling, the threadlocal NewStore is BYPASSED: Worker.get() calls ast_memory_allocator.push() which sets Expr.Data.Store.memory_allocator = &worker.ast_memory_allocator. When memory_allocator != null, Store.append() delegates to ASTMemoryAllocator.append() which allocates from the worker's mimalloc ThreadLocalArena (via StackFallbackAllocator). This means AST nodes for bundled files live in the WORKER'S mimalloc heap, not the NewStore blocks — so they survive across files and are readable from the linker thread until the worker heap is destroyed at end-of-bundle.
RUST: Crate bun_ast::store: pub struct AstMemoryAllocator { bump: StackFallback<8192, &'worker MimallocHeap>, prev: Option<NonNull<AstMemoryAllocator>> }. Thread-local override: thread_local! { static AST_ALLOC_OVERRIDE: Cell<Option<NonNull<AstMemoryAllocator>>> }. Store::append<T> checks override first: if let Some(a) = AST_ALLOC_OVERRIDE.get() { unsafe { a.as_ref() }.alloc(v) } else { instance.alloc(v) }. Worker::get() returns RAII guard that sets/restores the override. Crate bun_bundler depends on bun_ast and is the only crate that constructs AstMemoryAllocator backed by a worker heap.
PERF: The override check must be a single TLS load + null-check (no branch mispredict in steady state since it's always Some during bundling). AST node pointers allocated on worker N must be dereferenceable from the bundle/linker thread WITHOUT copying — verify with ASAN that linker reads of ast.parts[i].stmts[j].data do not fault. No deep-clone of AST when transferring to linker.
FACT: ParseTask is scheduled per file with TWO intrusive task nodes (task for parse, io_task for read) and a stage union. On worker: read source (or skip if .contents), parse into JSAst using worker allocator, then heap-allocate ONE ParseTask.Result via bun.default_allocator (NOT worker arena) containing Success{ ast: JSAst, source, log, ... } and post it to the bundle thread's event loop via enqueueTaskConcurrent (lock-free MPSC). The Result wrapper is freed on the bundle thread; the JSAst's interior pointers still point into the worker heap and are moved by-value into graph.ast.
RUST: Crate bun_bundler::parse: #[repr(C)] pub struct ParseTask { path: FsPath, contents_or_fd: ContentsOrFd, source_index: Index, stage: Stage, task: bun_threading::IntrusiveTask, io_task: bun_threading::IntrusiveTask, ctx: *mut BundleV2, ... }. pub enum ParseResultValue { Success(ParseSuccess), Err(ParseError), Empty{source_index: Index} }. ParseSuccess { ast: BundledAst, source: Source, log: Log, ... } — BundledAst is a POD struct of slices/BabyLists whose backing memory is in worker arenas; mark it unsafe impl Send (raw pointers, arena-owned). Result is Box<ParseResult> allocated from global allocator, sent via bun_threading::UnboundedQueue<ParseResult> (intrusive MPSC). Bundle thread does graph.ast[idx] = result.ast (move, no clone).
PERF: Exactly 1 global-allocator allocation per parsed file for the Result envelope (verify with allocator counter). Zero deep-copy of AST node graphs across the worker→bundle boundary — only the ~200-byte BundledAst header + import_records.clone() are copied. Measure: heaptrack shows allocation count during scan phase ≈ O(files), not O(nodes).
SRC: /root/bun-5/src/bundler/ParseTask.zig (lines 13-28: task + io_task intrusive ThreadPoolLib.Task fields, stage: ParseTaskStage); /root/bun-5/src/bundler/ParseTask.zig (lines 1404-1432: bun.default_allocator.create(Result) then enqueueTaskConcurrent(...onComplete) / mini.enqueueTaskConcurrentWithExtraCtx); /root/bun-5/src/bundler/bundle_v2.zig (lines 4149-4296: onParseTaskComplete: defer bun.default_allocator.destroy(parse_result); graph.ast.set(result.source.index.get(), result.ast) — moves BundledAst by value into MultiArrayList)
[bun_bundler::graph] BUNDLER-07
FACT: Graph stores input_files: MultiArrayList(InputFile) and ast: MultiArrayList(JSAst) indexed by Index (a packed struct(u32)). MultiArrayList gives Struct-of-Arrays layout so the linker can do graph.ast.items(.import_records) / .items(.parts) / .items(.flags) and iterate one column without touching others. BundledAst (JSAst) is a slimmed Ast specifically because 'the hottest function in the bundler is MultiArrayList(Ast).ensureTotalCapacity' — fewer/smaller fields = cheaper resize.
PERF: Column access ast.items(.field) must be a single base+offset slice, no per-element pointer chase. ensureTotalCapacity on Graph.ast for 10k files must do ≤ log2(10k) reallocs. Benchmark: link phase iteration over ast.items(.import_records) for 10k files: Rust throughput ≥ Zig (perf stat cache-misses within 5%).
SRC: /root/bun-5/src/bundler/Graph.zig (lines 7-14: input_files: MultiArrayList(InputFile), ast: MultiArrayList(JSAst)); /root/bun-5/src/ast/BundledAst.zig (lines 1-8: comment 'hottest function is MultiArrayList(Ast).ensureTotalCapacity ... so we make a slimmer version'); /root/bun-5/src/ast/BundledAst.zig (lines 53-70: Flags = packed struct(u16) with 9 bools + padding); /root/bun-5/src/ast/base.zig (lines 25-45: Index = packed struct(u32) { value: Int }, invalid = maxInt(Int))
[bun_collections] BUNDLER-10
FACT: BabyList(T) is the ubiquitous growable-slice type in the bundler/AST: { ptr: [*]T, len: u32, cap: u32 } = 16 bytes (vs 24 for std ArrayList). It carries NO allocator pointer in release; every mutation takes an explicit allocator parameter. Part.List, Symbol.List, ImportRecord.List, etc. are all BabyList. This 8-byte saving multiplies across the SoA MultiArrayList columns.
RUST: Crate bun_collections: #[repr(C)] pub struct BabyList<T> { ptr: NonNull<T>, len: u32, cap: u32 } (16 bytes, static_assert). unsafe impl<T: Send> Send for BabyList<T>. Methods take &impl Allocator explicitly: pub fn push(&mut self, a: &impl Allocator, v: T). No Drop impl — freeing is the arena's job (matches Zig's leak-on-arena-destroy model). Provide From<Vec<T>> and .slice()/.slice_mut(). In debug builds add #[cfg(debug_assertions)] origin: Origin for borrow tracking like Zig's #origin.
PERF: size_of::<BabyList>() == 16. MultiArrayList<BundledAst> row stride must match Zig's (sum of column sizes). Measure: size_of::<BundledAst>() in Rust == Zig @sizeOf(BundledAst) (dump both, compare in CI).
SRC: /root/bun-5/src/collections/baby_list.zig (lines 1-22: 'like ArrayList except length and capacity as u32'; fields ptr: [*]Type, len: u32, cap: u32 (+ debug-only #origin/#allocator)); /root/bun-5/src/ast/BundledAst.zig (lines 16-40: parts: Part.List, symbols: Symbol.List, import_records: ImportRecord.List, export_star_import_records: []u32 — all slim list types)
[bun_bundler] BUNDLER-11
FACT: Scan-phase concurrency control is a single pending_items: u32 counter on Graph (not atomic — only mutated on the bundle thread). Workers never touch it; they post Results to the bundle thread's event loop, and onParseTaskComplete (running on bundle thread under thread_lock) decrements it and may enqueue more ParseTasks discovered via import resolution. waitForParse() spins loop().tick() until pending_items == 0 && deferred_pending == 0.
RUST: Crate bun_bundler: Graph { pending_items: u32, deferred_pending: u32 } plain integers (NOT AtomicU32). Bundle thread runs an event loop (bun_runtime::EventLoop) that drains an MPSC UnboundedQueue<ParseResult>; on_parse_complete(&mut self, r: Box<ParseResult>) runs single-threaded, does resolution, pushes new ParseTasks to the worker pool, adjusts pending_items. wait_for_parse(&mut self) = self.loop_.tick_until(|| self.is_done()). Debug-only ThreadLock (like Zig's bun.safety.ThreadLock) asserts single-thread access.
PERF: No atomic RMW on pending_items hot path (it's touched once per file). Result delivery latency: worker→bundle-thread enqueue must be lock-free push (1 CAS). Measure: perf shows zero contention on Graph fields; scan-phase scaling near-linear up to 8 cores on Linux (match Zig's curve).
SRC: /root/bun-5/src/bundler/Graph.zig (lines 22-34: pending_items: u32, deferred_pending: u32 with doc 'Increment and decrement via incrementScanCounter...' — plain u32, no atomic); /root/bun-5/src/bundler/bundle_v2.zig (lines 474-491: isDone() checks pending_items==0 then drainDeferredTasks; waitForParse() = loop().tick(this, &isDone)); /root/bun-5/src/bundler/bundle_v2.zig (lines 4168-4175: onParseTaskComplete adjusts graph.pending_items with plain integer arithmetic under thread_lock assertion)
[bun_bundler] bundler-arch
FACT: BundleV2 is the top-level orchestrator owning graph: Graph and linker: LinkerContext by value (bundle_v2.zig:107-117). The bundle-thread arena is a ThreadLocalArena (mimalloc heap) that is PASSED INTO BundleV2::init by the caller (bundle_v2.zig:897-905, doc comment: "heap is not freed when deinit'ing the BundleV2") and stored at graph.heap (Graph.zig:4); BundleV2.allocator() and LinkerContext.allocator() both return this same heap's erased std.mem.Allocator handle (bundle_v2.zig:928,1005-1007; LinkerContext.zig:51-53), so all bundle-thread bookkeeping (input_files/ast MultiArrayLists, LinkerGraph, the *ThreadPool itself at graph.pool) lives in one arena destroyed wholesale at end of build. Separately, each ThreadPool.Worker owns its OWN heap: ThreadLocalArena created on its thread (ThreadPool.zig:205-287) — AST nodes are allocated on per-worker heaps, not the bundle-thread heap; cross-thread READS of worker-heap data are allowed but cross-thread ALLOCATION is forbidden (bundle_v2.zig:19-20).
RUST: Crate bun_bundler. Mirror Zig's erased allocator handle exactly to avoid self-referential lifetimes: #[derive(Copy, Clone)] pub struct BundleAlloc(NonNull<mi_heap_t>) — a Send raw-pointer handle wrapping a mimalloc heap (NOT &'a Bump; bumpalo is !Sync and a borrowed reference would force self-ref). Ownership: pub struct MiHeap(/* owns mi_heap_t, Drop = mi_heap_destroy */) with fn handle(&self) -> BundleAlloc. Layout matches Zig field placement: pub struct Graph { heap: MiHeap, pool: *mut ThreadPool /* arena-allocated */, input_files: MultiArrayList<InputFile>, ast: MultiArrayList<JSAst>, ... }; pub struct LinkerGraph { allocator: BundleAlloc, ... }; pub struct LinkerContext { parse_graph: *mut Graph, graph: LinkerGraph, ... }; pub struct BundleV2 { transpiler: *mut Transpiler, graph: Graph, linker: LinkerContext, ... }. BundleV2::init(transpiler, ..., heap: MiHeap) -> Box<BundleV2> heap-allocates the struct (Zig does alloc.create(BundleV2), line 909), moves heap into graph.heap, then copies graph.heap.handle() into linker.graph.allocator — copying a raw pointer into a sibling field of a boxed (address-stable) struct, no 'self borrow, no Pin/ouroboros needed. ThreadPool is created via BundleAlloc and stored at graph.pool (matches bundle_v2.zig:992-1000). Per-worker heaps live in struct Worker { heap: MiHeap, allocator: BundleAlloc, ast_memory_allocator: ASTMemoryAllocator, ... } (matches ThreadPool.zig:205-217). Unsafe boundary: BundleAlloc alloc/free methods are unsafe fn documented "must only be called on the thread that created the underlying mi_heap"; reads of arena-allocated data from other threads go through raw *const T / index-based access (no &-aliasing across threads). No per-node Drop anywhere — MiHeap::drop is the only free.
PERF: Zero per-node free: total mi_free calls during a bundle MUST be O(1) (heap-destroy only), never O(nodes). Bundle-thread bookkeeping (Graph/LinkerGraph growth) and per-worker AST allocation MUST each hit a single mi_heap_t with no locking. Measure: (1) mi_stats_print / mi_heap_visit_blocks after bundling three.js x10 shows all blocks freed via mi_heap_destroy, not individual mi_free; (2) DHAT or cargo bench bundling three.js x10 — wall time and peak RSS within ±2% of Zig build on same hardware; (3) assert BundleAlloc is 1 pointer wide (size_of::<BundleAlloc>() == size_of::<usize>()) so passing it matches Zig's std.mem.Allocator cost.
SRC: /root/bun-5/src/bundler/bundle_v2.zig (lines 11-20, 30: header — "Bun's bundler relies on mimalloc's threadlocal heaps as arena allocators... When the job is done, the threadlocal heap is destroyed and all memory is freed"; "A threadlocal heap cannot allocate memory on a different thread than the one that created it"; "LinkerContext's allocator is also threadlocal"); /root/bun-5/src/bundler/bundle_v2.zig (lines 107-117: pub const BundleV2 = struct { transpiler: *Transpiler, ..., graph: Graph, linker: LinkerContext, ... } — owns Graph and LinkerContext by value, no direct heap/pool field); /root/bun-5/src/bundler/bundle_v2.zig (lines 897-1007: doc "heap is not freed when deiniting the BundleV2"; init(..., heap: ThreadLocalArena) does alloc.create(BundleV2) (909), .graph.heap = heap (920), .linker.graph.allocator = heap.allocator() (928), this.allocator().create(ThreadPool) (992), this.graph.pool = pool (1000); pub fn allocator() (1005) returns this.graph.heap.allocator()); /root/bun-5/src/bundler/Graph.zig (lines 3-14: pool: *ThreadPool, heap: ThreadLocalArena, entry_points: ArrayListUnmanaged(Index), input_files: MultiArrayList(InputFile), ast: MultiArrayList(JSAst) — Graph OWNS the heap and holds the pool pointer); /root/bun-5/src/bundler/LinkerContext.zig (lines 9-10, 51-53: parse_graph: *Graph, graph: LinkerGraph; pub fn allocator() returns this.graph.allocator — same mimalloc heap as BundleV2.allocator()); /root/bun-5/src/bundler/ThreadPool.zig (lines 205-301: Worker = struct { heap: ThreadLocalArena, allocator: std.mem.Allocator, ctx: *BundleV2, ast_memory_allocator: ASTMemoryAllocator, ... }; create() does this.heap = ThreadLocalArena.init(); this.allocator = this.heap.allocator() (286-287); deinit() calls this.heap.deinit() (240) — per-worker arena, separate from bundle-thread arena)
[bun_ast] ast-expr-stmt-size-layout
FACT: Expr is a 32-byte value type { loc: logger.Loc, data: Data } where logger.Loc is { start: i32 } (4B) and Expr.Data is a tagged union comptime-asserted to be EXACTLY 24 bytes (bun.assert_eql(@sizeOf(Data), 24)); 4B loc + 4B pad + 24B Data = 32B. Stmt is a ≤24-byte value type (whole struct checked via if (@sizeOf(Stmt) > 24) @compileLog(...)). Both Data unions mix 8-byte *E.Foo/*S.Foo pointers (backed by the per-thread Store arena) with INLINE small variants to avoid allocation+indirection: Expr.Data inlines E.Identifier (Ref packed u64 + 3 bools ≈ 16B payload — this is what forces Data to 24B), E.ImportIdentifier, E.Boolean, E.Number (f64), E.Missing/E.Null/etc.; Stmt.Data's only inline variants are zero-sized (S.Empty, S.TypeScript, S.Debugger), so Stmt.Data ≈ 16B and Stmt fits in 24B. Expr/Stmt are passed and stored BY VALUE (Copy semantics) throughout parser/printer/visitor; ownership of boxed payloads stays with the Store, not the Expr/Stmt holding the pointer.
PERF: size_of::<ExprData>() == 24, size_of::<Expr>() == 32, size_of::<Stmt>() <= 24 — enforced by const _: () = assert!(...) so any variant addition that bloats the union fails to compile, exactly as Zig's comptime asserts do today. No heap allocation for Identifier/Number/Boolean/Missing/Empty (verify: zero Store::append calls when constructing these — add a #[cfg(test)] Store allocation counter and assert it stays 0 for a script of N identifiers/literals). Expr/Stmt remain Copy so Vec<Expr>/BabyList<Expr> are flat contiguous 32B/24B records (measure: Vec<Expr>::capacity() * 32 == allocated bytes). Do NOT gate on register passing — 32B/24B aggregates are passed in memory on SysV x86-64 and by indirect reference on AAPCS64 in both Zig and Rust; the invariant is memory density and zero-alloc inline variants, not ABI register count.
SRC: /root/bun-5/src/ast/Expr.zig (lines 1-2: loc: logger.Loc, data: Data,; lines 2132-2192: pub const Data = union(Tag) { e_array: *E.Array, e_unary: *E.Unary, e_binary: *E.Binary, ... e_identifier: E.Identifier, e_import_identifier: E.ImportIdentifier, ... e_boolean: E.Boolean, e_number: E.Number, ... e_missing: E.Missing, e_this: E.This, ... } mixing *E.Foo pointers with inline small variants; line 2191: comptime { bun.assert_eql(@sizeOf(Data), 24); // Do not increase the size of Expr } — asserts Data (not Expr) is 24 bytes.); /root/bun-5/src/ast/Stmt.zig (lines 1-2: loc: logger.Loc, data: Data,; lines 257-297: pub const Data = union(Tag) { s_block: *S.Block, ... s_with: *S.With, s_type_script: S.TypeScript, s_empty: S.Empty, s_debugger: S.Debugger, s_lazy_export: *Expr.Data, comptime { if (@sizeOf(Stmt) > 24) { @compileLog("Expected Stmt to be <= 24 bytes...") } } } — every payload is an 8-byte pointer or zero-size, so whole Stmt ≤ 24B.); /root/bun-5/src/logger.zig (lines 45-46: pub const Loc = struct { start: i32 = -1, ... } — 4 bytes, align 4. Combined with 24B align-8 Data → @sizeOf(Expr) = 4 + 4 pad + 24 = 32 bytes. (grep confirms no @sizeOf(Expr) assertion exists anywhere in src/.)); /root/bun-5/src/ast/base.zig (line 97: pub const Ref = packed struct(u64) { inner_index: u31, tag: enum(u2){...}, source_index: u31 } — 8 bytes.); /root/bun-5/src/ast/E.zig (lines 283-301: pub const Identifier = struct { ref: Ref, must_keep_due_to_with_stmt: bool, can_be_removed_if_unused: bool, call_can_be_unwrapped_if_unused: bool } — 8B Ref + 3 bools → 16B align-8 payload; with union tag/padding this is the variant that drives @sizeOf(Expr.Data) == 24.)
[bun_ast::symbol] ref-packed-u64-wyhash
FACT: Ref is packed struct(u64) { inner_index: u31, tag: enum(u2){invalid,allocated_name,source_contents_slice,symbol}, source_index: u31 } — an 8-byte Copy handle used as the key for NamedImports, Part.SymbolUseMap, TopLevelSymbolToParts, ConstValuesMap, TsEnumsMap (all Ref-keyed ArrayHashMaps). NamedExports is NOT Ref-keyed; it is bun.StringArrayHashMapUnmanaged(NamedExport) keyed by export-name string. Ref.None = all-zero bits with tag=.invalid (asU64()==0). Equality is a single u64 compare (asU64()==asU64()), but hashing is NOT identity: hash64() = bun.hash(&[8]u8 bitcast) = std.hash.Wyhash.hash(0, ...) over the 8 bytes, and hash() truncates that to u32. Zig deliberately mixes because during single-file parsing every Ref shares the same source_index (the high bits), so identity hashing would cluster.
RUST: Crate bun_ast, module symbol: #[derive(Copy, Clone, Eq)] #[repr(transparent)] pub struct Ref(u64); with const _: () = assert!(size_of::<Ref>() == 8);. Bit accessors mirror Zig packed-struct LSB-first field order: pub const fn inner_index(self) -> u32 { (self.0 & 0x7FFF_FFFF) as u32 }, pub const fn tag(self) -> RefTag { unsafe { transmute(((self.0 >> 31) & 0b11) as u8) } }, pub const fn source_index(self) -> u32 { (self.0 >> 33) as u32 }. pub const NONE: Ref = Ref(0);. impl PartialEq for Ref { fn eq(&self, o: &Self) -> bool { self.0 == o.0 } }. Hashing: do NOT use nohash_hasher/identity — impl Hash for Ref { fn hash<H: Hasher>(&self, h: &mut H) { h.write_u64(self.0) } } and pair with a mixing BuildHasher (either wyhash::WyHash for bit-exact parity, or rustc_hash::FxBuildHasher for a single-multiply mix that disperses the constant high source_index bits across hashbrown's H2 control byte). Type aliases: pub type RefMap<V> = IndexMap<Ref, V, FxBuildHasher>; used for named_imports: RefMap<NamedImport>, symbol_uses: RefMap<SymbolUse>, top_level_symbols_to_parts: RefMap<BabyList<u32>>, const_values, ts_enums. named_exports lives separately as IndexMap<Atom, NamedExport, AtomHasher> (string-keyed) — never Ref-keyed.
PERF: size_of::<Ref>() == 8, Copy, passed in a single register. Ref-keyed IndexMap lookups must use a constant-time u64 mixer (Wyhash or Fx multiply), never SipHash and never identity — identity would put source_index high bits into hashbrown's 7-bit H2 control byte, degenerating SIMD probes to linear compares when all symbols share one source*index during parsing. Measure: cargo bench insert+lookup of 100k sequential Ref{inner=0..100k, tag=symbol, source=42} into IndexMap<Ref,u32,*>must be within ±5% of ZigArrayHashMapUnmanaged(Ref, u32, RefHashCtx, false) on same dataset; assert hashbrown probe length p99 ≤ 2.
SRC: /root/bun-5/src/ast/base.zig (lines 97-112: pub const Ref = packed struct(u64) { inner_index: Int = 0, tag: enum(u2){invalid,allocated_name,source_contents_slice,symbol}, source_index: Int = 0 } with Int = u31; pub const None = Ref{.inner_index=0,.source_index=0,.tag=.invalid}; line 118-120 isEmpty() => asU64()==0); /root/bun-5/src/ast/base.zig (lines 194-208: hash() truncates hash64(); hash64() = bun.hash(&@as([8]u8, @bitCast(key.asU64()))) (Wyhash, NOT identity); eql() => ref.asU64() == other.asU64() (single u64 compare). Lines 3-21: RefHashCtx.hash → key.hash(), RefCtx.hash → key.hash64() — both route through Wyhash); /root/bun-5/src/bun.zig (lines 483-485: pub fn hash(content: []const u8) u64 { return std.hash.Wyhash.hash(0, content); } — confirms Ref.hash64 is Wyhash-seeded-0 over 8 bytes); /root/bun-5/src/ast/Ast.zig (line 1: TopLevelSymbolToParts = std.ArrayHashMapUnmanaged(Ref, BabyList(u32), Ref.ArrayHashCtx, false); line 80: NamedImports = std.ArrayHashMapUnmanaged(Ref, NamedImport, RefHashCtx, true); line 81: NamedExports = bun.StringArrayHashMapUnmanaged(NamedExport) — STRING-keyed, not Ref; lines 82-83: ConstValuesMap/TsEnumsMap are Ref-keyed); /root/bun-5/src/ast.zig (line 532: pub const SymbolUseMap = std.ArrayHashMapUnmanaged(Ref, Symbol.Use, RefHashCtx, false); line 490: symbol_uses: SymbolUseMap on Part); /root/bun-5/src/bundler/LinkerGraph.zig (lines 49-70: generateNewSymbol builds Ref.init(@truncate(source_symbols.len), @truncate(source_index), false) then ref.tag = .symbol — inner_index is dense per-source, source_index is constant per file (motivates mixing hash))
FACT: LinkerGraph.ast is a SHALLOW CLONE of the scan-phase Graph.ast, not an alias (the comment at LinkerGraph.zig:14-15 is stale): cloneAST() calls this.graph.ast.clone(this.allocator()), and std MultiArrayList.clone allocates fresh column storage and @memcpys every field column. cloneAST() additionally deep-clones each module_scope.generated BabyList onto the linker allocator, and LinkerGraph.load() deep-clones every per-source symbols BabyList onto the linker allocator before any append — so generateNewSymbol() grows linker-owned buffers (the TODO at :59 is stale). Only inner BabyLists reached through the shallow-copied columns (e.g. ast.items(.parts)[id] grown in addPartToFile, part.symbol_uses, part.dependencies) still point into per-worker MimallocArena heaps and are appended-to with the linker allocator; takeAstOwnership() is a debug-only ownership-tracking pass (early-returns when baby_list.safety_checks == Environment.ci_assert is off) and does not reallocate in release, so those specific appends rely on mimalloc supporting cross-heap realloc/free.
RUST: Crate bun_bundler::linker: pub struct LinkerGraph { files: MultiArrayList<File>, files_live: BitSet, symbols: SymbolMap, ast: MultiArrayList<BundledAst>, meta: MultiArrayList<JSMeta>, reachable_files: Vec<Index>, stable_source_indices: Box<[u32]>, allocator: ArenaHandle }. ast is OWNED by LinkerGraph (populated by MultiArrayList::shallow_clone(&graph.ast, alloc) which allocates fresh column buffers and ptr::copy_nonoverlappings each column — inner BabyList ptr/len/cap are byte-copied and still reference worker-arena storage), NOT a &'g mut borrow of Graph.ast (Graph.ast must remain untouched for DevServer/incremental reuse). In clone_ast(), additionally deep-clone each module_scope.generated: BabyList<Ref> into the linker arena. In LinkerGraph::load(), build symbols: SymbolMap by deep-cloning every per-source BabyList<Symbol> into the linker arena up front. generate_new_symbol() then appends to linker-owned Vec<Symbol>/BabyList<Ref> — no cross-thread allocator invariant needed there. The remaining cross-heap growth (add_part_to_file growing parts: BabyList<Part>, mutating part.symbol_uses/part.dependencies) lives in bun_collections::BabyList<T>, which stores no allocator handle in release and calls mi_realloc/mi_free directly; document at that boundary that growing a BabyList allocated on a different thread's mimalloc heap is sound only because mimalloc supports cross-thread free/realloc. take_ast_ownership() becomes a #[cfg(debug_assertions)] ownership-tag transfer, no-op in release.
PERF: Scan→link handoff copies O(file_count × Σ sizeof(BundledAst column field)) bytes in clone_ast (MultiArrayList shallow clone) plus O(Σ_per_source symbols.len × sizeof(Symbol)) + O(Σ module_scope.generated.len × sizeof(Ref)) in deep-clones — NOT zero-copy. Measure: instrument clone_ast/load byte counts and assert Rust ≤ Zig for the same input graph (compare against bun.perf.trace("Bundler.cloneAST") span and a counter on BabyList::clone). Regression check: bundling three.js x10 — cloneAST + load wall time and bytes-copied within ±5% of Zig.
SRC: /root/bun-5/src/bundler/bundle_v2.zig (lines 1210-1232 fn cloneAST: line 1215 this.linker.graph.ast = try this.graph.ast.clone(this.allocator()); line 1226 module_scope.generated = try module_scope.generated.clone(this.allocator()); line 1231 calls this.linker.graph.takeAstOwnership()); /root/bun-5/vendor/zig/lib/std/multi_array_list.zig (lines 517-531 pub fn clone: allocates fresh capacity then @memcpy(result_slice.items(field), self_slice.items(field)) for every non-zero-sized field — shallow column copy); /root/bun-5/src/bundler/LinkerGraph.zig (lines 14-16 stale comment 'This is an alias from Graph / it is not a clone!'; lines 49-70 generateNewSymbol appends to this.symbols.symbols_for_source[source_index] and ast.items(.module_scope)[i].generated with this.allocator (both already cloned onto linker heap); stale TODO at line 59); /root/bun-5/src/bundler/LinkerGraph.zig (lines 353-362 inside load(): var symbols = bun.handleOom(input_symbols.symbols_for_source.clone(this.allocator)); for (...) |*dest, src| { dest.* = bun.handleOom(src.clone(this.allocator)); } — deep-clone of every per-source Symbol BabyList onto linker allocator before any generateNewSymbol); /root/bun-5/src/bundler/LinkerGraph.zig (lines 93-100 addPartToFile: var parts: *Part.List = &graph.ast.items(.parts)[id]; try parts.append(graph.allocator, part); — grows a BabyList whose backing buffer is still worker-arena-allocated (only the column array was memcpy'd); lines 160-182 generateSymbolImportAndUse mutates part.symbol_uses and ast.items(.flags)[source_index] in the cloned columns); /root/bun-5/src/bundler/LinkerGraph.zig (lines 418-434 takeAstOwnership: if (comptime !bun.collections.baby_list.safety_checks) return; — debug-only ownership-tag transfer for import_records/parts/part.dependencies/symbols; no realloc in release); /root/bun-5/src/collections/baby_list.zig (line 650 pub const safety_checks = Environment.ci_assert; — confirms takeAstOwnership early-returns in release builds)
AST store + parser allocator → crate bun_ast
[bun_ast::store] AST-04
FACT: Store.append has a two-tier dispatch: if threadlocal memory_allocator: ?*ASTMemoryAllocator is set, allocations go to that bump allocator INSTEAD of the threadlocal block-chain; otherwise to instance.?.append. This lets the bundler give each worker its own arena (ThreadLocalArena via mimalloc heap) while the runtime/CLI path uses the resettable chain. disable_reset threadlocal flag suppresses reset() for callers that hold AST across parse calls.
RUST: crate bun_ast::store: #[thread_local] static MEMORY_ALLOCATOR: Cell<Option<NonNull<AstMemoryAllocator>>> and #[thread_local] static DISABLE_RESET: Cell<bool>. #[inline] pub fn append<T: AstNode>(v: T) -> StoreRef<T> { if let Some(a) = MEMORY_ALLOCATOR.get() { unsafe { a.as_ref().append(v) } } else { expr_store_mut().append(v) } }. The override path is the COLD branch in runtime mode but the HOT branch in bundler mode — mark neither #[cold]; rely on PGO or keep both inlined. Export extern "C" fn bun_ast_set_memory_allocator(ptr: *mut AstMemoryAllocator) and bun_ast_set_disable_reset(b: bool) so Zig's Scope.enter/exit and bundler Worker.get/unget can drive the Rust TLS during transition.
PERF: The null-check on memory_allocator must be a single TLS load + test. No atomic, no Mutex. Verify append::<EBinary> disassembly shows one cmpq $0, %fs:OFF then direct fallthrough. Bundler perf gate: bun build bench/three/index.ts --minify on 8 cores, wall time ≤ Zig baseline +2%.
SRC: src/ast/Expr.zig (L3182-3231 memory_allocator threadlocal; append() checks it first; disable_reset gating reset()); src/ast/Stmt.zig (L333-382 identical pattern for Stmt store); src/bundler/ThreadPool.zig (L217 Worker.ast_memory_allocator field; L252 worker.ast_memory_allocator.push() on get(); L265 .pop() on unget()); src/ast/ASTMemoryAllocator.zig (L59-70 push()/pop() swap Stmt/Expr.Data.Store.memory_allocator threadlocal)
[bun_ast::store] AST-07
FACT: NewStore validates at comptime that every appended T is in the closed types list (supportsType) and rejects zero-size types. This is a correctness fence: you cannot accidentally store a type whose size/align wasn't accounted for in Block sizing.
RUST: crate bunast::store: sealed trait pub trait ExprNode: Sealed { const SIZE: usize; const ALIGN: usize; } implemented via macro for exactly the 26 E. types and 29 S._ types. BlockStore::append<T: ExprNode> bounds the generic. const fn max_size<L: TypeList>() -> usize computed by the same macro feeds the const-generic SIZE. Zero-size rejection: const _: () = assert!(size_of::<T>() > 0)inside the trait impl macro. This replaces Zig'scomptime supportsType+@compileError with a trait bound — same guarantee, zero runtime cost.
PERF: Purely compile-time; no runtime invariant. CI gate: a trybuild negative test asserting BlockStore::<..>::append(NotARegisteredType{}) fails to compile.
FACT: Stmt.allocate(allocator, ...) is an escape hatch that heap-allocates the node payload via an arbitrary std.mem.Allocator instead of the Store, for cases where the node must outlive Store.reset(). The resulting *S.Foo is bit-identical to a Store-allocated one (same Data union layout), so consumers can't tell the difference.
RUST: crate bun_ast: impl Stmt { pub fn allocate_in<A: Allocator>(alloc: &A, data: impl Into<StmtPayload>, loc: Loc) -> Stmt } using alloc.alloc(Layout::new::<T>()) + ptr::write, returning StoreRef<T>. Because StoreRef is *mut T with no lifetime, it transparently accepts pointers from EITHER the BlockStore or an external bumpalo/mimalloc — exactly matching Zig's behavior. This is another reason StoreRef cannot carry 'ast: the same field type must hold pointers from heterogeneous allocators with disjoint lifetimes.
PERF: Cold path; no perf gate. Correctness gate: ASAN test that allocates via Stmt::allocate_in(&mimalloc, ..), calls expr_store().reset(), then dereferences — must NOT be poisoned.
SRC: src/ast/Stmt.zig (L162-204 Stmt.allocate: 'When lifetime must exist longer than reset()'; allocator.create + same Data union); src/ast/Stmt.zig (L108-113 allocateData: allocator.create(T); value.* = origData; comptime_init wraps as *T variant)
[bun_ast] ast-allocator
FACT: There are exactly two NewStore instantiations (Expr.Data.Store at Expr.zig:3152 and Stmt.Data.Store at Stmt.zig:301), each held in pub threadlocal var instance: ?*StoreType. The first Block is co-allocated with the Store metadata in a single PreAlloc { metadata: Store, first_block: Block } heap object so init() is one backing_allocator.create(PreAlloc) and firstBlock() is a @fieldParentPtr offset — no separate pointer load. The hot path Store.append is pub inline fn and Block.tryAlloc does std.mem.alignForward(usize, bytes_used, @alignOf(T)) with comptime T, so per-type alignment constant-folds and the whole allocation is an inlined bump-pointer with zero out-of-line calls. Additionally, both Data.Store.append wrappers first check threadlocal memory_allocator: ?*ASTMemoryAllocator and divert to it before touching instance — the bump store can be entirely overridden per-thread.
RUST: crate bun*ast: #[repr(C)] struct PreAlloc<const SIZE: usize, const ALIGN: usize> { metadata: BlockStore, first_block: Block<SIZE, ALIGN> }. TLS holds the full-allocation pointer: #[thread_local] static EXPR_STORE: Cell<Option<NonNull<PreAlloc<EXPR_SIZE, EXPR_ALIGN>>>> (nightly #![feature(thread_local)] to get raw __thread; on stable fall back to a #[no_mangle] extern "C" TLS slot defined in a one-line .c file so the TLS access model matches Zig's). first_block(p: NonNull<PreAlloc<..>>) -> NonNull<Block<..>> is unsafe { p.byte_add(mem::offset_of!(PreAlloc<..>, first_block)).cast() } — provenance is derived from the whole-allocation NonNull<PreAlloc>, never from &BlockStore/&self, so it is sound under Stacked Borrows/Miri. #[inline(always)] unsafe fn append<T>(store: NonNull<PreAlloc>, data: T) -> NonNull<T> stays a monomorphic generic so align_of::<T>()/size_of::<T>() constant-fold exactly like Zig's comptime path. Replicate the memory_allocator: Option<NonNull<AstMemoryAllocator>> thread-local override and check it beforeinstance, matching Expr.zig/Stmt.zig append() semantics. Migration ordering: do NOT expose extern "C" append — that inserts a non-inlinable call+memcpy into the per-node hot path js_parser.zig inlines today. Keep NewStore.zig as-is until js_parser/js_printer callers move to Rust in the same step (or run two independent per-thread stores — Zig callers use the Zig store, Rust callers use the Rust store; they share no pointers so duplication is safe). The only acceptable FFI seam is at the cold edges: extern "C" bun_ast*{expr,stmt}_store_{init,reset,deinit}().
PERF: Per-node allocation must remain a fully-inlined bump (TLS load + const-folded align_forward + bounds check + pointer add + typed store) with zero out-of-line calls on the fast path; no FFI hop on append. Measure: bun build ./bench/hot/three.js --no-bundle (or equivalent parse-only bench of three.js / react-dom.development.js) wall time and instruction count via perf stat -e instructions,cycles — ≤2% delta vs Zig baseline; perf record must show 0 samples in any extern "C" append symbol. Miri must pass on PreAlloc::first_block + append<T> round-trip to confirm provenance soundness.
SRC: src/ast/NewStore.zig (L63-72 const PreAlloc = struct { metadata: Store, first_block: Block, ... }; L74-76 firstBlock(store) = &@as(*PreAlloc, @fieldParentPtr("metadata", store)).first_block; L78-85 init() is a single backing_allocator.create(PreAlloc); L47-59 Block.tryAlloc uses std.mem.alignForward(usize, block.bytes_used, @alignOf(T)) with comptime T; L144-151 pub inline fn append(store, comptime T, data) *T.); src/ast/Expr.zig (L3152 const StoreType = NewStore(&.{ ... }, 512); L3181 pub threadlocal var instance: ?*StoreType = null; L3182 pub threadlocal var memory_allocator: ?*ASTMemoryAllocator = null; L3223-3229 append checks if (memory_allocator) |allocator| return allocator.append(T, value); before instance.?.append(T, value).); src/ast/Stmt.zig (L301 const StoreType = NewStore(&.{ ... }, 128); L333 pub threadlocal var instance: ?*StoreType = null; L334 pub threadlocal var memory_allocator: ?*ASTMemoryAllocator = null; L375-381 append checks memory_allocator override first then falls through to instance.?.append. rg confirms exactly two NewStore(...) call sites (Expr.zig:3152, Stmt.zig:301).)
[bun_ast] ast-allocator
FACT: Expr.Data and Stmt.Data are Zig union(Tag) (non-extern, implementation-defined layout) where LARGE variants are *E.Foo raw pointers into a threadlocal bump Store and SMALL variants are stored INLINE by value (E.Identifier, E.ImportIdentifier, E.PrivateIdentifier, E.CommonJSExportIdentifier, E.Boolean, E.Number, E.RequireString/RequireResolveString, E.Missing/This/Super/Null/Undefined/NewTarget/ImportMeta/ImportMetaMain, E.Special; for Stmt: S.TypeScript/S.Empty/S.Debugger). A comptime assert pins @sizeOf(Expr.Data) == 24 (NOT @sizeOf(Expr) — there is no assert on Expr, which is 32 bytes: 4-byte Loc + 4 pad + 24-byte Data at align 8). A separate check pins @sizeOf(Stmt) <= 24 (achievable because every Stmt variant is either a single 8-byte pointer or zero-sized). Expr/Stmt are { loc: logger.Loc /* i32 */, data: Data } passed BY VALUE throughout parser/visitor, and the visitor mutates THROUGH the boxed pointers in place (e.g. e_.target = p.visitExprInOut(...), e2.left = ...) while by-value copies of the parent Expr are alive on the stack — i.e. shared mutable aliasing of Store slots is part of the algorithm. Pointers are valid until the threadlocal Store.reset() / begin().
RUST: crate bun*ast: pub enum ExprData { EArray(StoreRef<EArray>), EBinary(StoreRef<EBinary>), ..., EIdentifier(EIdentifier), ENumber(f64), EBoolean(bool), EMissing, EThis, ... } with #[derive(Clone, Copy)] and a const *: () = assert!(size_of::<ExprData>() == 24);. Use default repr(Rust)(NOT#[repr(C, u8)]) — Zig union(Tag) has no C ABI, so byte-layout/calling-convention parity is neither achievable nor required; only SIZE parity matters.
#[repr(transparent)] #[derive(Clone, Copy)] pub struct StoreRef<T>(NonNull<UnsafeCell<T>>); — the Store bump-allocates UnsafeCell<T> and returns this handle. CRITICAL: do NOT implement safe Deref/DerefMut. The visitor algorithm holds by-value Expr copies whose StoreRef aliases the same slot while recursing and mutating children (verified at src/ast/visitExpr.zig:489,1025-1026); a safe &mut T here is instant UB under Stacked/Tree Borrows. Instead expose: pub fn ptr(self) -> *mut T (always sound via UnsafeCell::get), pub unsafe fn as_ref<'a>(self) -> &'a T, pub unsafe fn as_mut<'a>(self) -> &'a mut T — caller obligation documented as: (1) Store not reset since allocation, (2) no other live &mut/& to this slot for the chosen 'a. This mirrors Zig's "you own the discipline" contract without lying to the borrow checker.
Also reject &'ast T lifetimes: the same aliasing + 'until threadlocal reset()' lifetime cannot be expressed as a borrow scope and would virally infect ~200 parser/visitor functions.
Generate the enum + Expr::init(T, loc) -> Expr / Stmt::alloc(T, loc) -> Stmt dispatcher via macro_rules! ast_data! { ($(($variant:ident, $ty:ty, boxed)),* ; $(($ivariant:ident, $ity:ty, inline)),*) => ... } so the (variant, payload, boxed|inline) table is the single source of truth, replacing the giant hand-written switch in Expr.init/Stmt.alloc.
FFI boundary (if ever needed): pass Expr/Stmt across Zig↔Rust BY POINTER through an explicit extern struct { tag: u8, payload: [N]u8 } shim — never by value, never assume layout equality.
PERF: Zero regression = (a) size_of::<ExprData>() == 24, size_of::<Expr>() == 32, size_of::<Stmt>() <= 24, enforced by Rust const _: () = assert!(...) AND cross-checked at build time against Zig via export const BUN_SIZEOF_EXPR_DATA: usize = @sizeOf(Expr.Data); export const BUN_SIZEOF_EXPR: usize = @sizeOf(Expr); export const BUN_SIZEOF_STMT: usize = @sizeOf(Stmt); consumed by a Rust extern "C" { static ... } + a #[test] equality check (NOTE: export, not extern; and this checks SIZE only — layout/ABI equality is not a goal because Zig tagged unions have no C ABI). (b) Expr/Stmt are Copy and passed by value; no Box/Rc/Arc per node; boxed variants come exclusively from the threadlocal bump Store (one pointer-bump per node, no per-node malloc). (c) Hot constructors (Expr::init, Stmt::alloc, StoreRef::as_mut) marked #[inline]. Measure: bench/snippets parser throughput (e.g. three.js / react-dom) — bytes/sec within ±2% of Zig baseline; valgrind --tool=dhat or mimalloc stats showing zero per-node heap allocations during parse.
SRC: src/ast/Expr.zig (L1-2: loc: logger.Loc, data: Data — Expr is by-value struct.); src/ast/Expr.zig (L2132-2192: pub const Data = union(Tag) { e_array: *E.Array, ..., e_identifier: E.Identifier, e_number: E.Number, ..., e_missing: E.Missing, ... } followed by comptime { bun.assert_eql(@sizeOf(Data), 24); } — boxed pointers vs inline values; size assert is on Data, not Expr.); src/ast/Expr.zig (L1127-1410 Expr.init: large types route through Data.Store.append(Type, st) (returns *T), small types stored inline by value (e.g. .e_boolean = st, .e_this = st).); src/ast/Expr.zig (L3181-3230: pub threadlocal var instance: ?*StoreType; pub fn reset(); pub fn append(comptime T, value) *T— threadlocal bump store; pointers valid until reset().); src/ast/Stmt.zig (L257-296:pub const Data = union(Tag) { s_block: *S.Block, ..., s*type_script: S.TypeScript, s_empty: S.Empty, s_debugger: S.Debugger, s_lazy_export: *Expr.Data }withcomptime { if (@sizeOf(Stmt) > 24) @compileLog(...) }.); src/ast/Stmt.zig (L119-157 Stmt.alloc: comptime_alloc → Data.Store.append for boxed variants; Debugger/Empty/TypeScript constructed inline.); src/ast/Stmt.zig (L333-376: pub threadlocal var instance; pub fn reset(); pub fn append(comptime T, value) _T— same threadlocal Store pattern as Expr.); src/ast/visitExpr.zig (L489v.e.left = p.visitExprInOut(left, left_in);, L551/876/1196/1554 e_.target = p.visitExprInOut(...), L1025-1026 e2.left = ...; e2.right = p.visitExpr(...)— visitor mutates THROUGH the *E.Foo pointer while a by-value copy of the parent Expr (holding the same pointer) is live on the stack: shared mutable aliasing is algorithmic, hence safe Deref/DerefMut on StoreRef would be unsound.); src/logger.zig (L45-46:pub const Loc = struct { start: i32 = -1 } — 4-byte location; with 8-aligned 24-byte Data, Expr totals 32 bytes (no @sizeOf(Expr) assert exists in tree — grep confirms).)
FACT: The parser P struct holds a generic allocator: std.mem.Allocator (src/ast/P.zig:145) — a non-extern Zig struct { ptr: *anyopaque, vtable: *const VTable } where VTable is ALSO a plain (non-extern) Zig struct whose four function pointers use the default (.auto) Zig calling convention and take Zig-only types ([]u8 slices, std.mem.Alignment enum). This allocator is used for parser scratch (ScopeOrderList, Scope creation, slice dupes, hashmap storage); during bundling it is the worker's ThreadLocalArena.allocator(). Because neither Allocator nor VTable is extern struct and the fn-pointers are not callconv(.c), a Rust #[repr(C)] mirror of the vtable cannot soundly be passed back to Zig — the bridge must construct the vtable on the Zig side.
RUST: Do NOT export a #[repr(C)] ZigAllocatorVTable from Rust. Instead, in crate bun_ast::ffi export a single thin C-ABI shim: #[no_mangle] pub extern "C" fn bun_bump_alloc(ctx: *mut c_void, len: usize, log2_align: u8) -> *mut u8 { let bump = unsafe { &*(ctx as *const bumpalo::Bump) }; let layout = Layout::from_size_align(len, 1usize << log2_align).unwrap_or(Layout::new::<u8>()); bump.try_alloc_layout(layout).map(|p| p.as_ptr()).unwrap_or(core::ptr::null_mut()) }. On the Zig side (new file src/ast/rust_bump_allocator.zig): declare extern fn bun_bump_alloc(ctx: *anyopaque, len: usize, log2_align: u8) callconv(.c) ?[*]u8;, write ONE Zig-callconv trampoline fn zAlloc(ctx: *anyopaque, len: usize, a: std.mem.Alignment, _: usize) ?[*]u8 { return bun_bump_alloc(ctx, len, @intFromEnum(a)); }, then build the vtable IN ZIG using std's ready-made no-ops: const vtable: std.mem.Allocator.VTable = .{ .alloc = zAlloc, .resize = std.mem.Allocator.noResize, .remap = std.mem.Allocator.noRemap, .free = std.mem.Allocator.noFree }; and expose pub fn rustBumpAllocator(bump: *anyopaque) std.mem.Allocator { return .{ .ptr = bump, .vtable = &vtable }; }. This keeps all non-C-ABI types (slice args, Alignment enum, non-extern struct layout, .auto callconv) entirely on the Zig side; FFI crosses only via C scalars. P.init(allocator = rustBumpAllocator(bump_ptr), ...) then runs unchanged.
PERF: Current path is already one indirect call per alloc (a.vtable.alloc(a.ptr, ...) → ThreadLocalArena impl). The bridge adds exactly one thin Zig→C trampoline (3 scalar args + @intFromEnum) on top of that indirect call; resize/remap/free stay pure-Zig (noResize/noRemap/noFree) with zero FFI. Invariant: parser throughput (bytes/sec parsing three.js + react-dom corpus, release build, 8 worker threads) must be ≥99% of the Zig ThreadLocalArena baseline. Measure with bun build --minify wall-clock and perf stat -e instructions on the parse phase; the +1 direct call must not exceed ~1% instruction-count delta. If it does, mark zAlloccallconv(.c) is NOT an option (vtable requires .auto), so instead inline the bump-pointer fast path into the Zig trampoline and only call Rust on chunk-refill.
SRC: /root/bun-5/src/ast/P.zig (L145 allocator: Allocator, field on the P struct; L6748-6758 pub fn init(allocator: Allocator, ...) immediately does ScopeOrderList.initCapacity(allocator, 1) and allocator.create(Scope) — scratch allocations through the generic interface.); /root/bun-5/vendor/zig/lib/std/mem/Allocator.zig (L19-20 top-level fields ptr: *anyopaque, vtable: *const VTable on a NON-extern @This() struct. L22 pub const VTable = struct { — plain Zig struct, no extern, no layout guarantee. L29/48/69/81 fn-pointers are *const fn(...) with default callconv (no callconv(.c)), and resize/remap/free take memory: []u8 (Zig slice, no C ABI). Grep confirms zero occurrences of extern struct or callconv in this file.); /root/bun-5/vendor/zig/lib/std/mem/Allocator.zig (L84-123 pub fn noResize(...) bool { return false; }, noRemap(...) ?[*]u8 { return null; }, noFree(...) void {} — std-provided no-op vtable entries the Zig shim reuses so only alloc ever crosses FFI.); /root/bun-5/vendor/zig/lib/std/mem.zig (L23 pub const Alignment = enum(math.Log2Int(usize)) { — Alignment is a u6-backed enum; the Zig trampoline converts it to u8 via @intFromEnum before the C call, keeping the enum off the FFI boundary.); /root/bun-5/src/bundler/ThreadPool.zig (L286-287 this.heap = ThreadLocalArena.init(); this.allocator = this.heap.allocator(); — the std.mem.Allocator handed to the parser today is already vtable-dispatched (one indirect call per alloc), establishing the baseline the Rust bridge must match.)
FACT: The AST Store's real public surface is NOT just the zero-arg create()/reset()/deinit() functions — it is three coupled pub threadlocal vars (instance: ?*StoreType, memory_allocator: ?*ASTMemoryAllocator, disable_reset: bool) plus the per-node hot path append(comptime T, value). create() guards on instance != null OR memory_allocator != null; reset() guards on disable_reset OR memory_allocator != null. The memory_allocator threadlocal is written DIRECTLY from outside the module by ASTMemoryAllocator.push/pop/Scope (used by bake DevServer, npm manifest parsing) and disable_reset is written by macro execution. append() first checks memory_allocator, then falls through to instance.?.append(T, value) — an inline bump into a NewStore Block (linked list of fixed [size]u8 buffers with bytes_used+next; reset() simply rewinds current to the first block and zeroes bytes_used). Live zero-arg create() callers include bun.js.zig, transpiler.zig, install.zig, bake/production.zig, and postProcessJSChunk.zig (per bundler-worker thread). The previously cited json.zig:978-982 is dead test-helper code with a stale one-arg signature and is NOT part of the live surface.
RUST: crate bun_ast_store. Do NOT replace create/reset/append bodies with extern calls — that would add an FFI hop per AST node (millions/file) and sever Zig-side visibility into memory_allocator/disable_reset, causing UAF when macros or ASTMemoryAllocator overrides are active. Instead port ONLY the NewStore block-chain backend. Rust defines #[repr(C)] pub struct Block { bytes_used: u32, _pad: u32, next: *mut Block, buf: [u8; BLOCK_SIZE] } and #[repr(C)] pub struct BlockStore { current: *mut Block, first: *mut Block }. Exported C ABI (cold paths only): #[no_mangle] extern "C" fn Bun__AstStore__init() -> *mut BlockStore, Bun__AstStore__reset(store: *mut BlockStore) (rewind current→first, bytes_used=0, keep chain), Bun__AstStore__deinit(store: *mut BlockStore), Bun__AstStore__growBlock(store: *mut BlockStore) -> *mut Block (allocate+link next block on overflow). Zig side: threadlocal var instance: ?*BlockStore, threadlocal var memory_allocator, threadlocal var disable_reset, and ALL guard logic in create()/reset()/begin() remain in Zig unchanged (so ASTMemoryAllocator.zig L35-68 and Macro.zig L41-44 keep assigning them without modification). append(comptime T, value) stays a Zig inline fn doing the bump locally against the #[repr(C)] Block fields (load instance.?.current, alignForward bytes_used, compare vs BLOCK_SIZE, store) and only calls Bun__AstStore__growBlock on the rare overflow branch. No parser code changes; only NewStore.zig's init/reset/deinit and the block-overflow path become extern shims.
PERF: Common-case append() = zero FFI: one threadlocal load + one pointer chase + alignForward + compare + store, byte-identical to today's NewStore.Block.tryAlloc. FFI occurs only on block overflow (≤ once per count*2 nodes, count=512). Gate 1 (hot path): hyperfine --warmup 3 './build/release/bun build bench/three/index.js --outdir=/tmp/out' (or any multi-MB source) must be within ±1% wall-clock of Zig baseline — this exercises millions of append() calls, unlike an empty.js cold-start which only hits create(). Gate 2 (correctness of retained Zig guards): bun bd test test/bundler/bundler_compile.test.ts and macro tests pass (disable_reset path), and bun bd install in a fixture repo produces an identical lockfile (ASTMemoryAllocator override path via npm.zig).
SRC: src/ast/Expr.zig (L3181-3183 three pub threadlocals (instance, memory_allocator, disable_reset); L3185-3196 create() guards on instance!=null or memory_allocator!=null, reset() guards on disable_reset or memory_allocator!=null; L3223-3230 append(comptime T,value) reads memory_allocator first then instance.?.append — this is the per-node hot path); src/ast/NewStore.zig (L33-60 Block{buffer:[size]u8, bytes_used, next} with inline tryAlloc bump; L78-84 init() heap-allocates PreAlloc and returns *Store; L102-115 reset() rewinds current→firstBlock, bytes_used=0 (chain retained) — this is the only piece movable behind C ABI without touching the hot bump); src/ast/ASTMemoryAllocator.zig (L35-36, L45-46, L59-61, L67-68 directly assign Expr.Data.Store.memory_allocator / Stmt.Data.Store.memory_allocator from outside — proves the threadlocal must remain a Zig symbol, not hidden inside Rust); src/ast/Macro.zig (L41-44 directly assign Expr/Stmt.Data.Store.disable_reset = true/false around macro evaluation — reset() must keep reading this Zig threadlocal or macros UAF); src/bake.zig (L714 reads JSAst.Stmt.Data.Store.memory_allocator to capture .previous for an override scope — another external direct-field consumer); src/install/npm.zig (L1849 defer bun.ast.Stmt.Data.Store.memory_allocator.?.pop() — package manager directly dereferences the threadlocal); src/transpiler.zig (L397-398 and L549-550 live zero-arg Expr/Stmt.Data.Store.create() callers (replaces the invalid dead-code json.zig:978-982 citation)); src/bundler/linker_context/postProcessJSChunk.zig (L10-11 js_ast.Expr/Stmt.Data.Store.create() invoked per chunk-worker thread — confirms threadlocal-per-worker init pattern)
FACT: Each NewSocket instance carries three orthogonal lifecycle primitives inline in the struct: (1) ref_count: bun.ptr.RefCount — intrusive native refcount with deinit callback; (2) this_value: jsc.JSRef — a tagged union {weak: JSValue, strong: Strong.Optional, finalized} that is held strong while active and downgraded to weak on close so GC can collect the JS wrapper; (3) poll_ref: Async.KeepAlive — event-loop keep-alive that prevents process exit while the socket has pending I/O. None of these allocate per-socket (RefCount is an inline u32, JSRef is a small tagged union, KeepAlive is a small struct).
RUST: In bun_runtime::lifecycle: #[repr(C)] pub struct RefCount(Cell<u32>) with unsafe trait RefCounted { fn deinit(self: *mut Self); } mixin macro ref_counted!(Socket<SSL>, field = ref_count, drop = Self::deinit). #[repr(C, u8)] pub enum JsRef { Weak(JSValue), Strong(StrongOptional), Finalized } with upgrade()/downgrade() taking *mut JSGlobalObject. #[repr(C)] pub struct KeepAlive { status: KeepAliveStatus }. All three live as inline fields of Socket<SSL> — no Box/Arc. Unsafe boundary: RefCount::deref calls deinit via raw *mut Self (matches Zig's fn deref(this: *This) aliasing model); caller must guarantee single-threaded access (event-loop affinity).
PERF: sizeof(Socket) in Rust == @sizeOf(TCPSocket) in Zig (currently driven by inline fields, no heap indirection). ref()/deref() must compile to a single non-atomic inc/dec (Cell, NOT AtomicU32 — Bun sockets are single-threaded). Measure: static_assert in bindgen header on sizeof; perf stat -e cache-misses on 100k connect/close cycles within 1% of Zig.
FACT: Handlers is a single heap-allocated struct shared by pointer (handlers: ?*Handlers) across every socket accepted by a Listener. It stores 9 callbacks as raw jsc.JSValue (NOT jsc.Strong) and roots them via JSValue.protect() on construction / unprotect() on deinit. Liveness is tracked by active_connections: u32 (markActive/markInactive); when it hits 0 in client mode the Handlers self-destroys, in server mode it defers to the Listener via @fieldParentPtr("handlers", this). There is exactly ONE Handlers allocation per listen()/connect() call regardless of connection count.
RUST: In bun_runtime::socket: #[repr(C)] pub struct Handlers { on_open: JSValue, on_close: JSValue, on_data: JSValue, on_writable: JSValue, on_timeout: JSValue, on_connect_error: JSValue, on_end: JSValue, on_error: JSValue, on_handshake: JSValue, binary_type: BinaryType, vm: *mut VirtualMachine, global: *mut JSGlobalObject, active_connections: Cell<u32>, mode: SocketMode, promise: StrongOptional }. Stored in Socket<SSL> as Option<NonNull<Handlers>> (raw, NOT Arc — refcounting is manual via active_connections to match Zig's @fieldParentPtr trick for server mode). protect() loops the 9 JSValue fields calling extern "C" JSC__JSValue__protect. Unsafe boundary: mark_inactive may free self — callers must not touch self after; in server mode it computes the parent Listener via container_of!(self, Listener, handlers) (offset_of-based, #[repr(C)] required on Listener).
PERF: Per-accepted-socket cost for callback storage = 1 pointer (8 bytes), zero allocation. Calling a handler must be a direct field load + JSC call, no vtable. Measure: bun bd test test/js/bun/net with BUN_DEBUG_Listener and heaptrack — 1 Handlers allocation per Listener regardless of N sockets; RSS delta for 10k sockets matches Zig within 1%.
FACT: Socket flags are a packed struct(u16) with 10 bool bits + 6 padding bits, stored inline. RequestContext uses NewFlags(debug_mode) — also packed struct(u16) — where one field (is_web_browser_navigation) is bool only when debug_mode else void, and padding width is computed at comptime to keep the total at exactly 16 bits across all (debug_mode × Environment.isDebug) combinations.
RUST: In bun_runtime: #[repr(transparent)] pub struct SocketFlags(u16); with bitflags!-style const masks, OR a bitfield! proc-macro generating fn is_active(&self)->bool accessors. For RequestContext: #[repr(transparent)] pub struct RequestFlags<const DEBUG: bool>(u16); — the debug-only bit is always reserved in the u16 (Rust cannot have void-typed fields), but the accessor is_web_browser_navigation() is where DEBUG = true gated via impl RequestFlags<true>. This keeps size at 2 bytes unconditionally, matching Zig's packed struct(u16).
PERF: sizeof(SocketFlags) == 2, sizeof(RequestFlags<_>) == 2. Flag reads compile to a single test/and instruction. Measure: const _: () = assert!(size_of::<SocketFlags>() == 2);+ cargo-asm spot check onflags.aborted load in onAbort hot path.
FACT: NewRequestContext takes 4 comptime params (ssl_enabled, debug_mode, ThisServer, http3) and is instantiated at exactly 6 points: {HTTP, HTTPS, DebugHTTP, DebugHTTPS} × http3=false, plus {HTTPS, DebugHTTPS} × http3=true (h3 only exists when ssl_enabled). The struct is NOT a .classes.ts JS wrapper — it has no jsc.Codegen binding, no toJS, and is never exposed to JS directly; it is a pure native per-request state machine. The associated App/Req/Resp types and ResponseStream are all selected at comptime from the same params.
RUST: In bun_runtime::http_server: pub struct RequestContext<const SSL: bool, const DEBUG: bool, const H3: bool> { server: Option<NonNull<Server<SSL, DEBUG>>>, resp: Option<NonNull<Resp<SSL, H3>>>, allocator: Allocator, req: Option<NonNull<Req<H3>>>, flags: RequestFlags<DEBUG>, ref_count: u8, ... } with type aliases via a sealed trait trait Transport<const SSL: bool, const H3: bool> { type App; type Req; type Resp; const RESP_KIND: ResponseKind; }. ThisServer param collapses: in Zig it is only ever NewServer(ssl, debug), so Rust derives it as type ServerTy = Server<SSL, DEBUG> — no fourth generic needed. #[repr(C)] so AnyRequestContext can hand the raw pointer across the Zig FFI boundary unchanged. Because RequestContext is never a JS class, NO codegen integration is required — this is the cheapest module to port first.
PERF: Exactly 6 monomorphizations in the final binary (verify with nm | grep RequestContext | wc -l modulo mangling). No vtable: resp.write() must be a direct call into uws. ref_count stays u8 (1 byte) — matches Zig line 59. Measure: oha/bombardier against Bun.serve hello-world, p50/p99 latency and req/s within 1% of Zig build on same machine.
[bun_core::collections (consumed by bun_runtime)] hivearray-slab-threadlocal
FACT: RequestContext allocation goes through a per-monomorphization threadlocal var pool: ?*HiveArray(RequestContext, 2048).Fallback. HiveArray is a fixed-capacity slab: buffer: [capacity]T + IntegerBitSet(capacity); get() is findFirstUnset() (single bitscan) + set bit + return &buffer[i]; put() is a pointer-range check (addr - base) / sizeof(T) + unset bit. .Fallback wraps it with a heap allocator for overflow. The 2048-slot slab is heap-allocated lazily once per (thread × monomorphization) at first listen() and lives forever; the server holds request_pool_allocator: *RequestContextStackAllocator to avoid the threadlocal lookup on the hot path.
RUST: In bun_core::collections: #[repr(C)] pub struct HiveArray<T, const N: usize> { buffer: [MaybeUninit<T>; N], used: BitArray<[usize; (N+63)/64]> } with #[inline] fn get(&mut self) -> Option<*mut T> using used.first_zero() (compiles to TZCNT loop). pub struct HiveFallback<T, const N: usize> { hive: HiveArray<T, N>, alloc: &'static dyn Allocator }. Macro hive_array!(RequestContext<SSL,DEBUG,H3>, 2048) expands to #[thread_local] static mut POOL: Option<Box<HiveFallback<...>>> = None; plus fn pool() -> &'static mut HiveFallback<...>. Server stores request_pool_allocator: NonNull<HiveFallback<...>> to skip TLS access per-request. ASan poisoning hooks (asan::poison/unpoison) preserved via #[cfg(sanitize="address")] calls to __asan_poison_memory_region. Unsafe boundary: get() returns *mut T with uninitialized memory; caller MUST write all fields before use (matches Zig's buffer: [capacity]T = undefined).
PERF: Hot-path allocation = 1 bitscan + 1 bit-set + pointer arithmetic, zero syscalls, zero TLS access (pool ptr cached on server). put() must not call free() for the first 2048 concurrent requests. Measure: perf record on wrk 10k-conn run — __tls_get_addr and mi_malloc must NOT appear in RequestContext::init stack. Allocation latency p99 < 50ns (matches Zig slab).
[bun_core::ptr (consumed by bun_runtime)] anyreqctx-tagged-u64
FACT: AnyRequestContext is a single-field wrapper around bun.TaggedPointerUnion(.{6 RequestContext types}), which is itself packed struct(u64) { _ptr: u49, data: u15 }. Tags are assigned deterministically at comptime as 1024 - declaration_index (so HTTPServer.RequestContext=1024, HTTPSServer.RequestContext=1023, ..., DebugHTTPSServer.H3RequestContext=1019). dispatch() does an inline for over the type_map producing a 6-arm switch that monomorphizes the callback per concrete type. The whole AnyRequestContext fits in one register and is stored by-value in Request.request_context (the JS Request object).
RUST: In bun_core::ptr: #[repr(transparent)] pub struct TaggedPointer(u64); with const PTR_MASK: u64 = (1<<49)-1; const TAG_SHIFT: u32 = 49; and #[inline] fn ptr(self)->*mut () / fn tag(self)->u16. Macro tagged_union! { pub AnyRequestContext = [ HttpReqCtx = 1024, HttpsReqCtx = 1023, DebugHttpReqCtx = 1022, DebugHttpsReqCtx = 1021, HttpsH3ReqCtx = 1020, DebugHttpsH3ReqCtx = 1019 ] } generates: #[repr(transparent)] pub struct AnyRequestContext(TaggedPointer); #[repr(u16)] enum Tag {...}; #[inline] fn dispatch<R>(self, default: R, f: impl FnOnce(Variant)->R)->R { match self.tag() { 1024 => f(Variant::Http(self.as_unchecked())), ... } }. Tags are HARD-CODED to Zig's 1024-i values so a Rust-built AnyRequestContext is bit-identical to a Zig-built one. #[repr(transparent)] over u64 guarantees ABI-passing in a single register.
PERF: sizeof(AnyRequestContext) == 8; passed in register (System V: single INTEGER class). dispatch() compiles to a jump-table or 6-compare chain with each arm a direct call — no indirect call through fn-ptr. Measure: cargo asm AnyRequestContext::memory_cost shows cmp/je chain or jmp [table], no call rax. Tag values static-asserted against Zig via a generated header tagged_pointer_tags.h checked in CI.
FACT: Unlike RequestContext, NodeHTTPResponse IS a .classes.ts JS wrapper (jsc.Codegen.JSNodeHTTPResponse) and erases the transport via raw_response: ?uws.AnyResponse (a runtime-tagged union over TCP/SSL/H3 response) plus server: AnyServer. It uses the same RefCount mixin and a packed struct(u8) Flags. This is the template for 'JS-visible response object that doesn't monomorphize' — one struct, runtime dispatch on transport.
RUST: In bun_runtime::node_http: #[repr(C)] pub struct NodeHTTPResponse { ref_count: RefCount, raw_response: Option<AnyResponse>, flags: NodeHttpFlags /* #[repr(transparent)] u8 */, poll_ref: JscRef, body_read_ref: JscRef, promise: StrongOptional, server: AnyServer, ... }. AnyResponse is #[repr(C)] enum { Tcp(NonNull<uws::Response<false>>), Ssl(NonNull<uws::Response<true>>), H3(NonNull<uws::H3Response>) } — runtime tag is acceptable here because NodeHTTPResponse is the cold node:http compat path, not the Bun.serve hot path. Integrates with codegen via the existing .classes.ts → bun_jsc bridge (separate dimension).
PERF: NodeHTTPResponse is NOT on the Bun.serve fast path (it's the node:http shim), so a 1-byte tag + branch on AnyResponse is acceptable and matches current Zig. Invariant: Flags stays 1 byte; struct size matches Zig @sizeOf(NodeHTTPResponse). Measure: node:http compat benchmarks (test/js/node/http) within noise of Zig.
FACT: RequestContext stores allocator: std.mem.Allocator as an inline field with the explicit comment 'thread-local default heap allocator / this prevents an extra pthread_getspecific() call which shows up in profiling'. The allocator is captured once at request creation and reused for request_body_buf / response_buf_owned, avoiding mimalloc's TLS lookup on every small allocation in the response path.
RUST: In bun_runtime::http_server: RequestContext<...> field allocator: &'static dyn core::alloc::Allocator (or a thin MiAllocator(*mut mi_heap_t) newtype, #[repr(transparent)]). Populated from bun_mimalloc::thread_heap() once in RequestContext::init. All Vec fields use Vec<u8, MiAllocator> (allocator_api) so clear_and_free goes straight to the cached heap. Do NOT use the global #[global_allocator] for these — that reintroduces the TLS lookup the Zig code deliberately avoids.
PERF: Zero pthread_getspecific / __tls_get_addr calls during response body buffering. Measure: perf record -g on a streaming-response benchmark; __tls_get_addr samples under request handling must be ~0, matching Zig profile.
SRC: src/bun.js/api/server/RequestContext.zig (lines 43-45: /// thread-local default heap allocator / this prevents an extra pthread_getspecific() call which shows up in profiling / allocator: std.mem.Allocator,); src/bun.js/api/server/RequestContext.zig (lines 283-284: this.request_body_buf.clearAndFree(this.allocator); this.response_buf_owned.clearAndFree(this.allocator);)
[bun_runtime / server] async-natives
FACT: The JS Request object and the native RequestContext are coupled BIDIRECTIONALLY at field level, not via a single opaque handle. Forward: Request.request_context: AnyRequestContext is a packed-u64 tagged pointer (low u49 = address, high u15 = tag, tags = 1024-i over 6 concrete instantiations) whose accessors route through inline fn dispatch — but a public get(comptime T) ?*T escape hatch (and tagged_pointer.as(T)) hands the raw *RequestContext to callers; the WebSocket-upgrade and bake paths use it to read upgrader.upgrade_context, upgrader.resp, etc. directly. Backward: RequestContext.request_weakref: Request.WeakRef is an intrusive bun.ptr.WeakPtr(Request, "weak_ptr_data") (refcount lives inside Request.weak_ptr_data: WeakPtrData packed u32). Through it, Zig RequestContext writes request.request_context = AnyRequestContext.Null, calls request.internal_event_callback.{trigger,hasCallback,deinit}, and in toAsync touches request.url, ensureURL(), hasFetchHeaders(), setFetchHeaders(). Consequence: porting Request and RequestContext to Rust independently is unsound unless every cross-direction field access is first lifted to a C-ABI accessor; the u64 tag layout alone is not the contract.
RUST: Crate bun_server (RequestContext, AnyRequestContext) and bun_webcore (Request) migrate TOGETHER, or a pre-phase lands C-ABI shims in BOTH directions before any port. (A) Forward (Request→RequestContext): #[repr(transparent)] struct AnyRequestContext(u64) with const fn tag(self)->u16 { (self.0>>49) as u16 } / fn ptr(self)->usize { (self.0 & ((1<<49)-1)) as usize }; tags emitted by build.rs from the 6-type list so drift is a compile error; debug_assert!(addr>>49==0). While RequestContext stays Zig, accessors call extern "C" fn Bun__AnyRequestContext__{setTimeout,getRemoteSocketInfo,setCookies,ref,deref,onAbort,detachRequest,setRequest,memoryCost,devServer}(u64, ...) exported from Zig — accepting one non-inlinable hop per call where today dispatch inlines. The get(T) / .as(T) escape hatches are NOT bridged; their two call sites (WS upgrade, bake saved-request) are rewritten to go through new dispatched ops (tryTakeUpgradeContext, response, isAbortedOrEnded) before any side moves. (B) Backward (RequestContext→Request): replace direct field access with extern "C" accessors the Request owner exports: Bun__Request__detachContext(*Request), Bun__Request__triggerInternalEvent(*Request,u8,*JSGlobalObject)->bool, Bun__Request__hasInternalEventCallback(*Request)->bool, Bun__Request__deinitInternalEventCallback(*Request), Bun__Request__ensureURL(*Request), Bun__Request__setFetchHeaders(*Request,*FetchHeaders), Bun__Request__hasFetchHeaders(*Request)->bool. (C) WeakRef: define #[repr(C,packed)] struct WeakPtrData(u32) { ref_count:u31, finalized:u1 } embedded in Rust Request at the same intrusive field, plus extern "C" Bun__Request__weakRef/weakDeref/weakGet so the holder never touches Rust layout. Unsafe boundary: only the extern "C" fns dereference the u49 pointer; Rust callers treat AnyRequestContext as an opaque u64.
PERF: Every cross-direction accessor (setTimeout, ref/deref, detachContext, triggerInternalEvent, …) is invoked O(1) per HTTP request, never per-byte. Replacing today's fully-inlined inline fn dispatch and direct field stores with extern "C" shims adds at most one non-inlinable call per accessor invocation — a bounded constant per request, not per payload byte. Invariant to enforce: bench/hot/bun-serve-hello.ts (and oha -z 10s http://127.0.0.1:$PORT/ hello-world) p50/p99 latency and req/s within run-to-run noise of the all-Zig build on the same commit; perf record must show no Bun__AnyRequestContext__* or Bun__Request__* symbol above the existing uws_res_* frames. If a shim appears hot, it indicates an O(n)-per-byte caller bug, not an acceptable cost.
SRC: /root/bun-5/src/bun.js/webcore/Request.zig (lines 13-17,43: request_context: jsc.API.AnyRequestContext, weak_ptr_data: WeakRef.Data, internal_event_callback: InternalJSEventCallback; pub const WeakRef = bun.ptr.WeakPtr(Request, "weak_ptr_data") — forward link plus the intrusive weak-ref slot the back-link relies on); /root/bun-5/src/bun.js/api/server/RequestContext.zig (line 47 request_weakref: Request.WeakRef = .empty; lines 612-614, 656-663, 732-736: request.request_context = AnyRequestContext.Null, request.internal_event_callback.trigger/.deinit; lines 1249-1262 request_object.request_context.setRequest, .ensureURL(), .hasFetchHeaders(), .setFetchHeaders(); lines 2561-2563 req.internal_event_callback.hasCallback() — direct field-level access from RequestContext into Request); /root/bun-5/src/bun.js/api/server/AnyRequestContext.zig (lines 6-13 six-variant TaggedPointerUnion; line 27 inline fn dispatch (zero call boundary today); lines 54-56 pub fn get(self, comptime T) ?*T — public escape hatch that bypasses dispatch and returns the concrete pointer); /root/bun-5/src/bun.js/api/server.zig (line 872 var upgrader = request.request_context.get(RequestContext) orelse return .false; then reads upgrader.upgrade_context/upgrader.resp directly; line 2325 data.ctx.tagged_pointer.as(RequestContext); lines 2505 and 2631 ctx.request_weakref = .initRef(request_object) — establishes the back-link); /root/bun-5/src/ptr/tagged_pointer.zig (lines 1-7 AddressableSize = u49, packed struct(u64){ _ptr:u49, data:u15 }; lines 64-71 tag values assigned 1024 - i by declaration order — the bit-layout half of the ABI); /root/bun-5/src/ptr/weak_ptr.zig (lines 1-24 WeakPtrData = packed struct(u32) with onFinalize; WeakPtr(T, data_field) is intrusive (refcount stored INSIDE the pointee) — any Rust Request must host a layout-compatible u32 or expose ref/deref/get via extern C)
Event loop integration → crate bun_async
[bun_async::task] EL-TASK-TAGPTR
FACT: The JS-thread Task is NOT an intrusive list node — it is a single u64 TaggedPointerUnion (packed struct: low 49 bits = address, high 15 bits = type tag, tags assigned descending from 1024). ~95 concrete payload types (FetchTasklet, ShellRmTask, Readv, CppTask, AnyTask, …) share one u64. Dispatch in tickQueueWithCount is a giant switch (task.tag()) that recovers the typed *T and calls a fixed method (runFromJS/runFromMainThread/then). There is no vtable, no dyn dispatch, no allocation per Task value.
RUST: In crate bun_async: #[repr(transparent)] pub struct Task(u64); with const ADDR_MASK: u64 = (1<<49)-1; and #[repr(u16)] enum TaskTag (values start at 1024 and decrement, matching Zig's typeMap[i].value = 1024 - i). A bun_task_variants! declarative macro (or build.rs) emits both the TaskTag enum and a #[inline(never)] fn dispatch(self, vm: &mut VirtualMachine) containing one match arm per variant that does unsafe { &mut *((self.0 & ADDR_MASK) as *mut $Ty) }.run_from_js(vm). Unsafe boundary: tag→type bijection lives entirely inside the macro expansion; callers only see Task::init<T: TaskPayload>(ptr: *mut T) -> Task where trait TaskPayload { const TAG: TaskTag; fn run_from_js(&mut self, ...); }. Never Box<dyn Fn>, never Arc.
PERF: sizeof(Task)==8, Copy, passed in registers. Dispatch must compile to a jump table (verify with cargo asm / llvm-objdump -d that dispatch lowers to jmp *table(,%rax,8) not a cmp/je chain). Measure: bench/snippets/process-nextTick.mjs and setImmediate microbench — task-tick throughput must match Zig within ±2%.
SRC: /root/bun-5/src/bun.js/event_loop/Task.zig (lines 4-99: pub const Task = TaggedPointerUnion(.{ Access, AnyTask, ... ~95 types }); then tickQueueWithCount does switch (task.tag()) with one arm per type calling .runFromJS()/.runFromMainThread()); /root/bun-5/src/ptr/tagged_pointer.zig (lines 1-43: TaggedPointer = packed struct(u64) { _ptr: u49, data: u15 }; line 70: typeMap[i] = .{ .value = 1024 - i, ... } — tags assigned by index)
[bun_async::event_loop] EL-TASKS-FIFO
FACT: The same-thread task queue (EventLoop.tasks) is bun.LinearFifo(Task, .Dynamic) — a growable ring buffer of u64 values. enqueueTask is tasks.writeItem(task) (single-threaded, no atomics). tickConcurrentWithCount drains the lock-free concurrent queue via popBatch() and bulk-copies the inner Task u64s into tasks.writableSlice(0) before dispatching, freeing each ConcurrentTask shell if auto_delete.
RUST: In bun_async::event_loop: tasks: RingBuf<Task> — a ported LinearFifo (struct RingBuf<T: Copy> { buf: Box<[MaybeUninit<T>]>, head: usize, count: usize }) with write_item, read_item, ensure_unused_capacity, writable_slice. NOT VecDeque (VecDeque's two-slice layout breaks the writableSlice(0) bulk-copy fast path). tick_concurrent_with_count mirrors Zig: let batch = self.concurrent_tasks.pop_batch(); self.tasks.ensure_unused_capacity(batch.count); for t in batch { ws[0]=t.task; ... if t.auto_delete() { dealloc } }.
PERF: Draining N concurrent tasks must be O(N) with one allocation amortized (ring grow), zero per-item allocation. Bulk copy must be memcpy-able (Task is Copy u64). Measure: count mi_malloc calls during a 1M-task queueMicrotask/enqueueTask loop via BUN_DEBUG_MIMALLOC=1 — must equal Zig build.
SRC: /root/bun-5/src/bun.js/event_loop.zig (line 113: pub const Queue = bun.LinearFifo(Task, .Dynamic); line 3: tasks: Queue; line 594-596: enqueueTask = this.tasks.writeItem(task); lines 308-355: tickConcurrentWithCount does popBatch(), ensureUnusedCapacity(count), copies task.task into writableSlice(0), frees shells with auto_delete)
FACT: ConcurrentTask is the cross-thread intrusive MPSC node and is exactly 16 bytes (compile-time asserted): { task: Task /*u64*/, next: PackedNextPtr /*usize*/ }. PackedNextPtr stores the ?*ConcurrentTask in bits 1.. and auto_delete in bit 0 (alignment ≥2 guaranteed). It is the element type of UnboundedQueue(ConcurrentTask, .next) — a lock-free MPMC queue with back/front each on its own half-cache-line (align(cache_line_length/2)), using swap(.acq_rel) push and cmpxchgWeak pop.
RUST: In bun_async: #[repr(C, align(8))] pub struct ConcurrentTask { pub task: Task, next: AtomicUsize } with const _: () = assert!(size_of::<ConcurrentTask>() == 16);. impl ConcurrentTask { fn next_ptr(&self)->*mut Self { (self.next.load(Acquire) & !1) as _ } fn auto_delete(&self)->bool { self.next.load(Relaxed)&1!=0 } }. Port UnboundedQueue<T, const NEXT_OFFSET: usize> verbatim into bun_async::unbounded_queue with #[repr(align(64))] struct Padded<T>(T) for back/front (use 64 not 128/2 — Rust has no per-arch cacheline const, hardcode via cfg). Unsafe boundary: queue owns raw *mut ConcurrentTask; safe wrapper EventLoop::enqueue_task_concurrent(&self, *mut ConcurrentTask) is the only entry. NO crossbeam, NO Arc<Mutex<VecDeque>>.
PERF: Push = 1 swap + 1 store (2 atomic ops), no allocation when caller embeds the node. size_of::<ConcurrentTask>() == 16. Measure: hyperfine a worker that posts 10M messages back to main; compare perf stat -e cache-misses and wall-clock vs Zig.
SRC: /root/bun-5/src/bun.js/event_loop/ConcurrentTask.zig (lines 13-71: task: Task, next: PackedNextPtr; PackedNextPtr packs ptr|auto_delete in usize; comptime { if (@sizeOf(ConcurrentTask) != 16) @compileError(...) }; line 73: pub const Queue = UnboundedQueue(ConcurrentTask, .next);); /root/bun-5/src/threading/unbounded_queue.zig (lines 1-6: cache_line_length per-arch; lines 82-103: back/front are std.atomic.Value(?*T) align(queue_padding_length); pushBatch = back.swap(last,.acq_rel) then link)
[bun_async::ffi] EL-ENQUEUE-FFI
FACT: Cross-thread → JS-thread enqueue is EventLoop.enqueueTaskConcurrent(task): push to concurrent_tasks then wakeup() → us_wakeup_loop(loop) (writes to the loop's eventfd/pipe, increments pending_wakeups atomically). The C++/JSC side reaches this via two export fns: Bun__queueJSCDeferredWorkTaskConcurrently(*VirtualMachine, *JSCDeferredWorkTask) (allocates a ConcurrentTask{.auto_delete} and enqueues) and Bun__eventLoop__incrementRefConcurrently(*VirtualMachine, c_int) (atomic add to concurrent_ref + wakeup). concurrent_ref is folded into loop.num_polls/loop.active lazily by updateCounts() on the JS thread.
RUST: In bun_async::ffi expose #[no_mangle] pub extern "C" fn Bun__queueJSCDeferredWorkTaskConcurrently(vm: *mut VirtualMachine, work: *mut c_void) and #[no_mangle] pub extern "C" fn Bun__eventLoop__incrementRefConcurrently(vm: *mut VirtualMachine, delta: c_int). Additionally export a generic #[no_mangle] pub extern "C" fn bun_eventloop_enqueue_concurrent(loop: *mut EventLoop, task: *mut ConcurrentTask) so still-in-Zig callers can enqueue Rust-owned tasks during the incremental migration. Internally: (*loop).concurrent_tasks.push(task); us_wakeup_loop((*loop).usockets_loop()); — us_wakeup_loop stays an extern "C" import from usockets. concurrent_ref: AtomicI32 with fetch_add(.., SeqCst); update_counts swaps to 0 and applies to PosixLoop.{num_polls,active} on JS thread only.
PERF: enqueue_task_concurrent must be wait-free (no locks) and issue exactly one us_wakeup_loop per call. pending_wakeups short-circuit must survive so a flood of enqueues skips the GC safepoint. Measure: strace -e write count on eventfd during 100k cross-thread enqueues == Zig count.
SRC: /root/bun-5/src/bun.js/event_loop.zig (lines 641-669: enqueueTaskConcurrent = concurrent_tasks.push(task); this.wakeup();; lines 629-640 wakeup → loop.wakeup(); lines 671-680 refConcurrently/unrefConcurrently = concurrent_ref.fetchAdd/Sub(1,.seq_cst); wakeup(); lines 282-299 updateCounts swaps concurrent_ref into loop.num_polls/active); /root/bun-5/src/bun.js/event_loop/JSCScheduler.zig (lines 15-32: export fn Bun__eventLoop__incrementRefConcurrently and export fn Bun__queueJSCDeferredWorkTaskConcurrently — the C ABI surface C++ uses today); /root/bun-5/src/deps/uws/Loop.zig (lines 117-119: wakeup = c.us_wakeup_loop(this); lines 19-21: pending_wakeups: u32 atomically bumped by wakeup)
[bun_async::keep_alive] EL-KEEPALIVE-VS-REF
FACT: Async.KeepAlive and jsc.Ref are distinct, non-refcounted, idempotent 1-byte toggles that gate two DIFFERENT counters. KeepAlive.status: enum{active,inactive,done} calls loop.ref()/unref() which mutates PosixLoop.num_polls: i32 AND PosixLoop.active: u32 (the values usockets uses to decide whether epoll/kqueue blocks). jsc.Ref.has: bool mutates VirtualMachine.active_tasks: usize (checked by isEventLoopAlive to decide whether the process may exit). KeepAlive additionally has .done (terminal — further ref() is a no-op) and concurrent variants (unrefOnNextTickConcurrently does atomic RMW on vm.pending_unref_counter).
RUST: In bun_async: #[repr(u8)] pub enum KeepAliveStatus { Inactive=0, Active, Done } wrapped in pub struct KeepAlive(Cell<KeepAliveStatus>) with ref_(&self, loop_: &EventLoopHandle), unref, disable, unref_on_next_tick_concurrently(&self, vm: *mut VirtualMachine) (atomic fetch_add on pending_unref_counter). Separately pub struct ActiveTaskRef(Cell<bool>) (the jsc.Ref port) with ref_(&self, vm: &mut VirtualMachine) → vm.active_tasks += 1. Both are #[repr(transparent)] 1 byte, !Send/!Sync (concurrent paths take *mut VirtualMachine explicitly). DO NOT merge them into one type; DO NOT make either an Arc/refcount.
PERF: ref/unref on the JS thread must be a branch + two integer adds, no atomics. size_of::<KeepAlive>()==1, size_of::<ActiveTaskRef>()==1. Measure: perf record on test/js/node/timers hot path — KeepAlive::ref_ must not appear above noise floor; loop.active/num_polls values after a fixed sequence must be byte-identical to Zig (assert in a cross-impl test harness).
FACT: uws.PosixLoop is an extern struct that overlays the C struct us_loop_t byte-for-byte: internal_loop_data align(16), then num_polls/num_ready_polls/current_ready_poll/fd: i32, active: u32, pending_wakeups: u32, ready_polls: [1024]epoll_event align(16). Zig reads/writes num_polls and active directly (no FFI call) for ref/unref; everything else (tick, wakeup, run) is extern "c" calls into usockets (us_loop_run_bun_tick, us_wakeup_loop, uws_get_loop). Bun OWNS the loop — there is no tokio/async-std; the executor IS usockets' epoll/kqueue.
RUST: In bun_async::uws_loop: #[repr(C, align(16))] pub struct PosixLoop { pub internal_loop_data: InternalLoopData, pub num_polls: i32, pub num_ready_polls: i32, pub current_ready_poll: i32, pub fd: i32, pub active: u32, pub pending_wakeups: u32, pub ready_polls: [EpollEvent; 1024] } with #[cfg(target_os=...)] for EpollEvent vs kevent64_s vs Kevent. extern "C" { fn uws_get_loop() -> *mut PosixLoop; fn us_wakeup_loop(*mut PosixLoop); fn us_loop_run_bun_tick(*mut PosixLoop, *const Timespec); }. impl PosixLoop { #[inline] pub fn ref_(&mut self){ self.num_polls+=1; self.active+=1 } #[inline] pub fn unref(&mut self){ self.num_polls-=1; self.active=self.active.saturating_sub(1) } }. NO tokio::runtime, NO mio::Poll — Rust never owns an epoll fd; it borrows usockets'. Unsafe boundary: PosixLoop is only ever accessed via *mut obtained from uws_get_loop(); never constructed in Rust.
PERF: Field offsets must match C us_loop_t exactly (one wrong offset corrupts the ready*polls buffer). Add a C-side static_assert(offsetof(us_loop_t, active)==N) + Rust const *: () = assert!(offset_of!(PosixLoop, active)==N). ref/unref inline to 2 instructions. Measure: run full test/js/bun/http/serve.test.ts under Rust loop bindings — any offset mismatch crashes immediately.
FACT: AutoFlusher is a 1-byte mixin { registered: bool } embedded in flushable objects (PostgresSQLConnection, ValkeyClient, MySQL, HTTP response sinks). Registration inserts (ptr, &Type.onAutoFlush) into EventLoop.deferred_tasks: DeferredTaskQueue which is an AutoArrayHashMapUnmanaged(?*anyopaque, *const fn(*anyopaque)->bool). DeferredTaskQueue.run() is called after microtask drain and before the next macrotask; each callback returns bool = stay-registered. The map key IS the object pointer, so re-registration is O(1) idempotent and unregister is swapRemove.
RUST: In bun_async::deferred: #[repr(transparent)] pub struct AutoFlusher { pub registered: Cell<bool> } + pub trait AutoFlush { fn on_auto_flush(&mut self) -> bool; }. pub struct DeferredTaskQueue { map: IndexMap<*mut (), unsafe fn(*mut ())->bool, FxBuildHasher> } (IndexMap = insertion-ordered open-addressing, matching AutoArrayHashMap semantics including swap_remove_index during iteration). register<T: AutoFlush>(&mut self, this: *mut T) stores a monomorphized thunk |p| (&mut *(p as *mut T)).on_auto_flush(). run() iterates by index, swap-removing entries whose callback returns false, exactly mirroring the Zig loop (index does not advance on removal).
PERF: Registration when already registered must be a single bool check, no map probe. run() must not allocate. Map must preserve swap-remove-during-iterate semantics (a naive HashMap::retain changes iteration order vs Zig and can starve later entries). Measure: postgres pipelining benchmark (bench/sql) — flush batching latency p99 must match Zig.
FACT: ThreadPool.Task (kprotty's zap port) is struct { node: Node{next:?*Node}, callback: *const fn(*Task) } — an intrusive singly-linked node + bare fn-ptr, scheduled via Batch onto a lock-free MPMC Node.Queue (Treiber-stack with HAS_CACHE|IS_CONSUMING low-bit flags). WorkTask(Context) is the canonical state machine: it embeds BOTH a WorkPoolTask (for the pool) AND a ConcurrentTask (for the return trip) inline, plus a KeepAlive. Flow: schedule() refs KeepAlive + WorkPool.schedule(&this.task) → pool thread runs runFromThreadPool (recovers self via @fieldParentPtr("task", task)) → Context.run → onFinish() does event_loop.enqueueTaskConcurrent(this.concurrent_task.from(this,.manual_deinit)) → JS thread runFromJS unrefs + calls Context.then. Zero allocations after the initial bun.new.
RUST: Port src/threading/ThreadPool.zig verbatim into bun_async::thread_pool (NOT rayon, NOT tokio::spawn_blocking): #[repr(C)] pub struct Node { next: AtomicPtr<Node> }, #[repr(C)] pub struct PoolTask { node: Node, callback: unsafe fn(*mut PoolTask) }, Batch, Sync packed-state, Event futex. Then pub struct WorkTask<C: WorkTaskContext> { ctx: *mut C, task: PoolTask, concurrent_task: ConcurrentTask, event_loop: *mut EventLoop, keep_alive: KeepAlive, ... } with trait WorkTaskContext { fn run(&mut self, wt: &mut WorkTask<Self>); fn then(&mut self, global: *mut JSGlobalObject); }. run_from_thread_pool uses container_of!(task, WorkTask<C>, task). NO Future, NO Waker, NO Pin — the "continuation" is the embedded ConcurrentTask + a method name.
PERF: One WorkTask round-trip = 1 heap alloc total (the WorkTask itself). PoolTask and ConcurrentTask must be fields, not Box. schedule→then latency under contention must match Zig. Measure: test/js/node/fs async readFile throughput (uses WorkTask) and BUN_DEBUG_ALLOC=1 allocation count per op.
FACT: AnyTask ({ctx:?*anyopaque, callback:*const fn(*anyopaque)JSError!void}) and ManagedTask (same shape but heap-allocated and self-freeing in run()) are explicit ESCAPE HATCHES — the comments say 'slower wrapper, prefer adding a task type directly to Task'. They exist so callers that can't get a TaggedPointerUnion slot can still enqueue. Both are themselves variants in the Task union (so the dispatch switch hits AnyTask.run() which does the indirect call).
RUST: In bun_async::task: #[repr(C)] pub struct AnyTask { ctx: *mut c_void, callback: unsafe fn(*mut c_void) -> JsResult<()> } and #[repr(C)] pub struct ManagedTask { ctx: *mut c_void, callback: unsafe fn(*mut c_void) -> JsResult<()> } with ManagedTask::run doing let cb=self.callback; let ctx=self.ctx; bun_free(self as *mut _); cb(ctx). Provide AnyTask::new<T>(ctx: *mut T, f: fn(&mut T)->JsResult<()>) generating a monomorphized thunk. Keep these as TaskTag::AnyTask / TaskTag::ManagedTask variants. Document loudly: prefer adding a real TaskTag variant; these cost an extra indirect call.
PERF: AnyTask must stay 16 bytes (2 words) and be embeddable inline in parent structs (no Box). Adding a new first-class Task variant must remain a 2-line macro edit so contributors don't default to ManagedTask. Measure: grep count of ManagedTask::new in Rust code should not exceed Zig's ManagedTask.New count over time (CI lint).
SRC: /root/bun-5/src/bun.js/event_loop/AnyTask.zig (lines 1-33: 'slower wrapper... Prefer adding a task type directly to Task'; {ctx, callback} + New(Type, Callback) comptime thunk generator); /root/bun-5/src/bun.js/event_loop/ManagedTask.zig (lines 1-42: same shape, run does defer bun.default_allocator.destroy(this); New heap-allocates)
FACT: EventLoopTimer (src/bun.js/api/Timer/EventLoopTimer.zig) is an intrusive pairing-heap node embedded directly in owning structs — e.g. PostgresSQLConnection.timer and .max_lifetime_timer (PostgresSQLConnection.zig:50,59), Subprocess.event_loop_timer (subprocess.zig:43), js_valkey.zig:237 reconnect_timer. It is a non-extern Zig struct (file-level @This()) with fields { next: timespec, state: State{PENDING,ACTIVE,CANCELLED,FIRED}, tag: Tag, heap: IntrusiveField(Self){child,prev,next: ?*Self}, in_heap: enum{none,regular,fake} }; State/Tag/in_heap are plain (auto-sized) enums and IntrusiveField is also non-extern. Tag has 22 variants. fire() switches on tag and recovers the parent via @fieldParentPtr using FOUR distinct field names — "timer", "max_lifetime_timer", "reconnect_timer", and "event_loop_timer" (the inline else arm compile-asserts the field is named event_loop_timer) — so there is NO function pointer or vtable stored per timer. less() orders by sec, then nsec collapsed to ms granularity for JS timers, then by a wrapping u25 epoch for FIFO among equal-time JS timers. bun.timespec is already extern struct { sec: i64, nsec: i64 }.
RUST: In bun_async::timer. PREREQUISITE for mixed Zig/Rust embedding: convert the Zig side to well-defined layout — change EventLoopTimer to extern struct, IntrusiveField(T) to extern struct, and give State/Tag/in_heap explicit enum(u8) (bun.timespec is already extern struct{i64,i64}). Then mirror in Rust: #[repr(C)] pub struct EventLoopTimer { pub next: Timespec, pub state: TimerState, pub tag: TimerTag, pub heap: HeapNode, pub in_heap: InHeap } with #[repr(C)] struct Timespec{sec:i64,nsec:i64}, #[repr(C)] struct HeapNode{child:Option<NonNull<EventLoopTimer>>, prev:Option<NonNull<EventLoopTimer>>, next:Option<NonNull<EventLoopTimer>>} (Zig ?*T ↔ nullable pointer), #[repr(u8)] enum TimerState/TimerTag/InHeap. Port io/heap.zig's pairing heap as struct IntrusivePairingHeap<C: Fn(&EventLoopTimer,&EventLoopTimer)->bool> operating on raw *mut EventLoopTimer inside an unsafe boundary localized to meld/combine_siblings/remove. Dispatch: fire(&mut self, now, vm) is a match self.tag that does unsafe{ container_of!(self, $Parent, $field) }.on_fire(...); provide macro_rules! container_of over core::mem::offset_of!. A timer_tag! declarative macro registers each (TagVariant, ParentType, field_name, method) 4-tuple — note one parent type can host multiple timers under different field names (Postgres/MySQL: timer + max_lifetime_timer; Valkey: timer + reconnect_timer). Zero heap allocation per timer; no Box<dyn>.
PERF: size_of::<EventLoopTimer>() and EVERY field offset must equal the (now-extern) Zig struct: emit extern "C" constants from Zig (@sizeOf, @offsetOf for each of next/state/tag/heap.child/heap.prev/heap.next/in_heap) via bindgen and gate with const _: () = assert!(offset_of!(EventLoopTimer, field) == ZIG_EVENTLOOPTIMER_OFFSET_field) for each field — assert_eq_size! alone is INSUFFICIENT (Zig auto-layout reorders fields, so size match ≠ offset match). fire() must compile to a jump table with no indirect call. Heap insert/deleteMin must remain O(1)/O(log n) amortized with zero allocation. Benchmark: bench/snippets/set-timeout.mjs (1M setTimeout(0)) ops/sec must be ≥ the Zig build; verify with perf stat -e branches,branch-misses that fire-dispatch branch-miss rate does not regress.
SRC: /root/bun-5/src/bun.js/api/Timer/EventLoopTimer.zig (lines 1-9: file-level @This() (non-extern) with fields next: timespec, state: State, tag: Tag, heap: bun.io.heap.IntrusiveField(Self), in_heap: enum{none,regular,fake}; lines 51-73: Tag enum with 22 variants (TimerCallback…CronJob); lines 129-141: State plain enum {PENDING,ACTIVE,CANCELLED,FIRED}; lines 18-48: less() collapses nsec to ms for JS timers and tie-breaks via wrapping u25 epoch; lines 170-224: fire() switches on tag, uses @fieldParentPtr("timer",…) (172,174,176,189), @fieldParentPtr("max_lifetime_timer",…) (173,175), @fieldParentPtr("reconnect_timer",…) (177), and inline else at 201-219 compile-asserts and uses @fieldParentPtr("event_loop_timer",…) — no per-timer fn pointer.); /root/bun-5/src/io/heap.zig (lines 21-206: Intrusive(T,Context,less) pairing heap (insert/meld/deleteMin/remove/combine_siblings) operating on T.heap.{child,prev,next}; lines 211-217: IntrusiveField(T) = struct { child:?*T=null, prev:?*T=null, next:?*T=null } — plain (non-extern) struct.); /root/bun-5/src/bun.js/api/bun/subprocess.zig (line 43: event_loop_timer: bun.api.Timer.EventLoopTimer = .{ … } — Subprocess embeds the node as event_loop_timer, not timeout.); /root/bun-5/src/sql/postgres/PostgresSQLConnection.zig (line 50: timer: bun.api.Timer.EventLoopTimer; line 59: max_lifetime_timer: bun.api.Timer.EventLoopTimer — same parent type hosts two intrusive timer nodes under different field names.); /root/bun-5/src/valkey/js_valkey.zig (line 237: reconnect_timer: Timer.EventLoopTimer — fourth distinct field name used by fire()'s @fieldParentPtr.); /root/bun-5/src/bun.zig (line 3167: pub const timespec = extern struct { sec: i64, nsec: i64, … } — already C-ABI, so only EventLoopTimer/IntrusiveField/enums need extern/enum(u8) conversion for the ABI prerequisite.); /root/bun-5/bench/snippets/set-timeout.mjs (Benchmark file exists at this path (not setTimeout.mjs); use for the ops/sec regression gate.)
Ownership/containers: use Rust std — no bun_ptr crate
[bun_server (eliminate, do not port)] PTR-03-weakptr-single-consumer-techdebt
FACT: bun.ptr.WeakPtr has exactly ONE Zig consumer: Request.WeakRef (src/bun.js/webcore/Request.zig:43) stored as request_weakref on RequestContext (src/bun.js/api/server/RequestContext.zig:47). It is a packed u32 {reference_count: u31, finalized: bool} that defers bun.destroy of the Request allocation until both the JSC finalizer AND RequestContext have released it. This is a workaround for JSC GC finalization racing the HTTP response path — not a general-purpose weak reference.
RUST: DO NOT port WeakPtr. When bun_server crate is written, replace the pattern: Request becomes Rc<RequestInner> and RequestContext holds a strong Rc<RequestInner> clone (the GC-side JSRequest holds another strong via into_raw). The 'finalized' bit becomes a Cell<bool> detached inside RequestInner that request_context.get() checks. No std::rc::Weak needed because the current Zig WeakPtr is NOT a true weak (it keeps the allocation alive) — it's a 2-owner strong with a tombstone flag. Lives in crates/bun_server/src/request_context.rs. All other WeakPtr references in the repo are C++ WTF::WeakPtr (DOMURL, AbortSignal, ScriptExecutionContext) — those stay C++.
PERF: Hot path is request_weakref.get() in onAbort/finalize (RequestContext.zig:612,656,732). Replacement if !inner.detached.get() { Some(&inner) } is a single byte load + branch — same as current !data(value).finalized check. Measure: oha -z 30s against hello-world Bun.serve, p99 latency unchanged; no extra allocation per request (Rc header replaces the existing bun.new heap alloc 1:1).
[bun_socket / bun_http_client (Arc::into_raw at C boundary)] PTR-05-usockets-ext-stores-raw-self-ptr
FACT: uSockets allocates an ext-data region inline after each us_socket_t (C-owned memory). Bun stores a single raw *This (pointer to the Zig RefCount'd TCPSocket/TLSSocket) in that slot via s.ext(*This).* = this at connect time (socket.zig:139,143,156,162,503,1634), and reads it back in every callback. Separately, BoringSSL.SSL_set_ex_data(ssl, 0, this) stores the same raw pointer for ALPN callback retrieval (socket.zig:493, read at :16). The pointer is NOT refcounted by C; lifetime is managed by the Zig side calling ref()/deref() around the socket's open/close.
RUST: In crates/bun_socket/src/lib.rs: struct TcpSocket { ... } is created as Rc<TcpSocket> (single-threaded — uSockets is single-loop). On connect: let raw = Rc::into_raw(rc.clone()); us_socket_ext(s).cast::<*const TcpSocket>().write(raw);. Every uSockets callback trampoline (extern "C" fn on_open(s: *mut us_socket_t)) does let this = unsafe { &*(*us_socket_ext(s).cast::<*const TcpSocket>()) }; (borrow, no refcount touch). On close: drop(Rc::from_raw(...)) consumes the stored ref. SSL ex_data uses the SAME raw pointer (no second into_raw — it's a borrow of the ext-slot's ref). The unsafe boundary is a single module crates/bun_socket/src/ext_slot.rs with unsafe fn store<T>(s, rc) / unsafe fn load<T>(s) -> &T / unsafe fn take<T>(s) -> Rc<T>. No #[repr(C)] on TcpSocket — C never reads its fields, only stores the pointer.
PERF: Callback hot path = 1 pointer deref (us_socket_ext returns ptr+offset) + 1 load — identical to Zig's socket.ext(**anyopaque).*. Zero atomic ops in on_data/on_writable. Measure: tcp-echo-benchmark (test/js/bun/net/) bytes/sec within ±1%; perf record must show no __rust_alloc or atomic in on_data path.
SRC: /root/bun-5/src/bun.js/api/bun/socket.zig (L139,143,156,162: s.ext(*This).* = this; / c.ext(*This).* = this; — store raw self ptr in C-owned ext slot at connect); /root/bun-5/src/bun.js/api/bun/socket.zig (L493: BoringSSL.SSL_set_ex_data(ssl_ptr, 0, this); L16: SSL_get_ex_data(ssl, 0) cast to *TLSSocket in ALPN callback); /root/bun-5/src/deps/uws/us_socket_t.zig (L133-141: pub fn ext(this, T) *T { return @ptrCast(@alignCast(c.us_socket_ext(this))); } — ext is C-allocated bytes after the socket struct)
[inline slab in each consumer crate (bun_server, bun_dns, bun_event_loop, bun_install)] PTR-07-hivearray-is-per-subsystem-slab
FACT: HiveArray<T,N> is a fixed-capacity slab: [N]T buffer + IntegerBitSet(N) free-mask, with .Fallback wrapper that spills to heap allocator when full. It is NOT 'only used by RequestContext' — consumers: RequestContext(2048), FilePoll(128 posix+windows), H2FrameParser(256), DNS PendingCache×15(32 each), PackageManager Task(64)/NetworkTask(128), Body.HiveRef, TranspilerJob(64), DevServer DeferredRequest, renamer NumberScope(128), HTTPContext PooledSocket. Each consumer instantiates with a different N tuned to its workload.
RUST: NO shared slab crate. Each ported subsystem inlines its own: struct Slab<T, const N: usize> { buf: Box<[MaybeUninit<T>; N]>, used: [u64; (N+63)/64] } defined in the crate that needs it (e.g. crates/bun_server/src/slab.rs for RequestContext, crates/bun_dns/src/pending_cache.rs for DNS). The .Fallback behavior = fn get(&mut self) -> *mut T { self.slab_get().unwrap_or_else(|| Box::into_raw(Box::new_uninit())) }. Because slab entries are handed out as stable *mut T stored in C callbacks (FilePoll → kqueue/epoll, RequestContext → uWS response), the slab MUST be pinned/boxed — Box<[_; N]> not stack array. Returned pointers are opaque at FFI; Zig never indexes into the Rust slab.
PERF: get() = bitset.trailing_zeros() + index — single BSF/TZCNT instruction, same as Zig's findFirstUnset(). put() = pointer-subtract + bit-clear. Zero allocation on hot path until N exceeded. Measure: wrk -c 2000 against Bun.serve (exercises RequestContext slab near capacity) — allocations/req must stay 0 for first 2048 concurrent; bun bd test test/js/bun/dns/ latency unchanged.
FACT: Bun uses both std.MultiArrayList and a forked src/collections/multi_array_list.zig for struct-of-arrays storage: router Param/RouteIndex lists, Watcher.WatchList, url Param.List, resolver PendingResolution.List, options Entry.List, and heavily in install/lockfile. The fork exists for minor API additions, not layout changes. Each instantiation has a different field set — there is no shared ABI.
RUST: Per-use-site: either soa_derive crate's #[derive(StructOfArray)] or a hand-written struct ParamList { names: Vec<StringId>, values: Vec<StringId> } in the consuming crate (crates/bun_router/src/params.rs, crates/bun_install/src/lockfile/packages.rs). No bun_collections::MultiArrayList<T> generic — Rust can't introspect struct fields at compile time without proc-macros, and a shared proc-macro crate would be the bun_ptr anti-pattern under another name. These never cross FFI by value; lockfile serialization already goes through a custom binary format, and router params are consumed Zig-side only.
PERF: SoA column access = base + idx*sizeof(field), identical to Zig .items(.field)[i]. Cache behavior is the point: iterating one column must not pull other columns. Measure: bun install on a 2000-dep lockfile, wall time within ±2%; perf stat -e L1-dcache-misses on lockfile parse unchanged.
SRC: /root/bun-5/src/collections/multi_array_list.zig (L1-19: 'Copy of std.MultiArrayList with changes'; stores each field in separate array); /root/bun-5/src/router.zig (L15,82: Param.List = std.MultiArrayList(Param), RouteIndex.List = std.MultiArrayList(RouteIndex)); /root/bun-5/src/Watcher.zig (L50: WatchList = std.MultiArrayList(WatchItem) — fs watcher hot list)
FACT: bun.ptr.RefCount is an INTRUSIVE, NON-ATOMIC mixin: it injects raw_count: u32 plus a debug-only bun.safety.ThreadLock (zero-sized in release — owning_thread: if (enabled) Thread.Id else void) into the host struct, and ref() is a plain count.raw_count += 1 with no atomic and no overflow guard. 57 instantiation sites across 48 files use it (s3/credentials.zig, s3/multipart.zig, valkey/js_valkey.zig, http/websocket_client.zig, http/h2_client/ClientSession.zig, http/h3_client/ClientSession.zig, sql/postgres/PostgresSQLConnection.zig, bun.js/api/bun/socket.zig TCPSocket/TLSSocket, etc). Because the count is a struct field at a Zig-chosen offset, sharing the same field between Zig and Rust would require both languages to agree on layout and increment semantics — exactly the cross-language struct-field sharing the migration forbids.
RUST: DO NOT create a bun_ptr::RefCount<T> crate. When a subsystem is ported, the ref-counted type becomes std::rc::Rc<T> inside that crate; the intrusive-field pattern is dropped (Rust's RcInner stores strong+weak as two usize in a heap header before T, not inside T). For the FFI edge the crate exports:
These std helpers (stable 1.53) are the ONLY sound formulation — mem::forget(Rc::from_raw(p).clone()) is a net-zero no-op (the from_raw temporary drops at end-of-statement and undoes the clone's +1) and would cause UAF; it is explicitly banned. Zig callers hold only an opaque *anyopaque and never read a count field.
SCOPE OF THE EXTERN FNS: they exist solely as a transitional escape hatch for ownership HANDOFF at creation/destruction edges (e.g. Zig stores a Rust-allocated connection in a map, releases it on close) — call frequency O(object-lifetime), not O(iteration). They MUST have zero callers on hot per-iteration paths once a subsystem port lands. If a hot path in Zig still needs to bump a Rust-owned count, that is the signal the migration unit was drawn too small — port the caller too. This resolves the apparent contradiction: the fns are real but their steady-state call count is ~0; any non-zero hot-path usage is a bug in the port plan, not an accepted cost.
PERF: Within the ported crate, Rc::clone / Rc::increment_strong_count must compile to a NON-ATOMIC sequence with no heap alloc and no extern call in the body. Realistic release codegen is ≤7 instructions: pointer-offset sub (data ptr → RcInner header, −16 on 64-bit), load usize strong, cmp-against-usize::MAX + je abort (std's overflow guard), inc, store, ret. This is strictly more than Zig's inlined 4-byte += 1 (no offset sub, no overflow branch, 4-byte counter vs 16-byte header) — the delta is accepted because it stays inside Rust where it inlines; the extern xxx_ref/xxx_deref add a non-inlineable call+ret and MUST NOT appear on hot paths. Measure with cargo asm --lib '<crate>::xxx_ref' and assert: (a) no lock prefix, (b) no call other than the abort cold-path, (c) ≤7 insns on the non-abort path. Separately, rg for Zig call sites of xxx_ref/xxx_deref after each subsystem lands — count must be O(1) per object lifetime, never inside a loop body.
SRC: /root/bun-5/src/ptr/ref_count.zig (L69 raw_count: u32; L70 thread: bun.safety.ThreadLock; L113-114 count.assertSingleThreaded(); count.raw_count += 1; — plain non-atomic increment, no overflow guard); /root/bun-5/src/safety/ThreadLock.zig (L3 owning_thread: if (enabled) Thread.Id else void — field is zero-sized in release builds, so RefCount adds exactly 4 bytes in release); /root/bun-5/src/bun.js/api/bun/socket.zig (L49 const RefCount = bun.ptr.RefCount(@This(), "ref_count", deinit, .{}); and L61 ref_count: RefCount, — intrusive field on TCPSocket/TLSSocket); /root/bun-5/src/http/h2_client/ClientSession.zig (L13 const RefCount = bun.ptr.RefCount(@This(), "ref_count", deinit, .{}); L19 ref_count: RefCount, — single-threaded RefCount on HTTP/2 client session); /root/bun-5/src (rg -l 'bun\.ptr\.RefCount\(' → 48 files; rg -c summed → 57 instantiation sites; includes s3/credentials.zig, s3/multipart.zig, s3/client.zig, valkey/js_valkey.zig, sql/postgres/PostgresSQL{Connection,Statement,Query}.zig, http/websocket_client.zig, http/h3_client/ClientSession.zig, http/websocket_client/WebSocket{UpgradeClient,ProxyTunnel}.zig)
FACT: bun.ptr.ThreadSafeRefCount is an intrusive atomic refcount mixin: raw_count: std.atomic.Value(u32) with .fetchAdd(1, .seq_cst) on ref and .fetchSub(1, .seq_cst) on deref, running the destructor when old_count == 1. Exactly 9 Zig types use it: JSBundleCompletionTask (bundle_v2.zig:1929 — once per Bun.build() JS call, NOT per parsed file), LinuxMemFdAllocator (LinuxMemFdAllocator.zig:20), ThreadSafeStreamBuffer (http/ThreadSafeStreamBuffer.zig:23 — .initExactRefs(2) for main+http thread), ParsedSourceMap (sourcemap/ParsedSourceMap.zig:3 — shared via thread-safe SavedSourceMap store), Process (process.zig:194 — ref'd repeatedly across signal/uv-poll watchers, e.g. lines 381/401/419/432/509/746), BlockList (net/BlockList.zig:1), StatWatcher (node_fs_stat_watcher.zig:198), StatWatcherScheduler (node_fs_stat_watcher.zig:26), RefCountedEnvValue (rare_data.zig:234).
RUST: Replace each user with std::sync::Arc<T> defined inside its owning crate (bun_http::ThreadSafeStreamBuffer, bun_process::Process, bun_sourcemap::ParsedSourceMap, bun_fs_watch::{StatWatcher,StatWatcherScheduler}, bun_bundler::JSBundleCompletionTask, bun_allocators::LinuxMemFdAllocator, bun_runtime::{BlockList,RefCountedEnvValue}) — no shared bun_ptr crate. Arc's Acquire/Release ordering is weaker than Zig's seq_cst but is the correct minimum for refcount semantics; Zig's seq_cst is over-specified and not load-bearing. For C-stored handles: ONE-SHOT callbacks (e.g. JSBundleCompletionTask posted to a thread queue) use Arc::into_raw() → store *const T in C → trampoline does Arc::from_raw() exactly once. REPEATED callbacks (e.g. Process held by a uv_poll/signal watcher that fires many times, StatWatcher held by a recurring timer) MUST instead use Arc::into_raw() to seed the raw pointer, then Arc::increment_strong_count(ptr) / Arc::decrement_strong_count(ptr) (or ManuallyDrop::new(Arc::from_raw(ptr)) per fire) so the count is bumped per ref()/deref() exactly as Zig does today; a single from_raw would double-free on the second fire. The extern boundary signature stays extern "C" fn(ctx: *const c_void) — never expose #[repr(C)] struct { count: AtomicU32, ... }.
PERF: Arc's fetch_add(Relaxed)/fetch_sub(Release)+Acquire-fence is ≤ the cost of Zig's seq_cst RMW, so refcount ops cannot regress. The two users where ref/deref runs on a hot path are ThreadSafeStreamBuffer (one ref+deref pair per HTTP streaming request body, crossing main↔http thread) and ParsedSourceMap (one ref per stack frame remapped in error/Bun.inspect). Verify with perf stat -e instructions,cache-misses on bun bd test test/js/web/fetch/body-stream.test.ts (streaming body) and bun bd test test/js/bun/util/error-gc-test.test.ts (sourcemap remap) — Rust build instruction count must be ≤ Zig build. Do NOT benchmark via test/bundler/: JSBundleCompletionTask is allocated once per Bun.build() call and is not on the per-file bundler hot path.
SRC: /root/bun-5/src/ptr/ref_count.zig (L215-266: pub fn ThreadSafeRefCount(...) returns struct with raw_count: std.atomic.Value(u32); L238 count.raw_count.fetchAdd(1, .seq_cst); L252 count.raw_count.fetchSub(1, .seq_cst); L261 if (old_count == 1) { ... destructor(self); }); /root/bun-5/src/bundler/bundle_v2.zig (L1928-1933: pub const JSBundleCompletionTask = struct { pub const RefCount = bun.ptr.ThreadSafeRefCount(@This(), ...) } — created once per Bun.build() at L1849 bun.new(JSBundleCompletionTask, ...). rg 'ParseResult' src/bundler/bundle_v2.zig returns zero matches.); /root/bun-5/src/http/ThreadSafeStreamBuffer.zig (L5 ref_count: StreamBufferRefCount = .initExactRefs(2), // 1 for main thread and 1 for http thread; L23 bun.ptr.ThreadSafeRefCount(@This(), ...) — confirmed cross-thread hot user); /root/bun-5/src/sourcemap/ParsedSourceMap.zig (L3 bun.ptr.ThreadSafeRefCount(@This(), ...); L7-9 doc comment: 'can be acquired by different threads via the thread-safe source map store (SavedSourceMap), so the reference count must be thread-safe'); /root/bun-5/src/bun.js/api/bun/process.zig (L194 bun.ptr.ThreadSafeRefCount(@This(), ...) on Process; L381/401/419/432/509/746 each call this.ref()/process.ref() from poller/watcher setup paths — proves the C-side handle is ref'd/deref'd MULTIPLE times, not one-shot); /root/bun-5/src/bun.js/node/node_fs_stat_watcher.zig (L26 bun.ptr.ThreadSafeRefCount(StatWatcherScheduler, ...); L198 bun.ptr.ThreadSafeRefCount(StatWatcher, ...)); /root/bun-5/src/bun.js/rare_data.zig (L234 bun.ptr.ThreadSafeRefCount(@This(), "ref_count", RefCountedEnvValue.destroy, .{}) on RefCountedEnvValue); /root/bun-5/src/allocators/LinuxMemFdAllocator.zig (L20 bun.ptr.ThreadSafeRefCount(@This(), ...)); /root/bun-5/src/bun.js/node/net/BlockList.zig (L1 bun.ptr.ThreadSafeRefCount(@This(), ...))
FACT: BabyList is {ptr: [*]T, len: u32, cap: u32} = 16 bytes on 64-bit (vs std ArrayList's 24); the #origin/#allocator safety fields are void in release. It does NOT cross the C++ FFI boundary by value — grep of src/bun.js/bindings/.{h,cpp} returns zero BabyList references; the existing Zig→C++ convention is to pass (ptr, len) pairs (ZigString.zig L570, JSUint8Array.zig L14/L20). Usage is concentrated in src/bundler/ (107 refs) and src/css/ (70 refs), with src/ast/+ast.zig at 32 and src/js_parser.zig at 0. In the AST, ExprNodeList = BabyList(Expr) but StmtNodeList = []Stmt is a plain slice; each E. union variant embeds at most one BabyList (E.Array.items, E.Call.args, E.New.args, E.Object.properties, E.JSXElement.children), not 3-4.
RUST: Default everywhere (server, install, http, shell, sql): std Vec<T> (24B). Do NOT introduce a shared bun_ptr::ThinVec. For the hot AST-embedded fields only — E.Array.items, E.Call.args, E.New.args, E.Object.properties/E.JSXElement.properties (G.Property.List), E.JSXElement.children, G.Decl.List — define a crate-local #[repr(C)] struct ThinVec<T> { ptr: NonNull<T>, len: u32, cap: u32 } in crates/bun_ast/src/thin_vec.rs, re-exported to crates/bun_bundler and crates/bun_css (the two heaviest consumers). StmtNodeList/BindingNodeList are plain slices today, so they map to Box<[Stmt]>/&[Stmt], NOT Vec/ThinVec. At every extern "C" boundary destructure to (ptr: *const T, len: usize) — never pass Vec or ThinVec by value; this is byte-identical to the existing ZigString/JSUint8Array convention.
PERF: Gate: bench/bundle/run-bench.sh (the documented 10×three.js bundler benchmark, /root/bun-5/bench/bundle/README.md) measured with /usr/bin/time -v — peak RSS of the Rust build must be ≤ Zig release build, and wall-clock real within noise. Because each Expr.Data variant embeds ≤1 BabyList, swapping 16B→24B Vec costs ≤8 bytes per E.Array/E.Call/E.New/E.Object/E.JSXElement node only (not per Expr); if RSS regresses, the fix is the crate-local ThinVec on exactly those six fields — no global container crate. FFI invariant: extern "C" signatures stay (*const T, usize) so the C++ side (bindings/*.cpp) needs zero changes.
FACT: Bun's Zig↔C++ FFI boundary uses three mechanisms, not one: (a) opaque pointer types for JSC/WebCore objects (URL, JSGlobalObject, Exception); (b) raw (ptr,len) pairs; and critically (c) a fixed catalog of hand-mirrored SHARED-LAYOUT aggregate structs — ZigString, BunString (tag+union), ZigErrorType, ErrorableZigString/ErrorableString/ErrorableResolvedSource (Result-style tagged unions), ResolvedSource, SystemError, ZigStackFramePosition, ZigStackFrame, ZigStackTrace, ZigException — each defined once in C++ at headers-handwritten.h and once in Zig as extern struct (string.zig, ZigException.zig, ResolvedSource.zig). Many extern fns return these aggregates BY VALUE across the C ABI (URL__protocol(*URL) → BunString, ZigException__fromException(*Exception) → ZigException). Bun does NOT consume this boundary via @cImport — the only live @cImport in src/ is src/test/recover.zig:87 for setjmp; the dual definitions are hand-maintained, augmented by src/codegen/generate-classes.ts for class bindings. Scope correction: Zig's bun.ptr.{RefCount,WeakPtr,TaggedPointerUnion,BabyList,HiveArray} container types have no C++ layout mirror and stay Zig-side; however the literal names RefCounted/WeakPtr DO appear pervasively in src/bun.js/bindings/*.{h,cpp} as WTF/WebCore types (DOMURL.h:43, DOMFormData.h:48) — those are unrelated C++-only types, not the Zig containers.
RUST: Mirror category (c) in a single crates/bun-ffi/src/abi.rs of #[repr(C)] structs/unions/enums byte-identical to headers-handwritten.h: #[repr(C)] pub struct ZigString { ptr: *const u8, len: usize }, #[repr(u8)] pub enum BunStringTag {…}, #[repr(C)] pub union BunStringImpl { zig: ZigString, wtf: *mut c_void }, #[repr(C)] pub struct BunString { tag: BunStringTag, impl_: BunStringImpl }, and likewise ZigErrorType, Errorable<T> (#[repr(C)] struct { result: #[repr(C)] union{value:T, err:ZigErrorType}, success: bool }), ResolvedSource, SystemError, ZigStackFrame, ZigStackTrace, ZigException. extern "C" fns may take and RETURN these aggregates by value — System V / Win64 lower this to a hidden sret pointer exactly as Zig does today; restricting signatures to only *c_void/ints/(ptr,len) would be NARROWER than the current ABI and is rejected. All extern "C" + #[unsafe(no_mangle)] declarations live only in crates/*/src/ffi.rs. Header strategy: cbindgen over bun-ffi emits the single-source-of-truth .h that supersedes headers-handwritten.h; the Zig side continues to declare hand-written extern fn + extern struct against it (NOT @cImport — matching existing practice). FFI-safety enforcement: #![deny(improper_ctypes, improper_ctypes_definitions)] at crate root (the rustc lint that actually inspects parameter/return types — nm cannot, it only sees symbol names), plus a belt-and-suspenders CI check that scans struct BODIES, not the attribute line: rg -U --multiline '#\[repr\(C\)\][^}]*?\b(Rc|Arc|Vec|Box|String)\s*<' crates/ (the original single-line rg '#\[repr\(C\)\]' | rg 'Rc<…' is vacuous because the attribute sits alone on its own line).
PERF: Layout parity is the invariant: for every mirrored struct, a const _: () = { assert!(size_of::<BunString>()==24); assert!(offset_of!(BunString, impl_)==8); … }; block in abi.rs compiled on every target, paired with static_assert(sizeof(BunString)==24) on the C++ side — build fails on drift. By-value aggregate return must lower to the same sret ABI as today: objdump -d of the Rust URL__protocol vs the current Zig impl both show the hidden first-arg result pointer and a 24-byte store, with zero heap allocation and no trampoline. Gate is -D improper_ctypes_definitions (compile-time fail on any FFI-unsafe signature), which replaces the unverifiable nm check — nm reports only the unmangled name #[no_mangle] already guarantees and proves nothing about argument types.
SRC: /root/bun-5/src/bun.js/bindings/headers-handwritten.h (L19-22 typedef struct ZigString{const unsigned char* ptr; size_t len;}; L58-93 BunString{BunStringTag tag; BunStringImpl impl;} (tag+union aggregate); L95-114 ZigErrorType/ErrorableZigString/ErrorableString; L115-133 ResolvedSource (composite of 4 BunStrings + bool + EncodedJSValue + ptrs); L144-154 SystemError; L176-210 ZigStackFramePosition/ZigStackFrame; L212-220 ZigStackTrace; L223-236 ZigException — the catalog of non-opaque shared-layout structs that cross the FFI.); /root/bun-5/src/string.zig (L42-46 pub const String = extern struct { pub const name = "BunString"; tag: Tag, value: StringImpl, } — Zig-side mirror of headers-handwritten.h BunString; L71 extern fn BunString__transferToJS(this: *String, globalThis: *jsc.JSGlobalObject) jsc.JSValue — non-opaque struct passed by pointer across FFI.); /root/bun-5/src/bun.js/bindings/URL.zig (L1 pub const URL = opaque {; L4-19 extern fn URL__protocol(*URL) String;extern fn URL__href(*URL) String; … — opaque receiver, BunString aggregate RETURNED BY VALUE across the C ABI (sret), demonstrating the boundary is broader than (ptr,len)/opaque-only.); /root/bun-5/src/bun.js/bindings/ZigException.zig (L2 pub const ZigException = extern struct { — Zig mirror; L123 extern fn ZigException__fromException(*Exception) ZigException; — large composite aggregate returned by value from C++.); /root/bun-5/src/bun.js/bindings/ResolvedSource.zig (L1 pub const ResolvedSource = extern struct { — Zig mirror of headers-handwritten.h ResolvedSource, hand-maintained dual definition (not @cImport-generated).); /root/bun-5/src/bun.js/bindings/DOMURL.h (L43 class DOMURL final : public RefCounted<DOMURL>, public CanMakeWeakPtr<DOMURL>, public URLDecomposition { — WTF RefCounted/WeakPtr ARE present in bindings/*.{h,cpp} (also DOMFormData.h:48 RefCounted<DOMFormData>); only the Zig bun.ptr.* container types lack C++ mirrors — the original unscoped grep claim was false.); /root/bun-5/src/test/recover.zig (L87 const jmp_buf = @cImport(@cInclude("setjmp.h")).jmp_buf; — the only non-comment @cImport under src/ (rg '@cImport' src/ --type zig returns this plus 3 commented-out size annotations in src/bun.js/node/zlib/). The JSC bindings boundary uses hand-written extern fn + headers-handwritten.h, not @cImport.)
FACT: Bun has NO CMakeLists.txt — the build is a TypeScript-generated Ninja graph (scripts/build/). The final link step link(n, cfg, exeName, [...allObjects, ...zigObjects, ...windowsRes], { libs: depLibs, ... }) combines (a) compiled C/C++ objects, (b) the Zig object bun-zig.o (or shards), and (c) depLibs — an array of static-library paths produced by each Dependency. lolhtml is the existing precedent: a kind: "cargo" dep that produces liblolhtml.a and is appended to depLibs exactly like a C library.
RUST: Create crates/ cargo workspace with a single top-level crate-type = ["staticlib"] crate bun-rs that re-exports all sub-crates. Register it as scripts/build/deps/bun-rs.ts returning a Dependency with build: cfg => ({ kind: "cargo", manifestDir: ".", libName: "bun_rs", rustflags, rustTarget, buildStd }) and source: { kind: "local", path: "crates" }. resolveDep() will route it through emitCargo() and push <buildDir>/deps/bun-rs/<triple>/<profile>/libbun_rs.a into depLibs, which link() already consumes — no new link logic. The unsafe boundary is the staticlib's #[no_mangle] pub extern "C" surface; all intra-Rust code stays safe.
PERF: Adding libbun_rs.a to depLibs must not change link semantics vs. moving symbols out of bun-zig.o: lld resolves archives lazily, so dead Rust code is dropped (matches Zig's --gc-sections). Verify: nm build/release/bun-profile | wc -l unchanged ±1% after migrating a module; emitSmokeTest (<exe> --revision) passes (catches missing-symbol/load-time ABI mismatch).
SRC: /root/bun-5/scripts/build/bun.ts (lines 466-472: link(n, cfg, exeName, [...allObjects, ...zigObjects, ...windowsRes], { libs: depLibs, flags: [...flags.ldflags, ...systemLibs(cfg), ...] }) — depLibs is the slot for libbun_rs.a); /root/bun-5/scripts/build/bun.ts (lines 175-199: dep resolution loop populating depLibs.push(...d.libs) from every ResolvedDep); /root/bun-5/scripts/build/deps/lolhtml.ts (entire file: working CargoBuild precedent — { kind: "cargo", manifestDir: "c-api", libName: "lolhtml" } with rustflags/rustTarget/buildStd); /root/bun-5/scripts/build/source.ts (lines 1323-1418: emitCargo() constructs lib path <target-dir>/<triple?>/<profile>/lib<name>.a, registers dep_cargo ninja rule with restat=1)
[crates/bun-alloc (GlobalAlloc impl)] BUILD-03
FACT: Zig's bun.default_allocator is allocators.c_allocator, a hand-written std.mem.Allocator vtable whose alloc/free call extern fn mi_malloc / mi_malloc_aligned / mi_free / mi_free_size_aligned directly. mimalloc is built from the vendored oven-sh/mimalloc fork as a SINGLE TU src/static.c (compiled as C++), with MI_MALLOC_OVERRIDE only on Linux-non-ASAN, MI_DEFAULT_ALLOW_THP=0, -ftls-model=initial-exec (or local-dynamic on musl). There is exactly one mimalloc copy in the binary; Zig, C++, and the malloc-override all hit it.
RUST: crates/bun-alloc/src/lib.rs: extern "C" { fn mi_malloc_aligned(size: usize, align: usize) -> *mut u8; fn mi_free(p: *mut u8); fn mi_realloc_aligned(p: *mut u8, n: usize, a: usize) -> *mut u8; fn mi_is_in_heap_region(p: *const u8) -> bool; } then pub struct Mi; unsafe impl GlobalAlloc for Mi { ... }. In crates/bun-rs/src/lib.rs: #[global_allocator] static A: bun_alloc::Mi = bun_alloc::Mi;. CRITICAL: do NOT depend on the mimalloc crate from crates.io — it would link a second mimalloc copy. Symbols resolve at final link against the existing static.c.o already in allObjects. The unsafe boundary is the four extern fns + the GlobalAlloc impl.
PERF: Every Box/Vec/String in Rust must land in the SAME mimalloc heap as Zig's bun.new() so cross-language ownership transfer is mi_free-safe. Test: assert!(mi_is_in_heap_region(Box::into_raw(Box::new(0u8)) as _)). RSS regression gate: bun --eval 1 peak RSS ≤ 30MB (the THP-off baseline cited in mimalloc.ts); measure with /usr/bin/time -v.
SRC: /root/bun-5/src/bun.zig (line 14: pub const default_allocator: std.mem.Allocator = allocators.c_allocator; and line 13: use_mimalloc = @import("build_options").use_mimalloc); /root/bun-5/src/allocators/basic.zig (lines 10-71: c_allocator vtable calling mimalloc.mi_malloc, mi_malloc_aligned, mi_free, mi_free_size_aligned); /root/bun-5/src/allocators/mimalloc.zig (lines 1-50: raw pub extern fn mi_* declarations — Rust must declare the identical symbols); /root/bun-5/scripts/build/deps/mimalloc.ts (DirectBuild: sources: ["src/static.c"], lang: "cxx", MI_MALLOC_OVERRIDE gated to linux&&!asan, MI_DEFAULT_ALLOW_THP=0 with measured 30MB/52MB RSS figures, -ftls-model=initial-exec)
FACT: Zig's root module sets pub const panic = _bun.crash_handler.panic (= panicImpl), which prints a bun.report trace string and aborts. C++ reaches the same handler via extern "C" void Bun__crashHandler(const char* msg, size_t len) noreturn. The lolhtml dep already builds Rust with -Cpanic=abort -Cforce-unwind-tables=no on unix, and in release additionally -Zbuild-std=std,panic_abort + -Cpanic=immediate-abort to drop gimli/addr2line/miniz_oxide (~230KB). Windows MUST keep unwind tables (SEH).
RUST: Workspace Cargo.toml: [profile.dev] panic = "abort" and [profile.release] panic = "abort". crates/bun-panic/src/lib.rs: extern "C" { fn Bun__crashHandler(ptr: *const u8, len: usize) -> !; } and a ctor-registered hook: pub fn install() { std::panic::set_hook(Box::new(|info| { let msg = format!("{info}"); unsafe { Bun__crashHandler(msg.as_ptr(), msg.len()) } })); } called from a #[ctor] or from Zig's crash_handler.init() via extern "C" fn bun_rs_panic_install(). bun-rs.ts mirrors lolhtml's rustflags exactly, including the cfg.windows guard that omits -Cforce-unwind-tables=no and the cfg.release && canBuildStdImmediateAbort gate for -Zbuild-std.
PERF: No .gcc_except_table / landing-pad sections in libbun_rs.a on unix (panic=abort eliminates them). Verify: llvm-objdump -h libbun_rs.a | grep -c except_table == 0. Release binary size: rust panic machinery must add ≤0 bytes vs. Zig (immediate-abort path drops the formatter); compare size build/release/bun before/after migrating a module.
SRC: /root/bun-5/src/main.zig (line 1: pub const panic = _bun.crash_handler.panic; line 22: _bun.crash_handler.init();); /root/bun-5/src/crash_handler.zig (line 816 panicImpl(msg, ert, addr) noreturn; line 832 pub const panic = if (enable) panicImpl else panicBuiltin; line 2313 export fn Bun__crashHandler(message_ptr: [*]u8, message_len: usize) noreturn); /root/bun-5/src/bun.js/bindings/napi.h (line 28: extern "C" void Bun__crashHandler(const char* message, size_t message_len); — C++ already calls this symbol); /root/bun-5/scripts/build/deps/lolhtml.ts (lines 47-56 -Cpanic=abort -Cforce-unwind-tables=no (unix-only, Windows requires SEH); lines 86-100 release -Zbuild-std=std,panic_abort + -Cpanic=immediate-abort to drop ~230KB of backtrace machinery)
FACT: Today's cross-language layout check is RUNTIME, not compile-time: generate-classes.ts emits Zig export const <Type>__ZigStructSize: usize = @sizeOf(<Type>) and C++ extern "C" const size_t <Type>__ZigStructSize; — the value crosses at link time and is read at runtime (used in estimatedSize). C++ separately exports extern "C" const size_t <Type>__ptrOffset = JS<Type>::offsetOfWrapped(). There is no static_assert that Zig and C++ agree on a struct's layout; agreement is by-convention via extern struct / #[repr(C)]-equivalents.
RUST: Add a b.addExecutable("emit-layouts") step in build.zig (precedent: lines 563/634 already build helper exes) whose main does inline for (types) |T| writer.print("{s} {d} {d}\n", .{@typeName(T), @sizeOf(T), @alignOf(T)}) plus @offsetOf per field, writing <codegenDir>/zig-layouts.json. Wire as a ninja codegen rule with restat=1 (output goes in o.rsInputs). crates/bun-ffi/build.rs reads env!("BUN_CODEGEN_DIR")/zig-layouts.json and emits OUT_DIR/layout_asserts.rs containing const _: () = assert!(core::mem::size_of::<crate::T>() == N && core::mem::align_of::<crate::T>() == A && core::mem::offset_of!(crate::T, f) == O); for every #[repr(C)] mirror struct. Ninja passes BUN_CODEGEN_DIR to cargo via the env: map in emitCargo (same mechanism as CARGO_ENCODED_RUSTFLAGS).
PERF: All assertions are const _:() = assert!(..) — evaluated at rustc compile time, zero bytes in the binary. Measure: nm libbun_rs.a | grep layout_assert is empty; cargo build --timings shows bun-ffi build.rs < 100ms (JSON parse only).
SRC: /root/bun-5/src/codegen/generate-classes.ts (line 2176 emits export const <Name>__ZigStructSize: usize = @sizeOf(<Name>) into ZigGeneratedClasses.zig; line 1738 emits matching extern "C" const size_t in .cpp; line 1862 <Type>__ptrOffset = ::offsetOfWrapped(); line 1497 OBJECT_OFFSETOF(${name}, m_ctx)); /root/bun-5/build.zig (lines 563, 634, 771: b.addExecutable helper-exe pattern already used for build-time generators — same pattern hosts the comptime @sizeOf/@alignOf/@offsetOf emitter); /root/bun-5/scripts/build/source.ts (lines 1350-1360: emitCargo passes env via --env=K=V through stream.ts — hook for BUN_CODEGEN_DIR)
FACT: Bun pins LLVM to 21.1.8 with acceptance range >=21.1.0 <21.1.99 for clang/lld, and the oven-sh/zig fork emits LLVM bitcode for -flto=full (build.zig sets obj.lto = .full; obj.use_lld = true when -Dlto). Bun uses FULL LTO, not thin (-flto=full on unix, plain -flto on Windows). When LTO is on, Zig codegen sharding is forced to 1 because zig_llvm.cpp gates SplitModule on !lto.
RUST: Pin Rust via crates/rust-toolchain.toml to a nightly whose bundled LLVM major.minor == LLVM_MAJOR.LLVM_MINOR (verify in scripts/build/tools.ts at configure time: spawn rustc -vV, parse LLVM version:, error if outside LLVM_VERSION_RANGE). In bun-rs.ts, when cfg.lto: spec.rustflags.push("-Clinker-plugin-lto", "-Cembed-bitcode=yes", "-Ccodegen-units=1") so libbun_rs.a members are bitcode that lld can merge with Zig's and clang's bitcode. When !cfg.lto: emit native objects (no -Clinker-plugin-lto), keeping codegen-units=16 for build speed. No [profile.release] lto = in Cargo.toml — LTO happens at Bun's final link, not cargo's.
PERF: Cross-language inlining must work: a hot Rust #[inline] extern "C" fn called from Zig must inline at link time under cfg.lto. Verify: llvm-objdump -d build/release/bun | grep -A2 'call.*<rust_sym>' shows no call (inlined). Bitcode compatibility gate: smoke test <exe> --revision (bun.ts emitSmokeTest) — lld rejects mixed-LLVM-version bitcode with a hard error, so this is the trip-wire.
SRC: /root/bun-5/scripts/build/tools.ts (lines 267-270: LLVM_VERSION = "21.1.8", LLVM_VERSION_RANGE = ">=21.1.0 <21.1.99" — Rust toolchain's LLVM must satisfy the same range); /root/bun-5/scripts/build/flags.ts (lines 398-417 compile-side -flto=full (unix) / -flto (windows) + -fwhole-program-vtables; lines 713-731 link-side LTO flags); /root/bun-5/build.zig (lines 214, 827-832: -Dlto option → obj.lto = .full; obj.use_lld = true — Zig emits bitcode object for full LTO); /root/bun-5/scripts/build/zig.ts (lines 39-48 + codegenThreads(): if (cfg.lto) return 1 because SplitModule is gated on !lto — Rust must mirror with -Ccodegen-units=1 under LTO)
FACT: CI splits the build across machines via cfg.mode: cpp-only runs on a per-target agent, iterates allDeps via resolveDep() (building every dep including the cargo-built lolhtml on that target's native OS), compiles all C++ and archives the .o files into lib<exeName>.a; zig-only cross-compiles bun-zig.o on a Linux box for every target; link-only downloads both peer artifacts plus the dep libs and links. emitLinkOnly() reconstructs dep .a paths via computeDepLibs() (path-only, no build edges) and reconstructs the peer artifacts (archive, zigObjects) by direct path formula — those are NOT in allDeps. cpp-only uploads dep libs as soon as they finish via a dedicated bk_upload ninja pool (depth 1, plain stamp file, no restat) so the upload overlaps cxx compilation. build-cpp and build-zig have no depends_on on each other; build-bun depends on both.
RUST: Add cfg.mode === "rs-only" as a sibling of zig-only (dispatch at the top of emitBun()), with a new BuildKite step getBuildRsStep(platform) that runs on per-target-OS agents (same agent-selection pattern as getCppAgent — NOT one Linux host; the repo has no precedent for Linux→darwin/msvc cargo cross-compilation, and rustTargetTriple() explicitly excludes Windows). emitRsOnly(n, cfg) emits only codegen + a cargo build --target <rustTargetTriple(cfg)> -p bun-rs edge producing libbun_rs.a; reuse rustTargetTriple() for the explicit-triple requirement and extend it to return *-pc-windows-msvc. Treat libbun_rs.a as a PEER artifact like archive/zigObjects — do NOT add it to allDeps (which would make every cpp-only agent redundantly cargo-build it via the resolveDep loop); instead add a rsArchive?: string field to BunOutput and special-case its path in emitLinkOnly() next to archive/zigObjects. Add getLinkBunStep's depends_on to include ${key}-build-rs. Add a NEW rsInputs: string[] field to CodegenOutputs (it does not exist today — codegen.ts has only zigInputs/cppSources/cppAll) so rs-only pulls only the codegen subset Rust needs.
PERF: rs-only must run concurrently with cpp-only and zig-only (no depends_on between the three; only build-bun fans in). Because libbun_rs.a is a peer artifact outside allDeps, cpp-only's resolveDep loop never builds it, so cpp-only critical-path time is unchanged. Total pipeline wall-time must not increase vs. baseline: max(t_cpp, t_zig, t_rs) + t_link ≤ max(t_cpp, t_zig) + t_link_baseline, i.e. rs-only must finish no later than the slower of cpp-only/zig-only. Measure via BuildKite step timings on a no-op-Rust build (rs-only ≤ current cpp-only on every target).
SRC: /root/bun-5/scripts/build/bun.ts (lines 156-163: emitBun() dispatches cfg.mode to emitZigOnly/emitLinkOnly before the full path — rs-only slots in here as a third early-return branch); /root/bun-5/scripts/build/bun.ts (lines 177-183: cpp-only/full path iterates for (const dep of allDeps) resolveDep(...) — anything in allDeps is built on every cpp-only agent, so bun-rs must NOT go in allDeps); /root/bun-5/scripts/build/bun.ts (lines 412-451: cpp-only archives via ar() into lib<exeName>.a, then defines bk_upload pool (depth 1) and rule (command/description/pool only — no restat = 1) writing plain .dep-libs-uploaded stamp; uploads depLibs overlapping cxx compile); /root/bun-5/scripts/build/bun.ts (lines 512-555: emitZigOnly docstring + body — cross-compiles bun-zig.o on Linux for all targets, resolves only zstd (header-only need), pulls only codegen.zigInputs; template for emitRsOnly's minimal graph); /root/bun-5/scripts/build/bun.ts (lines 569-612: emitLinkOnly — archive and zigObjects are reconstructed by direct path formula as peer artifacts (NOT via allDeps); rsArchive slots in the same way. Dep libs separately via computeDepLibs() loop at 581-584); /root/bun-5/scripts/build/source.ts (lines 884-933: computeDepLibs() path-only computation including the cargo case (915-920) — used only for allDeps entries; peer artifacts bypass this); /root/bun-5/scripts/build/deps/lolhtml.ts (lines 14-25: rustTargetTriple(cfg) derives unix Rust triples; docstring states 'Windows handled separately (buildStd is unix-only)' — reusable for rs-only's --target but must be extended for *-pc-windows-msvc; NOT precedent for Linux-hosted cross-OS builds); /root/bun-5/scripts/build/deps/lolhtml.ts (lines 55-60, 100-102: only cross cases are x64-windows→arm64-windows and host→android/freebsd; lolhtml otherwise builds natively on each cpp-only agent — precedent for per-target-OS rs-only, not single-Linux-host); /root/bun-5/.buildkite/ci.mjs (lines 555-609: getBuildCppStep (agents: getCppAgent(platform), no depends_on) and getBuildZigStep (no depends_on) run concurrently; getLinkBunStepdepends_on: [build-cpp, build-zig] — rs-only step mirrors build-zig's shape and is added to build-bun's depends_on); /root/bun-5/scripts/build/codegen.ts (lines 214-281: CodegenOutputs has zigInputs/cppSources/cppAll but NO rsInputs — must be added as a new field for rs-only to pull only Rust-relevant codegen)
FACT: emitGeneratedClasses (scripts/build/codegen.ts:567-598) declares exactly 8 ninja outputs for one bun run src/codegen/generate-classes.ts <*.classes.ts...> <codegenDir> invocation: ZigGeneratedClasses.{h,cpp,zig,lut.txt} plus the 4 +lazyStructureHeader.h/+DOMClientIsoSubspaces.h/+DOMIsoSubspaces.h/+lazyStructureImpl.h headers. The codegen rule is registered with restat: true (codegen.ts:140-144) and the script writes every output via writeIfNotChanged (generate-classes.ts:2903-3077), so byte-identical outputs prune downstream rebuilds. Outputs are bucketed into CodegenOutputs.{zigInputs,cppSources,cppHeaders} (codegen.ts:593-596) and routed: zigInputs → emitZig implicit inputs, cppSources → compiled via cxx(), cppHeaders → PCH order-only. Separately, resolveDep runs for every entry in allDeps BEFORE emitCodegen (bun.ts:177-178 vs bun.ts:209), and emitCargo only sees {srcDir, sourceStamp} (source.ts:828, 1308-1311) — CodegenOutputs is not in scope there. The C++ symbols <Type>__fromJS are defined extern JSC_CALLCONV ... JSC_HOST_CALL_ATTRIBUTES where JSC_CALLCONV = "C" SYSV_ABI on Windows (generate-classes.ts:2643-2647), matched on the Zig side by callconv(jsc.conv) = .x86_64_sysv on Windows x64 (src/bun.js/jsc.zig:9-12); <Type>__ptrOffset is a plain extern "C" const size_t data symbol (generate-classes.ts:1862).
RUST: Add a 9th output ZigGeneratedClasses.rs to the outputs array in emitGeneratedClasses and emit it from generate-classes.ts via writeIfNotChanged. File shape — one pub mod per class to avoid Rust E0428 identifier collisions (#[link_name] only renames the linker symbol, not the Rust item): pub mod <snake_type> { use super::*; #[repr(transparent)] pub struct Handle(pub core::ptr::NonNull<c_void>); #[cfg(all(windows, target_arch = "x86_64"))] extern "sysv64" { #[link_name = "<Type>__fromJS"] pub fn from_js(v: EncodedJSValue) -> *mut c_void; } #[cfg(not(all(windows, target_arch = "x86_64")))] extern "C" { #[link_name = "<Type>__fromJS"] pub fn from_js(v: EncodedJSValue) -> *mut c_void; } extern "C" { #[link_name = "<Type>__ptrOffset"] pub static PTR_OFFSET: usize; } }. The cfg-gated extern "sysv64" exactly mirrors jsc.conv/JSC_CALLCONV/SYSV_ABI = __attribute__((sysv_abi)); the data symbol stays extern "C" (no callconv applies). Build wiring: do NOT thread CodegenOutputs into emitCargo (it doesn't exist yet at dep-resolution time). Instead extend CargoBuild (source.ts:339) with codegenInputs?: string[] (basenames relative to cfg.codegenDir) and extraEnv?: Record<string,string>. In emitCargo, resolve spec.codegenInputs against cfg.codegenDir and spread into implicitInputs: [sourceStamp, cfg.cargo, ...resolvedCodegenInputs] at source.ts:1401, and merge spec.extraEnv into the existing env map (source.ts:1351) so BUN_CODEGEN_DIR=cfg.codegenDir reaches cargo. Ninja's DAG orders by edges, not emission order, so even though the cargo n.build is emitted before the codegen n.build, the implicit-input edge on ${codegenDir}/ZigGeneratedClasses.rs forces codegen to run first. The bun-rs dep entry sets codegenInputs: ["ZigGeneratedClasses.rs"]. Consumer crate crates/bun-jsc-gen/src/lib.rs does include!(concat!(env!("BUN_CODEGEN_DIR"), "/ZigGeneratedClasses.rs")); and re-exports pub use <snake_type>::Handle as <Type>;.
PERF: restat=1 + writeIfNotChanged must keep no-op .classes.ts edits from cascading into a cargo build of bun-rs: touch a .classes.ts file without semantic change, run ninja -d explain, and verify the dep_cargo edge for bun-rs is reported clean (only the codegen step re-runs, its outputs restat-prune). Conversely, adding a new class must show ZigGeneratedClasses.rs as dirty and trigger exactly one dep_cargo rebuild.
SRC: /root/bun-5/scripts/build/codegen.ts (lines 567-598: emitGeneratedClasses — 8-element outputs array (570-579), rule: "codegen" (583), bucketing into o.zigInputs/o.cppSources/o.cppHeaders (593-596). Add ZigGeneratedClasses.rs here as outputs[8].); /root/bun-5/scripts/build/codegen.ts (lines 140-144: n.rule("codegen", { ..., restat: true }) — enables mtime-prune when writeIfNotChanged leaves output untouched.); /root/bun-5/scripts/build/codegen.ts (lines 209-257: interface CodegenOutputs — zigInputs/cppSources/cppHeaders/cppAll buckets. No rsInputs field needed under this design (cargo edge references the fixed codegenDir path directly).); /root/bun-5/scripts/build/bun.ts (lines 177-184 loop allDeps → resolveDep BEFORE line 209 const codegen = emitCodegen(...) — proves CodegenOutputs is unavailable inside emitCargo; wiring must go through CargoBuild spec + cfg.codegenDir instead.); /root/bun-5/scripts/build/source.ts (line 828: emitCargo(n, cfg, dep.name, buildSpec, { srcDir, sourceStamp }); lines 1308-1311 interface EmitCargoInput { srcDir; sourceStamp }; lines 339-372 interface CargoBuild — extend with codegenInputs?: string[] + extraEnv?.); /root/bun-5/scripts/build/source.ts (lines 1351-1355 const env: Record<string,string> (merge extraEnv/BUN_CODEGEN_DIR here); lines 1396-1414 n.build({ outputs:[lib], rule:"dep_cargo"/..., implicitInputs:[sourceStamp, cfg.cargo], ... }) — spread resolved codegenInputs into implicitInputs.); /root/bun-5/src/codegen/generate-classes.ts (line 1820: extern JSC_CALLCONV void* JSC_HOST_CALL_ATTRIBUTES ${typeName}__fromJS(JSC::EncodedJSValue value) — Rust must bind via extern "sysv64" on win-x64, extern "C" elsewhere.); /root/bun-5/src/codegen/generate-classes.ts (line 1862: extern "C" const size_t ${typeName}__ptrOffset = ... — plain C data symbol, Rust binds via extern "C" { static PTR_OFFSET: usize; } with #[link_name].); /root/bun-5/src/codegen/generate-classes.ts (lines 2643-2647: #if !OS(WINDOWS) #define JSC_CALLCONV "C" #else #define JSC_CALLCONV "C" SYSV_ABI #endif — source of the win-x64 sysv ABI requirement.); /root/bun-5/src/bun.js/jsc.zig (lines 9-12: pub const conv = if (isWindows and isX64) .{ .x86_64_sysv = .{} } else .c; — Zig precedent the Rust cfg-gate mirrors.); /root/bun-5/vendor/WebKit/Source/WTF/wtf/PlatformCallingConventions.h (lines 37,45: #define SYSV_ABI __attribute__((sysv_abi)) and #define JSC_HOST_CALL_ATTRIBUTES SYSV_ABI — confirms the C++ side is sysv on win-x64.); /root/bun-5/src/codegen/generate-classes.ts (lines 2903, 3030, 3039, 3054, 3057-3077: every output goes through writeIfNotChanged — add the .rs writer alongside these so restat pruning applies.)
FACT: Cargo deps are built via ninja rule dep_cargo (<stream> --cwd=$manifestdir $env <cargo> build $args, restat=1, pool=dep depth-4). emitCargo() always passes --locked --target-dir <buildDir>/deps/<name>, appends --release when cfg.release, encodes RUSTFLAGS as CARGO_ENCODED_RUSTFLAGS joined by U+001F, and for cross targets selects dep_cargo_cross (prepends rustup target add <triple>) unless buildStd is set, in which case plain dep_cargo with -Zbuild-std=std,panic_abort is used. The single declared output is the staticlib path; the only implicitInputs today are [sourceStamp, cfg.cargo]. For local/in-tree sources sourceStamp resolves to <manifestDir>/Cargo.toml, so hand-written .rs edits do NOT currently dirty the edge — emitCargo has no alwaysBuild path, unlike emitNestedCmake which pushes n.always() for source.kind === "local".
RUST: Add bun-rs as a Dep in scripts/build/deps/bun-rs.ts with source: () => ({ kind: "in-tree", path: "crates" }) (NOT bare {kind:"local"} — that defaults srcDir to vendor/bun-rs/), build: () => ({ kind: "cargo", manifestDir: ".", libName: "bun_rs", rustflags: [...], rustTarget: cfg.crossTarget && rustTripleFor(cfg) }), provides: { libs: ["bun_rs"] }. Extend EmitCargoInput with alwaysBuild?: boolean and extraImplicitInputs?: string[]; in resolveDep()'s cargo branch pass alwaysBuild: source.kind === "local" || source.kind === "in-tree". Inside emitCargo(), build implicitInputs = [sourceStamp, cfg.cargo, ...extraImplicitInputs] and if (alwaysBuild) implicitInputs.push(n.always()) — exactly mirroring emitNestedCmake's pattern. extraImplicitInputs carries generated .rs paths from build-dir codegen so they order-before cargo. The dep_cargo rule text and restat=1 stay unchanged; cargo's own fingerprint is the inner incremental check, ninja's always-dirty edge ensures it is invoked, and restat prunes link when libbun_rs.a's mtime is untouched. The Rust workspace itself lives at repo-root crates/ (workspace Cargo.toml) with member crates bun-core, bun-ffi, etc.; the staticlib crate is crates/bun-rs with crate-type = ["staticlib"]. No unsafe at the build layer — the .a is opaque to ninja.
PERF: (a) touch crates/bun-core/src/foo.rs && ninja -C build/debug: ninja sees the always-dirty implicit, runs dep_cargo bun-rs; cargo recompiles the touched crate and rewrites libbun_rs.a; restat sees mtime changed → link bun-debug + smoke_test run; NO cc/zig/dep_build/dep_configure edges fire. (b) Immediate second ninja with no edits: dep_cargo bun-rs runs again (always-dirty) but cargo fingerprints clean and does NOT touch libbun_rs.a; restat=1 prunes link/smoke_test; total wall time ≤ cold-cargo-noop (~300ms) + ninja stat. (c) ninja -C build/debug -t commands bun-debug | grep -c 'cargo build' == 1. Measure with ninja -d explain and assert the only "dirty" lines are the phony-always edge and dep_cargo bun-rs for case (b).
SRC: /root/bun-5/scripts/build/source.ts (lines 585-590: n.rule("dep_cargo", { command: ${stream} --cwd=$manifestdir $env ${q(cfg.cargo)} build $args, restat: true, pool: "dep" }); lines 597-605: dep_cargo_cross prepends rustup target add $rust_target, restat:true, pool:"dep"; line 651: n.pool("dep", 4)); /root/bun-5/scripts/build/source.ts (emitCargo lines 1323-1418: line 1343 args = ["--locked", "--target-dir", targetDir]; 1344 if (cfg.release) args.push("--release"); 1346 if (spec.buildStd) args.push("-Zbuild-std=std,panic_abort"); 1359 env.CARGO_ENCODED_RUSTFLAGS = spec.rustflags.join("\x1f"); 1394 cross = cfg.crossTarget && spec.rustTarget && !spec.buildStd; 1396 outputs: [lib]; 1401 implicitInputs: [sourceStamp, cfg.cargo] — NO always() and NO extra inputs today); /root/bun-5/scripts/build/source.ts (lines 772-779: for local/in-tree cargo deps, sourceStamp = resolve(srcDir, manifestDir, "Cargo.toml") — touching .rs files under crates/ does not change this stamp); /root/bun-5/scripts/build/source.ts (lines 820-824 nested-cmake call passes alwaysBuild: source.kind === "local" with comment "Local-mode deps: always re-invoke inner build. We can't track source changes reliably"; lines 1280-1285 emitNestedCmake: if (alwaysBuild) buildImplicits.push(n.always()) with restat=1 pruning — this is the exact pattern to mirror in emitCargo); /root/bun-5/scripts/build/source.ts (lines 716-721: srcDir resolution — in-tree → resolve(cfg.cwd, source.path); local without path → depSourceDir(cfg, name) i.e. vendor//; lines 112-120 define kind:"in-tree" as "Source lives in the bun repo itself, not vendor/" — correct kind for crates/ at repo root); /root/bun-5/scripts/build/source.ts (lines 339-372 CargoBuild interface: manifestDir, libName, rustTarget?, rustflags?, buildStd? — extend EmitCargoInput (lines 1308-1311) with alwaysBuild?: boolean and extraImplicitInputs?: string[]); /root/bun-5/scripts/build/ninja.ts (line 267: always(): string { — emits a phony edge that is always dirty; the implicit-input mechanism emitNestedCmake already relies on)
[crates/bun-alloc] build-integration
FACT: Beyond the global allocator, Bun uses per-subsystem mimalloc heaps via MimallocArena — a Zig std.mem.Allocator wrapping mi_heap_new() for construction, mi_heap_malloc/mi_heap_malloc_aligned for allocation (branching on mustUseAlignedAlloc: align > 16), mi_heap_collect for GC, mi_heap_contains for ownership checks, and mi_heap_destroy (NOT mi_heap_delete) for teardown — destroy bulk-frees every block in the heap, which is the arena contract the parser/transpiler rely on. Zig also models a non-owning Borrowed view so callers that receive a heap pointer cannot double-deinit. The v3 mimalloc mi_malloc/mi_free are already thread-local-fast, so the global path has no per-call lock.
RUST: crates/bun-alloc/src/arena.rs (nightly allocator_api, already required for -Zbuild-std):
extern"C"{fnmi_heap_new() -> *mutc_void;fnmi_heap_destroy(h:*mutc_void);fnmi_heap_malloc(h:*mutc_void,n:usize) -> *mutu8;fnmi_heap_malloc_aligned(h:*mutc_void,n:usize,a:usize) -> *mutu8;fnmi_heap_collect(h:*mutc_void,force:bool);fnmi_heap_contains(h:*constc_void,p:*constc_void) -> bool;fnmi_free(p:*mutu8);}constMI_MAX_ALIGN_SIZE:usize = 16;// mirrors src/allocators/mimalloc.zig:217#[repr(transparent)]pubstructArena(NonNull<c_void>);// owning; Drop = mi_heap_destroy#[repr(transparent)]#[derive(Clone,Copy)]pubstructArenaRef<'a>(NonNull<c_void>,PhantomData<&'aArena>);// non-owning, mirrors Zig `Borrowed`; no DropimplArena{pubfnnew() -> Self{Arena(NonNull::new(unsafe{mi_heap_new()}).expect("oom"))}pubfnborrow(&self) -> ArenaRef<'_>{ArenaRef(self.0,PhantomData)}pubfngc(&self){unsafe{mi_heap_collect(self.0.as_ptr(),false)}}pubfnowns(&self,p:*constc_void) -> bool{unsafe{mi_heap_contains(self.0.as_ptr(), p)}}}implDropforArena{fndrop(&mutself){unsafe{mi_heap_destroy(self.0.as_ptr())}}}impl<'a>ArenaRef<'a>{pubunsafefnfrom_raw(h:*mutc_void) -> Self{ArenaRef(NonNull::new_unchecked(h),PhantomData)}}// accept *mimalloc.Heap from Zig WITHOUT taking ownershipunsafeimplAllocatorforArenaRef<'_>{#[inline(always)]fnallocate(&self,l:Layout) -> Result<NonNull<[u8]>,AllocError>{let p = unsafe{if l.align() > MI_MAX_ALIGN_SIZE{mi_heap_malloc_aligned(self.0.as_ptr(), l.size(), l.align())}else{mi_heap_malloc(self.0.as_ptr(), l.size())}};NonNull::new(p).map(|p| NonNull::slice_from_raw_parts(p, l.size())).ok_or(AllocError)}#[inline(always)]unsafefndeallocate(&self,p:NonNull<u8>, _:Layout){mi_free(p.as_ptr())}}unsafeimplAllocatorfor&Arena{/* delegate to self.borrow() */}
Same extern symbols as src/allocators/mimalloc.zig. ArenaRef::from_raw lets Rust code accept a *mimalloc.Heap from Zig and allocate into the same arena (parser/transpiler) without the double-ownership hazard the refuters flagged; only Arena (Rust-created) destroys.
PERF: (a) Fast-path codegen: cargo asm --release 'bun_alloc::ArenaRef::allocate' instantiated for Layout::new::<u64>() (size=8, align=8, both const) must show a single call mi_heap_malloc with NO cmp/branch and NO call mi_heap_malloc_aligned — LLVM const-folds 8 > 16. This matches Zig's comptime fold of mustUseAlignedAlloc in Borrowed.alignedAlloc. (b) Teardown: a microbench of Arena::new() + 100k Box::new_in(0u64, arena.borrow()) + drop(arena) must show drop time independent of allocation count (O(1) mi_heap_destroy), identical to Zig MimallocArena.deinit. Regression = either asm shows mi_heap_malloc_aligned for align≤16, or teardown scales with N.
SRC: /root/bun-5/src/allocators/MimallocArena.zig (line 162 mimalloc.mi_heap_new() in init(); line 157 mimalloc.mi_heap_destroy(mimalloc_heap) in deinit() (NOT mi_heap_delete); line 42 mi_heap_collect; line 53 mi_heap_contains; lines 72-75 if (mimalloc.mustUseAlignedAlloc(alignment)) mi_heap_malloc_aligned else mi_heap_malloc fast-path branch; lines 21-98 pub const Borrowed = struct non-owning view; lines 119-120 comment In v3, mi_malloc/mi_free are already thread-local-fast); /root/bun-5/src/allocators/mimalloc.zig (lines 42-49 pub const Heap = opaque { pub fn new()/delete() } — Rust mirrors as #[repr(transparent)] NonNull<c_void>; line 69 extern fn mi_heap_destroy; line 217 MI_MAX_ALIGN_SIZE = 16; lines 219-221 mustUseAlignedAlloc(alignment) { return alignment.toByteUnits() > MI_MAX_ALIGN_SIZE } — Rust hardcodes the same > 16 threshold); /root/bun-5/src/allocators/basic.zig (lines 30-33: global allocator branches if (mimalloc.mustUseAlignedAlloc(alignment)) mi_malloc_aligned else mi_malloc — same fast-path the Rust allocate body now preserves)
Object lifetime categories: GC-wrapped vs pure-native infrastructure
[bun_http] lifetime-cat-B-http-no-gc
FACT: The HTTP client state machine (HTTPClient/AsyncHTTP/HTTPThread) contains ZERO GC handles: grep for JSRef/JSValue/jsc.Strong/hasPendingActivity in src/http.zig, src/http/AsyncHTTP.zig, src/http/HTTPThread.zig returns nothing. The only jsc.* symbols touched are non-GC utility types (jsc.ZigString.Slice for unix_socket_path, jsc.URL.hrefFromString/join, jsc.CommonAbortReason enum, jsc.API.ServerConfig.SSLConfig struct). AsyncHTTP's lifetime is governed by task: ThreadPool.Task, state: AtomicState, and an owning *AsyncHTTP raw pointer held by the caller; HTTPThread is a process-singleton with explicit Mutex-guarded queues and std.atomic.Value flags.
RUST: Crate bun_http (workspace member) with Cargo.toml that does NOT list bun_jsc in [dependencies] — enforced by a CI grep + cargo tree -i bun_jsc returning empty. #[repr(C)] pub struct AsyncHttp { request: Option<picohttp::Request>, response: Option<picohttp::Response>, task: threadpool::Task, state: AtomicU8, result_callback: extern "C" fn(*mut c_void, *mut AsyncHttp, *const HttpClientResult), real: *mut AsyncHttp, ... }. unix_socket_path becomes bun_str::Slice (moved out of jsc namespace). HttpClientResult::abort_reason() returns a plain Option<AbortReason> enum re-exported from bun_core, not from jsc. HTTPThread is pub struct HttpThread { loop_: *mut MiniEventLoop, queued_tasks: mpsc::Queue<*mut AsyncHttp>, deferred_tasks: Vec<*mut AsyncHttp>, queued_shutdowns_lock: parking_lot::Mutex<Vec<ShutdownMessage>>, ... } with unsafe impl Send. Unsafe boundary: raw *mut AsyncHttp crosses the JS→HTTP-thread queue; ownership is documented as 'caller allocates, HTTP thread borrows, callback returns ownership'. No Drop impl on AsyncHttp that touches JSC.
PERF: Scheduling an AsyncHTTP onto HTTPThread must remain a single atomic queue push + one loop wakeup (no Arc clone, no allocation). Measure: bun install against a warm cache with 1000 packages — wall time and perf stat -e cache-misses must match Zig within ±2%. Also active_requests_count.fetchAdd must stay Ordering::Relaxed (monotonic).
SRC: /root/bun-5/src/http/AsyncHTTP.zig (lines 1-35: fields are picohttp/ThreadPool.Task/AtomicState/HTTPClient — no JSValue/JSRef; line 476 imports jsc only for ZigString.Slice at lines 82,99); /root/bun-5/src/http.zig (grep 'JSRef|jsc.Strong|JSValue|hasPendingActivity' → 0 matches; jsc.* hits are lines 682/714/1089/2354 (ZigString.Slice), 1817 (SSLConfig struct), 2429 (CommonAbortReason enum), 3000/3049/3062 (URL utility)); /root/bun-5/src/http/HTTPThread.zig (lines 1-47: fields are MiniEventLoop/queues/Mutex/atomic.Value — grep 'JSRef|JSValue|Strong' → 0 matches; only jsc ref is line 707 wtf.releaseFastMallocFreeMemoryForThisThread (malloc trim, not GC))
FACT: The bundler CORE (Graph, LinkerContext, parse/link/print pipeline) has zero JSValue/JSRef — grep on LinkerContext.zig returns nothing GC-related; its only jsc refs are ModuleLoader.HardcodedModule.Alias.has (a const lookup table) and AnyEventLoop. JSValue/JSPromise.Strong appear ONLY inside JSBundleCompletionTask (bundle_v2.zig:1928-2450), a separable adapter struct that owns config, promise: jsc.JSPromise.Strong, globalThis, and posts results back to JS. BundleV2 holds completion: ?*JSBundleCompletionTask and plugins: ?*jsc.API.JSBundler.Plugin as nullable opaque pointers — the core runs with both null (CLI path).
RUST: Split into two crates. bun_bundler (no bun_jsc dep): pub struct BundleV2 { transpiler: *mut Transpiler, graph: Graph, linker: LinkerContext, completion: Option<*mut dyn BundleCompletion>, plugins: Option<*mut dyn PluginRunner>, ... } where trait BundleCompletion: Send { fn on_complete(&mut self, result: BundleResult); } and trait PluginRunner are defined in bun_bundler but IMPLEMENTED in bun_runtime. bun_runtime::bundler_js provides #[repr(C)] struct JSBundleCompletionTask { promise: jsc::Strong<JSPromise>, global: *mut JSGlobalObject, jsc_event_loop: *mut jsc::EventLoop, ... } impl BundleCompletion for JSBundleCompletionTask. Unsafe boundary: trait object vtable call from bundler thread → enqueue ConcurrentTask on JS thread (matches line 2095 today).
PERF: bun build CLI (no plugins, completion=None) must compile/link without ever entering the dyn BundleCompletion vtable — verify with perf record that no bun_runtime:: symbol appears in a CLI build profile. End-to-end bun build ./three.js wall time within ±2% of Zig.
SRC: /root/bun-5/src/bundler/LinkerContext.zig (grep 'JSRef|JSValue|jsc.Strong' → 0; only jsc refs: line 2009 HardcodedModule.Alias.has (const table), line 2782 AnyEventLoop); /root/bun-5/src/bundler/bundle_v2.zig (lines 107-123: BundleV2 fields with plugins: ?*jsc.API.JSBundler.Plugin and completion: ?*JSBundleCompletionTask as nullable; lines 1928-2450 JSBundleCompletionTask is the only holder of JSPromise.Strong/JSValue; line 1885 promise = jsc.JSPromise.Strong.init)
FACT: RequestContext is NOT a .classes.ts wrapper (grep over *.classes.ts → 0 hits). Its lifetime primitive is a HiveArray slab pool of 2048 entries (RequestContextStackAllocator = bun.HiveArray(RequestContext, 2048).Fallback, line 39) plus a hand-rolled ref_count: u8 = 1 with ref()/deref() (lines 59, 303-316); when count hits 0 it returns to the pool — there is NO GC finalizer. It DOES hold JSValues (response_jsvalue: jsc.JSValue, ReadableStream.Strong) as fields, but those are released in finalizeWithoutDeinit/deinit driven by the refcount, not by GC sweep. This is the canonical 'native-pooled object that BORROWS JS handles' pattern — distinct from category A.
RUST: Lives in bun_runtime::server (depends on bun_jsc because it stores Strong handles, but is itself category-B lifetime). #[repr(C)] pub struct RequestContext<const SSL: bool, const DEBUG: bool, const H3: bool> { ref_count: u8, resp: Option<NonNull<uws::Response>>, response_jsvalue: jsc::JSValue, request_body_readable_stream_ref: jsc::ReadableStreamStrong, ... }. Pool: static POOL: ThreadLocal<HiveArray<RequestContext<..>, 2048>> from bun_collections. impl RequestContext { #[inline] pub fn ref_(&mut self){self.ref_count+=1} pub fn deref(&mut self){self.ref_count-=1; if self.ref_count==0 {self.finalize_without_deinit(); POOL.with(|p| p.put(self))}} }. NO impl Drop — explicit deref only. NO JSRef self-reference (it has no JS wrapper). Unsafe boundary: *mut RequestContext is stored as uWS user-data; pool returns stable addresses (HiveArray guarantees pointer stability).
PERF: Per-request allocation count must stay 0 for the RequestContext itself under ≤2048 concurrent requests. sizeof::<RequestContext> × 2048 must stay ≈655KB (matches comment line 38). Measure: oha -z 30s against hello-world Bun.serve — RPS and p99 latency within ±2% of Zig; heaptrack shows no per-request malloc for RequestContext.
SRC: /root/bun-5/src/bun.js/api/server/RequestContext.zig (line 39 HiveArray(RequestContext, 2048).Fallback pool; line 59 ref_count: u8 = 1; lines 303-316 manual ref/deref → finalizeWithoutDeinit+deinit; line 58 response_jsvalue: jsc.JSValue held but released via refcount path; grep '*.classes.ts' for RequestContext → 0)
FACT: FetchTasklet is the canonical BRIDGE object proving the category split: it owns category-B http: ?*http.AsyncHTTP via raw pointer + allocator.create (lines 6, 1085), and surfaces results to JS via category-A primitives promise: jsc.JSPromise.Strong / abort_reason: jsc.Strong.Optional / response: jsc.Weak (lines 26/45/19). Its OWN lifetime is a thread-safe atomic ref_count: std.atomic.Value(u32) (line 60, fetchAdd monotonic / fetchSub) — NOT GC. AsyncHTTP never sees the JSPromise; it calls back via result_callback (AsyncHTTP.zig:18) with a plain HTTPClientResult struct.
RUST: Lives in bun_runtime::fetch (depends on both bun_http and bun_jsc). #[repr(C)] pub struct FetchTasklet { ref_count: AtomicU32, http: Option<Box<bun_http::AsyncHttp>>, promise: jsc::Strong<JSPromise>, abort_reason: jsc::OptionalStrong, response: jsc::Weak<FetchTasklet>, signal_store: http::SignalsStore, mutex: parking_lot::Mutex<()>, ... }. AsyncHttp's result_callback is extern "C" fn(ctx: *mut c_void, http: *mut AsyncHttp, result: *const HttpClientResult) — bun_http defines the fn-pointer type, bun_runtime provides the impl that locks the mutex and resolves the Strong promise. This is the ONLY place a bun_http type and a jsc::Strong coexist; the dependency arrow is bun_runtime → {bun_http, bun_jsc}, never bun_http → bun_jsc.
PERF: Callback dispatch from HTTP thread → JS thread must be one atomic refcount inc + one ConcurrentTask enqueue (matches today). fetch() microbenchmark (10k sequential localhost requests) latency within ±2%. Verify with cargo tree -p bun_http that bun_jsc does not appear.
SRC: /root/bun-5/src/bun.js/webcore/fetch/FetchTasklet.zig (line 6 http: ?*http.AsyncHTTP; line 26 promise: jsc.JSPromise.Strong; line 45 abort_reason: jsc.Strong.Optional; line 60 atomic ref_count; line 1085 allocator.create(http.AsyncHTTP); line 1401 callback(task, async_http, result) — plain struct, no JSValue); /root/bun-5/src/http/AsyncHTTP.zig (line 18 result_callback: HTTPClientResult.Callback — typed fn pointer, no JS)
[bun_ptr] lifetime-primitive-refcount-intrusive
FACT: bun.ptr.RefCount is an INTRUSIVE mixin: the count lives inline at a named field offset inside the owner struct (RefCount(@This(), "ref_count", deinit, .{})), not in a separate control block. RawRefCount provides both modes: single_threaded (plain integer + debug ThreadLock assertion) and thread_safe (fetchAdd(1, .monotonic) for increment, fetchSub(1, .acq_rel) for decrement). Destruction is caller-supplied (deinit fn pointer), enabling pool-return instead of free.
RUST: Crate bun_ptr. #[repr(transparent)] pub struct RefCount(u32); and #[repr(transparent)] pub struct AtomicRefCount(AtomicU32); with #[inline(always)] fn inc(&self){ self.0.fetch_add(1, Ordering::Relaxed); } fn dec(&self)->bool{ self.0.fetch_sub(1, Ordering::AcqRel)==1 }. Trait pub unsafe trait IntrusiveRc { fn ref_count(&self) -> &AtomicRefCount; unsafe fn destroy(ptr: *mut Self); } + pub struct RefPtr<T: IntrusiveRc>(NonNull<T>) with Clone = inc, Drop = dec→destroy. A #[derive(IntrusiveRc)] proc-macro locates the ref_count field by name (mirrors Zig's string field-name lookup). NO Arc — Arc's separate control block would change layout and break #[repr(C)] interop with C++ that reads the count at a fixed offset.
PERF: Increment = 1 lock xadd (Relaxed); decrement = 1 lock xadd (AcqRel) + 1 branch. sizeof(RefCount)==4, sizeof(AtomicRefCount)==4. Verify with cargo asm bun_ptr::AtomicRefCount::inc — must be a single instruction. Any deviation (e.g. SeqCst) is a regression.
FACT: src/install/ contains ZERO GC-root primitives — grep -rnE 'JSRef|jsc\.Strong|hasPendingActivity' src/install/ returns 0 — so PackageManager's lifetime model is purely explicit ownership: allocator: std.mem.Allocator (PackageManager.zig:4), thread_pool: ThreadPool (:48), pending_tasks: std.atomic.Value(u32) (:70), and HiveArray slab pools preallocated_network_tasks / preallocated_resolve_tasks (:72-73, types defined :1137-1138 as bun.HiveArray(Task,64).Fallback / bun.HiveArray(NetworkTask,128).Fallback). HOWEVER, src/install/ is NOT JSC-free: it has 28 JSValue uses across 5 files (install_binding.zig, dependency.zig, hosted_git_info.zig, npm.zig, PackageManager/UpdateRequest.zig) forming a JS host-function surface, and 23 bun.jsc references for non-GC utilities — event_loop: jsc.AnyEventLoop (:106) with both .mini (:872) and .js = jsc.VirtualMachine.get().eventLoop() (:1049) variants, bun.jsc.WorkPoolTask as the intrusive threadpool node in PackageInstall.zig:744, bun.jsc.initialize(false) to boot JSC's regex engine in PnpmMatcher.zig:30, jsc.Subprocess/jsc.WebCore.Blob in security_scanner.zig:729/844/866/872-873, and bun.jsc.URL.join in NetworkTask.zig:212.
RUST: Two crates. (1) bun_install_core — holds #[repr(C)] pub struct PackageManager { allocator: BumpAlloc, thread_pool: bun_threadpool::ThreadPool, preallocated_network_tasks: HiveArray<NetworkTask,128>, preallocated_resolve_tasks: HiveArray<Task,64>, pending_tasks: AtomicU32, event_loop: bun_async::AnyEventLoop, ... }, plus Lockfile/Dependency/Npm manifest parsing. It depends on bun_threadpool (WorkPoolTask becomes bun_threadpool::Task intrusive node, replacing PackageInstall.zig:744's bun.jsc.WorkPoolTask), bun_url (replacing bun.jsc.URL.join), bun_fs (replacing bun.jsc.Node.fs.NodeFS), and bun_regex or regex crate (replacing PnpmMatcher's bun.jsc.initialize). It does NOT depend on bun_jsc. (2) bun_install_js — depends on both bun_install_core and bun_jsc; hosts every JSValue-touching function (jsParseLockfile, UpdateRequest::fromJS, Dependency::Version::{toJS,fromJS}, hosted_git_info::{toJS,jsParseUrl,jsFromUrl}, npm::{jsFunctionOperatingSystemIsMatch,...,jsParseManifest}) and security_scanner (which needs jsc::Subprocess/Blob). AnyEventLoop lives in bun_async as a tagged union { Mini(MiniEventLoop), Js(*mut JsEventLoop) } so bun_install_core can hold the field without linking JSC; the .Js arm is only constructed inside bun_install_js (mirroring PackageManager.zig:1049). Unsafe boundary: HiveArray<T,N> hands out *mut T to ThreadPool; pool is a struct field of PackageManager and outlives all tasks — assert pending_tasks.load() == 0 in PackageManager::drop.
PERF: First 128 NetworkTask and first 64 resolve Task allocations are slab hits (no heap alloc); ThreadPool dispatch uses an intrusive node embedded in Task/NetworkTask (no Box per task). Measure: instrument HiveArray::get() hit/miss counters and run bun install on a fresh 500-dependency lockfile — slab-miss count for NetworkTask must be max(0, total_network_tasks - 128) and per-task heap allocations during enqueue must be 0, matching Zig baseline.
SRC: /root/bun-5/src/install/PackageManager.zig (line 4 allocator: std.mem.Allocator; line 48 thread_pool: ThreadPool; line 70 pending_tasks: std.atomic.Value(u32); lines 72-73 preallocated_network_tasks: PreallocatedNetworkTasks / preallocated_resolve_tasks: PreallocatedTaskStore; line 106 event_loop: jsc.AnyEventLoop; line 872 .mini = jsc.MiniEventLoop.init(...); line 880 bun.jsc.EventLoopHandle.init(&manager.event_loop); line 890 jsc.MiniEventLoop.global = &manager.event_loop.mini; line 1049 .event_loop = .{ .js = jsc.VirtualMachine.get().eventLoop() }; lines 1137-1138 PreallocatedTaskStore = bun.HiveArray(Task, 64).Fallback / PreallocatedNetworkTasks = bun.HiveArray(NetworkTask, 128).Fallback; line 1285 const jsc = bun.jsc;); /root/bun-5/src/install/ (grep -rnE 'JSRef|jsc\.Strong|hasPendingActivity' src/install/ → 0 matches (no GC roots). grep -rn 'JSValue' src/install/ → 28 matches in install_binding.zig, hosted_git_info.zig, npm.zig, PackageManager/UpdateRequest.zig, dependency.zig (JS host-function surface).); /root/bun-5/src/install/PackageInstall.zig (line 744 task: bun.jsc.WorkPoolTask = .{ .callback = &runFromThreadPool } — intrusive threadpool node currently lives under bun.jsc namespace; must move to bun_threadpool for core crate to drop jsc dep); /root/bun-5/src/install/PnpmMatcher.zig (line 30 bun.jsc.initialize(false); — boots JSC solely to use its regex engine; Rust port replaces with regex crate or bun_regex); /root/bun-5/src/install/PackageManager/security_scanner.zig (lines 729/844/866/872-873/907/920 use jsc.Subprocess, jsc.EventLoopHandle, jsc.WebCore.Blob, jsc.AnyEventLoop — security scanner must live in bun_install_js (or behind a feature gate)); /root/bun-5/src/install/NetworkTask.zig (line 212 bun.jsc.URL.join(...) — WHATWG URL utility under jsc namespace; Rust port routes through bun_url so bun_install_core stays jsc-free)
[bun_runtime (depends on bun_jsc, bun_uws)] lifetime-categories
FACT: TCPSocket/TLSSocket is a category-A JSC-wrapped object: sockets.classes.ts:5 generates a JSCell wrapper holding m_ctx. The Zig struct holds this_value: jsc.JSRef = .empty() (socket.zig:66) — a self-reference to its own wrapper. JSRef is a 3-variant union { weak: jsc.JSValue, strong: jsc.Strong.Optional, finalized: void } (JSRef.zig:90-93); the weak arm is a raw unrooted 8-byte EncodedJSValue, NOT a JSC::Weak handle, and empty() is encoded as .weak = .js_undefined (JSRef.zig:105-107) — there is no separate Empty variant. The ref is upgraded to .strong while the socket is active (socket.zig:399-401) via Strong.Optional.create → Bun__StrongRef__new (allocates one HandleSet slot, StrongRef.cpp:15-28), and downgraded to .weak on connect-error/close (socket.zig:349-350, 368, 432-433) via strong.deinit() → Bun__StrongRef__delete (HandleSet deallocate, StrongRef.cpp:9-13). The struct ALSO carries an intrusive bun.ptr.RefCount (socket.zig:49,61) so native code can keep it alive across callbacks independent of GC. Lifetime termination is the GC-driven finalize() (socket.zig:1402-1411) which sets flags.finalizing = true, calls this_value.finalize() (flip to .finalized + free any Strong slot, JSRef.zig:199-202), calls closeAndDetach(.failure) if the socket is still open, then deref() — the GC finalizer holds exactly one refcount.
RUST: Lives in bun_runtime. #[repr(C)] pub struct TcpSocket<const SSL: bool> { ref_count: ptr::RefCount<Self>, this_value: jsc::JSRef, handlers: Option<NonNull<Handlers>>, socket: uws::SocketHandler<SSL>, poll_ref: r#async::KeepAlive, flags: Flags, ... }. jsc::JSRef lives in bun_jsc and mirrors Zig exactly: pub enum JSRef { Weak(jsc::JSValue /* raw EncodedJSValue, untracked; js_undefined == empty */), Strong(jsc::StrongOptional /* Option<NonNull<jsc::HandleSlot>> */), Finalized } with fn empty() -> Self { Weak(JSValue::UNDEFINED) }. CRITICAL: the Weak arm MUST NOT be JSC::Weak<T> — it is a bare tagged pointer whose validity is guaranteed solely by the wrapper's own m_ctx back-pointer ensuring finalize() runs before the cell is swept. upgrade(&mut self, global) calls extern "C" Bun__StrongRef__new (one FFI hop, HandleSet allocate + writeBarrier); downgrade(&mut self) reads the JSValue out of the slot then calls Bun__StrongRef__delete and stores Weak(value) — identical FFI surface to Zig (Strong.zig:118-145). The define_js_class! macro (the .classes.ts replacement) emits extern "C" fn TCPSocket__finalize(ctx: *mut TcpSocket<SSL>) that performs, in order: (*ctx).flags.finalizing = true; (*ctx).this_value.finalize(); if !(*ctx).socket.is_closed() { (*ctx).close_and_detach(CloseCode::Failure); } (*ctx).deref(); — matching socket.zig:1402-1411. Unsafe boundary: finalize is invoked from the JSC sweep safepoint; it must not re-enter JS — JSRef::finalize only frees the HandleSet slot (if any) and writes the Finalized discriminant, and close_and_detach only touches uSockets/native state.
PERF: Parity with Zig, not zero-alloc: (a) the Weak arm is 8 bytes of raw JSValue with zero GC bookkeeping — no WeakImpl allocation, no FFI on try_get()/downgrade() beyond what Zig does; (b) upgrade() = exactly one Bun__StrongRef__new FFI call (HandleSet allocate + writeBarrier), downgrade() = exactly one Bun__StrongRef__delete FFI call — same as JSRef.zig:139-163 + StrongRef.cpp:9-28; (c) sizeof(JSRef) ≤ 16 bytes (8-byte payload + discriminant, matching the Zig tagged union). Measure: 10k connect/close cycles must show identical Bun__StrongRef__new/__delete call counts under dtrace/perf vs the Zig build, and heaptrack must show zero additional per-socket allocations attributable to JSRef (specifically: zero WeakImpl::allocate calls).
SRC: /root/bun-5/src/bun.js/api/bun/socket.zig (line 49: RefCount = bun.ptr.RefCount(@This(), "ref_count", deinit, .{}); line 61: ref_count: RefCount; line 66: this_value: jsc.JSRef = .empty()); /root/bun-5/src/bun.js/api/bun/socket.zig (lines 349-350, 368, 432-433: this.this_value.downgrade() on connect-error / markInactive; lines 399-401: this.this_value.upgrade(handlers.globalObject) in markActive); /root/bun-5/src/bun.js/api/bun/socket.zig (lines 1402-1411: finalize() sets flags.finalizing = true, calls this_value.finalize(), closeAndDetach(.failure) if still open, then deref()); /root/bun-5/src/bun.js/bindings/JSRef.zig (lines 90-93: union(enum) { weak: jsc.JSValue, strong: jsc.Strong.Optional, finalized: void } — weak is raw JSValue, no JSC::Weak; lines 105-107: empty() returns .{ .weak = .js_undefined }; lines 139-151 upgrade → Strong.Optional.create; lines 153-163 downgrade → strong.deinit() then store raw JSValue; lines 199-202 finalize = deinit + set .finalized); /root/bun-5/src/bun.js/Strong.zig (lines 49-54: Optional.create → Impl.init; lines 118-121: Impl.init calls Bun__StrongRef__new; lines 139-142: Impl.deinit calls Bun__StrongRef__delete; lines 144-147: extern decls); /root/bun-5/src/bun.js/bindings/StrongRef.cpp (lines 15-28: Bun__StrongRef__new = vm.heap.handleSet()->allocate() + writeBarrier<false> + store (allocates one HandleSet slot per upgrade); lines 9-13: Bun__StrongRef__delete = HandleSet::heapFor(handleSlot)->deallocate()); /root/bun-5/src/bun.js/api/sockets.classes.ts (line 5: name: !ssl ? "TCPSocket" : "TLSSocket" — codegen produces JSCell wrapper with m_ctx back-pointer and finalize() hook)
[bun_collections] lifetime-categories
FACT: HiveArray<T, N> is a fixed-capacity inline slab buffer: [capacity]T + used: bun.bit_set.IntegerBitSet(capacity) with pointer-stable slots (get() does findFirstUnset then returns &buffer[index]). .Fallback wraps it with hive: Self inline plus allocator: std.mem.Allocator; on overflow it calls allocator.create(T) and on put() of a non-hive pointer calls allocator.destroy(value) — per-object heap free, not arena. It is the lifetime primitive for category-B hot-path objects: RequestContext (N=2048, thread-local ?*RequestContextStackAllocator), PackageManager Task (N=64), NetworkTask (N=128). Zero GC interaction.
RUST: Crate bun_collections. #[repr(C)] pub struct HiveArray<T, const N: usize> { buffer: [MaybeUninit<T>; N], used: BitArray<N>, _pin: PhantomPinned } with #[inline] pub fn get(&mut self) -> Option<NonNull<T>> (raw pointer; slots are stable because buffer is inline and the struct is pinned). #[repr(C)] pub struct HiveFallback<T, const N: usize> { hive: HiveArray<T,N>, _pin: PhantomPinned } — hive stored INLINE (matching hive_array.zig:79 hive: Self), no extra Box indirection. Overflow path uses the global allocator: get() falls back to Box::into_raw(Box::<MaybeUninit<T>>::new_uninit()), put(p) does pointer-range check against buffer then either clears the bit or drop(Box::from_raw(p)) — exactly mirroring allocator.create/allocator.destroy. Do NOT use bumpalo: per-object free is required for long-lived thread-local pools. Owner heap-allocates the whole Fallback (Box<HiveFallback<RequestContext,2048>> stored in a thread_local, mirroring threadlocal var pool: ?*RequestContextStackAllocator). Unsafe boundary: returned NonNull<T> valid until put(); HiveFallback must not move after first get() (enforced by PhantomPinned + always boxing at the call site).
PERF: get() under capacity = bitwise-NOT + first-unset scan over ⌈N/64⌉ u64 words (1 word for N≤64, 32 words for N=2048) + bit-set + pointer offset; zero heap allocation. put() of an in-hive pointer = one pointer-range subtraction + bit-clear. Overflow path = exactly one global-allocator alloc on get and one free on put (no arena growth). Measure: (a) get/put round-trip in a hot loop must allocate 0 bytes (track via #[global_allocator] counter) for first N slots; (b) wrk against Bun.serve hello-world at <2048 concurrent connections shows 0 allocator calls from the RequestContext pool; (c) at >2048 concurrent, RSS is flat across 1M requests (proves overflow frees, regression test against the Bump-arena bug).
SRC: /root/bun-5/src/collections/hive_array.zig (lines 8-9: buffer: [capacity]T, used: bun.bit_set.IntegerBitSet(capacity); lines 29-35: get() = findFirstUnset → set bit → &buffer[index]; lines 78-80: Fallback = { hive: Self, allocator: std.mem.Allocator } (hive inline, not boxed); line 103: overflow allocator.create(T); lines 135-141: put() falls through to allocator.destroy(value) — per-object free, not arena); /root/bun-5/src/collections/bit_set.zig (line 62: MaskInt = std.meta.Int(.unsigned, size) → IntegerBitSet(2048) is backed by a single u2048; lines 190-194: findFirstUnset = @ctz(~mask) on that u2048, which the compiler lowers to a ⌈N/64⌉-limb scan — not a single hardware tzcnt for N>64); /root/bun-5/src/bun.js/api/server/RequestContext.zig (line 34: threadlocal var pool: ?*RequestContextStackAllocator — owner heap-allocates the Fallback and stores a pointer; line 39: RequestContextStackAllocator = bun.HiveArray(RequestContext, 2048).Fallback); /root/bun-5/src/install/PackageManager.zig (lines 1137-1138: PreallocatedTaskStore = bun.HiveArray(Task, 64).Fallback; PreallocatedNetworkTasks = bun.HiveArray(NetworkTask, 128).Fallback)
FACT: GC-rooting primitives (bun.jsc.JSRef, jsc.Strong, hasPendingActivity) are empirically absent from src/http/, src/install/, src/threading/ThreadPool.zig, and src/bundler/** (the only JSRef substring hits in src/bundler/ are local variables named toCommonJSRef of type js_ast.Ref, not bun.jsc.JSRef). HOWEVER, those same directories DO depend on core JSC types for host-function shims and event-loop integration: src/install/ has 60 occurrences of jsc.{JSValue,JSGlobalObject,CallFrame,JSFunction,RegularExpression,VirtualMachine,Subprocess,WebCore.Blob} across 10 files; src/http/ + src/http.zig have 33 occurrences of jsc.{JSValue,JSGlobalObject,CallFrame,EventLoop,MiniEventLoop,WebCore.Blob,AnyTask,URL} across 9 files; src/bundler/bundle_v2.zig embeds jsc.JSPromise.Strong and extern Bun__setupLazyMetafile. Additionally, GC-rooting types DO appear outside src/bun.js/ — in src/shell/, src/sql/, src/valkey/, src/bake/, src/napi/, src/io/PipeReader.zig, src/deps/uws/UpgradedDuplex.zig. Therefore the boundary today is: 'infra subsystems are free of GC-rooting lifetimes but NOT free of JSC linkage'; the directory layout does NOT encode a clean jsc-free dependency DAG.
RUST: Encode this as a two-layer crate split per subsystem: (a) JSC-free core crates bun_http_core, bun_install_core, bun_bundler_core, bun_threadpool containing the algorithmic hot paths (HTTP state machine, dependency resolver, linker, work-stealing pool) with [dependencies] excluding bun_jsc, marked #![forbid(unsafe_op_in_unsafe_fn)]; (b) thin glue crates bun_http_jsc, bun_install_jsc, bun_bundler_jsc that depend on BOTH the core crate and bun_jsc, and own every toJS/fromJS/host-function currently colocated in Zig (npm.zig jsFunction*, install*binding.zig, dependency.zig toJS/fromJS, hosted_git_info.zig fromJS, Method.zig BunHTTPMethodtoJS, websocket_client.zig globalThis/event_loop/Blob fields, H2Client/H3Client liveCounts, bundle_v2.zig JSBundleCompletionTask). Utility types currently namespaced under jsc but JSC-independent in implementation move out: jsc.ZigString.Slice→bun_str, jsc.CommonAbortReason→bun_core, jsc.AnyEventLoop/MiniEventLoop/EventLoopHandle/WorkPoolTask/AnyTask→bun_async, jsc.SSLConfig→bun_tls, jsc.URL→bun_url (wraps WTF::URL), jsc.RegularExpression→bun_regex (wraps Yarr), jsc.Node.fs.NodeFS→bun_fs. Crates that today legitimately hold GC roots (bun_shell, bun_sql, bun_valkey, bun_bake, bun_napi) depend on bun_jsc directly with no core/glue split. CI invariant: cargo tree --invert bun_jsc must list ONLY {bun_runtime, bun**_jsc glue, bun_shell, bun_sql, bun_valkey, bun_bake, bun_napi}.
PERF: Build-graph check, not symbol check: cargo tree -p bun_http_core -e normal | grep -q bun_jsc && exit 1 (likewise for bun*install_core, bun_bundler_core, bun_threadpool, bun_collections, bun_ptr) — must exit 0. The *_core crates must compile with bun_jsc removed from disk. Hot-path cost parity: bun install resolver loop and HTTP client state machine live entirely in __core, so no JSValue boxing/unboxing or vm() pointer chase appears in perf record of bun install --frozen-lockfile on a 1000-dep tree (zero samples in bun_jsc:: frames between NetworkTask dispatch and lockfile write). The *_jsc glue crates are cold-path (called once per Bun.build()/once per JS API entry), so an extra crate boundary adds zero per-iteration overhead vs Zig.
SRC: /root/bun-5/src/install/npm.zig (lines 685, 731, 806: pub fn jsFunctionOperatingSystemIsMatch/jsFunctionLibcIsMatch/jsFunctionArchitectureIsMatch(globalObject: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSError!jsc.JSValue — host functions colocated in install/, must move to buninstall_jsc); /root/bun-5/src/install/PnpmMatcher.zig (line 30 bun.jsc.initialize(false); and line 133 jsc.RegularExpression.init(...) — install/ links JSC runtime init + Yarr regex; relocate to bun_regex so bun_install_core stays jsc-free); /root/bun-5/src/install/install_binding.zig (lines 2-13: imports jsc.JSValue/ZigString/JSGlobalObject, calls jsc.JSFunction.create — entire file is glue, becomes bun_install_jsc); /root/bun-5/src/http/Method.zig (line 151 extern "c" fn Bun**HTTPMethod**toJS(method: Method, globalObject: *jsc.JSGlobalObject) jsc.JSValue;— extern JSC symbol inside http/, refutes anynm | grep jsc → emptyinvariant on a monolithic bun_http; moves to bun_http_jsc); /root/bun-5/src/http/websocket_client.zig (lines 41globalThis: *jsc.JSGlobalObject, 50 event_loop: *jsc.EventLoop, 1092 blob*value: jsc.JSValue, 1107 blob_value.as(jsc.WebCore.Blob)— websocket client stores JSC pointers; the socket/frame codec goes to bun_http_core, the JSGlobalObject/Blob handling to bun_http_jsc); /root/bun-5/src/http/H2Client.zig (lines 44-46liveCounts(globalThis: \*jsc.JSGlobalObject, *: \*jsc.CallFrame) bun.JSError!jsc.JSValuebuilding JSValue object — JS host function inside http/); /root/bun-5/src/bundler/bundle_v2.zig (lines 1885jsc.JSPromise.Strong.init, 1937-1938 globalThis: \*jsc.JSGlobalObject, promise: jsc.JSPromise.Strong, 5083 extern "C" fn Bun\_\_setupLazyMetafile(...jsc.JSValue...)— JSBundleCompletionTask is the Bun.build() JS surface, becomes bun_bundler_jsc; LinkerContext.zig itself has zero jsc.JSValue/Strong and stays in bun_bundler_core); /root/bun-5/src/bundler/linker_context/postProcessJSChunk.zig (lines 24, 632toCommonJSRef: Ref— onlyJSRef substring in src/bundler/ is a js_ast.Ref local, NOT bun.jsc.JSRef; confirms GC-rooting absence in bundler core); /root/bun-5/src/shell/interpreter.zig (rg 'JSRef|jsc.Strong|hasPendingActivity' over src/ excluding src/bun.js/ returns this file plus src/shell/subproc.zig, src/sql/{postgres,mysql,shared}/*, src/valkey/js*valkey*.zig, src/bake/{DevServer,FrameworkRouter}.zig, src/napi/napi.zig, src/io/PipeReader.zig, src/deps/uws/UpgradedDuplex.zig — refutes 'GC-rooting only under src/bun.js/'; these become direct bun_jsc dependents with no core/glue split); /root/bun-5/src/threading/ThreadPool.zig (rg 'JSRef|jsc.Strong|JSValue|JSGlobalObject' → 0 matches; only jsc-adjacent reference is wtf.releaseFastMallocFreeMemoryForThisThread (bmalloc, not GC) — bun_threadpool is genuinely jsc-free today, no glue crate needed)
Rust workspace layout — where everything goes
[bun_jsc] WS-07
FACT: JSValue is enum(i64) (ABI-identical to JSC::EncodedJSValue) with sentinel constants {undefined=0xa, null=0x2, true=0x7/FFI.TrueI64, false=0x6, zero=0}. The native↔JS calling convention jsc.conv is .x86_64_sysv on Windows-x64 and .c everywhere else; JSHostFn = fn(*JSGlobalObject,*CallFrame) callconv(jsc.conv) JSValue and toJSHostFn wraps a JSError!JSValue-returning Zig fn into that ABI by mapping error.JSError→.zero. Strong boxes a heap-allocated C++ JSC::Strong<Unknown> via opaque *Impl; JSRef is a 3-state union {weak:JSValue, strong:Strong, finalized}. MarkedArgumentBuffer is an opaque C++ stack object accessed via MarkedArgumentBuffer__run(ctx, fn) callback inversion.
RUST: Crate bun_jsc (Layer 2, deps: bun*str only). Public: #[repr(transparent)] pub struct JsValue(i64) with const UNDEFINED/NULL/TRUE/FALSE/ZERO; pub type JsResult<T=JsValue> = Result<T, JsError> where JsError is ZST meaning 'exception pending on VM'. #[repr(transparent)] pub struct JsGlobalObject(c_void) / CallFrame(c_void) opaque. pub struct Strong(NonNull<StrongImpl>) with Drop calling Bun__StrongRef__delete. pub enum JsRef { Weak(JsValue), Strong(Strong), Finalized }. pub struct MarkedArgumentBuffer<'a>(&'a mut c_void) only constructible inside with_marked_args(|buf| ...). Macro host_fn! generates extern "sysv64" (win-x64) or extern "C" (else) thunk: pub extern $CONV fn thunk(g:*mut JsGlobalObject, cf:_mut CallFrame)->i64 { match user_fn(g,cf) { Ok(v)=>v.0, Err(_)=>0 } }. Private mod syscontains ALLexterndecls auto-generated by extending cppbind.ts to emit Rust alongside Zig from the same[[ZIG_EXPORT(nothrow|zero_is_throw|check_slow)]]parse — single source of truth.unsafelives ONLY inbun_jsc::sysand thehost_fn! thunk body.
PERF: JsValue is exactly 8 bytes, passed in a single register; host_fn! thunk adds zero stack frames over the user fn (#[inline(always)] on the match); the sysv64 ABI on win-x64 is mandatory or every JS→native call corrupts the stack. Measure: static_assert size_of::()==8; cargo asm on a generated thunk shows direct tail-dispatch; JSC microbench (1M host calls) within ±1%.
SRC: /root/bun-5/src/bun.js/bindings/JSValue.zig (lines 1-23 pub const JSValue = enum(i64) with js_undefined=0xa, null=0x2, false=0x6, zero=0); /root/bun-5/src/bun.js/jsc.zig (lines 9-12 conv = if (Windows && X64) .x86_64_sysv else .c); /root/bun-5/src/bun.js/jsc/host_fn.zig (lines 1-46 JSHostFn type + toJSHostFn wraps JSError!JSValue→JSValue mapping error.JSError→.zero); /root/bun-5/src/bun.js/Strong.zig (lines 1-60 Strong{impl:*Impl} with create/deinit/get/set; Optional variant); /root/bun-5/src/bun.js/bindings/JSRef.zig (lines 1-80 doc: weak|strong|finalized states with upgrade/downgrade); /root/bun-5/src/bun.js/bindings/MarkedArgumentBuffer.zig (lines 1-33 opaque type, MarkedArgumentBuffer__run(ctx, fn) callback pattern); /root/bun-5/src/codegen/cppbind.ts (lines 1-52 + 489-705: parses [[ZIG_EXPORT(nothrow|zero_is_throw|check_slow)]] via Lezer and emits extern decls — extend to emit bun_jsc::sys Rust)
[workspace (no bun_ptr)] WS-09
FACT: Bun's src/ptr.zig provides RefCount/ThreadSafeRefCount/RefPtr as intrusive mixins (a struct embeds ref_count: RefCount and exposes ref()/deref()), used because Zig lacks Arc/Rc. The destructor is a user-provided deinit called when count hits 0. There is no shared-ownership primitive that isn't expressible as Arc<T>/Rc<T> + Arc::into_raw at the C boundary.
RUST: NO bun_ptr crate. Layer-1 crates use Arc<T> (thread-safe) or Rc<T> (loop-local) directly. At the bun_runtime↔C++ boundary the constructor thunk does Arc::into_raw(arc) as *mut c_void stored in m_ctx; finalize does unsafe { Arc::from_raw(ctx as *const T) } and drops it; any Layer-1 callback that also holds the object gets its own Arc::clone before into_raw. Tagged-pointer use cases (e.g. ZigString) stay as explicit bitmask types in their owning crate, not a generic ptr crate. unsafe for from_raw/into_raw is confined to the generated thunks in bun_runtime (WS-08) — never in Layer-1 code.
PERF: Arc ref/deref is one atomic add/sub — same as ThreadSafeRefCount's @atomicRmw. No double-indirection: Arc::into_raw yields *const T directly (data is inline after the count), matching Zig's intrusive layout for cache locality. Measure: size_of heap block for a representative class (e.g. Subprocess) within 16 bytes of Zig; ref/deref under contention (8 threads, 1M ops) within ±5%.
FACT: BunString is extern struct { tag: Tag /*enum(u8): Dead=0|WTFStringImpl=1|ZigString=2|StaticZigString=3|Empty=4*/, value: StringImpl /*extern union*/ } with comptime-asserted @sizeOf==24 and @alignOf==8 on 64-bit (tag:1 byte + 7 pad + 16-byte union). The StringImpl extern union's largest arm is ZigString = extern struct { _unsafe_ptr_do_not_use:[*]const u8, len:usize } (16 bytes; pointer high-bits tagged for utf8/utf16/global-alloc). WTFStringImplStruct is extern struct { m_refCount:u32, m_length:u32, m_ptr: extern union{latin1:[*]const u8, utf16:[*]const u16}, m_hashAndFlags:u32 } mirroring WTF::StringImpl byte-for-byte so Zig reads s_hashFlag8BitBuffer/s_refCountIncrement flags directly without FFI. ref()/deref() of WTFStringImpl call through extern "C" [[ZIG_EXPORT(nothrow)]] Bun__WTFStringImpl__ref/deref C shims (wtf.zig:89-107 → BunString.cpp:53-60). The C++ side declares the identical 24-byte layout in headers-handwritten.h (struct ZigString{const unsigned char* ptr; size_t len;}, union BunStringImpl{ZigString zig; WTF::StringImpl* wtf;}, struct BunString{BunStringTag tag; BunStringImpl impl;}).
RUST: Crate bun_str (Layer 0, no_std-ish, zero deps on JSC). #[repr(C)] pub struct WtfStringImpl { ref_count:u32, length:u32, ptr: StringPtr /*#[repr(C)] union {latin1:*const u8, utf16:*const u16}*/, hash_and_flags:u32 } — opaque to callers; only *const WtfStringImpl (NonNull) crosses FFI. #[repr(C)] pub struct ZigString { ptr:*const u8, len:usize } with const-fn high-bit tag masks. #[repr(C)] pub union BunStringImpl { zig: ZigString, wtf: *const WtfStringImpl } (16 bytes, align 8). #[repr(C)] pub struct BunString { tag: u8, value: BunStringImpl } — store tag as raw u8 (NOT a #[repr(u8)] enum field) to avoid invalid-discriminant UB if C++ hands back an unexpected byte; provide #[repr(u8)] pub enum BunStringTag { Dead=0, Wtf=1, Zig=2, StaticZig=3, Empty=4 } and fn tag(&self)->BunStringTag accessor that debug-asserts range. Let #[repr(C)] insert the 7-byte padding (no explicit _pad field). All unsafe confined to private bun_str::wtf_ffi mod declaring extern "C" { fn Bun__WTFStringImpl__ref(*mut WtfStringImpl); fn Bun__WTFStringImpl__deref(*mut WtfStringImpl); fn Bun__WTFStringImpl__ensureHash(*mut WtfStringImpl); }. impl Drop for BunString calls deref only when self.tag == BunStringTag::Wtf as u8. NO Arc — WTF::StringImpl owns the refcount; Rust just bumps it through the shim.
PERF: size*of::()==24 && align_of::()==8 on 64-bit; size_of::()==16; size_of::()==16. Enforced with const *: () = assert!(size_of::<BunString>()==24 && align_of::<BunString>()==8);matching the Zig comptime assert at src/string.zig:1290-1291 and the C++struct BunStringin headers-handwritten.h. ref()/deref() must compile to a single non-inlinedcall Bun**WTFStringImpl**ref/deref(no panic landing pad, no Drop glue branches beyond the tag==1 check) — verify withcargo asm bun_str::BunString::derefshowingcmp byte ptr [rdi], 1; jne; jmp Bun**WTFStringImpl**deref. Reading is_8bit()/length() is a direct field load with zero FFI calls (matches Zig's inline field reads in wtf.zig).
SRC: /root/bun-5/src/string.zig (lines 13-46: Tag = enum(u8){Dead=0,WTFStringImpl=1,ZigString=2,StaticZigString=3,Empty=4}; StringImpl = extern union{ZigString,WTFStringImpl,StaticZigString,Dead,Empty}; String = extern struct{tag:Tag, value:StringImpl}. lines 1289-1292: comptime { bun.assert_eql(@sizeOf(bun.String), 24); bun.assert_eql(@alignOf(bun.String), 8); } — proves 24 bytes, NOT 16.); /root/bun-5/src/string/wtf.zig (lines 1-28: WTFStringImpl = *WTFStringImplStruct; WTFStringImplStruct = extern struct{m_refCount:u32, m_length:u32, m_ptr: extern union{latin1:[*]const u8, utf16:[*]const u16}, m_hashAndFlags:u32} with s_hashFlag8BitBuffer=1<<2, s_refCountIncrement=0x2, s_refCountFlagIsStaticString=0x1. lines 89-107: deref()/ref() call bun.cpp.Bun__WTFStringImpl__deref/ref(self) — the only FFI surface for refcounting.); /root/bun-5/src/bun.js/bindings/ZigString.zig (lines 2-7: ZigString = extern struct{_unsafe_ptr_do_not_use:[*]const u8, len:usize} — 16 bytes on 64-bit; comment confirms pointer is tagged (utf8/utf16/latin1) and unsafe to access directly. This 16-byte member is why the StringImpl union, and thus BunString, is 24 not 16.); /root/bun-5/src/bun.js/bindings/BunString.cpp (lines 53-60: extern "C" [[ZIG_EXPORT(nothrow)]] void Bun__WTFStringImpl__deref(WTF::StringImpl* impl){impl->deref();} and Bun__WTFStringImpl__ref(...){impl->ref();} — the exact C ABI symbols bun_str::wtf_ffi declares.); /root/bun-5/src/bun.js/bindings/headers-handwritten.h (lines 19-60: C++ mirror — typedef struct ZigString{const unsigned char* ptr; size_t len;} (16B); typedef union BunStringImpl{ZigString zig; WTF::StringImpl* wtf;} (16B); enum class BunStringTag:uint8_t{Dead=0,...,Empty=4}; typedef struct BunString{BunStringTag tag; BunStringImpl impl;} (24B). Rust #[repr(C)] must match this exactly; static_assert against 24.)
[bun_threadpool] crate-layout-threadpool
FACT: src/threading/ThreadPool.zig is kprotty's lock-free pool (attribution lines 3-11). Sync is packed struct(u32){idle:u14, spawned:u14, unused:bool, notified:bool, state:enum(u2)} (lines 45-67), stored as field sync: Atomic(Sync) (line 34). Task = struct{ node: Node, callback: *const fn(*Task) void } (lines 98-101) where the callback uses Zig default callconv, NOT callconv(.C). Node = struct{ next: ?*Node } (lines 740-741), so size_of(Task)==2*usize. Batch is a head/tail singly-linked list (lines 104-107). The schedule() hot path (scheduleImpl lines 231-270 → notify 299-314) is: monotonic load of is_running → wait_group.add (monotonic fetchAdd, WaitGroup.zig:29-34) → run_queue.push which is a release cmpxchgWeak loop on Atomic(usize) stack (Treiber stack, lines 762-782) → sync.fetchOr(.{}, .release) (line 308), with notifySlow CAS-loop on cold path. There are zero JSValue/JSGlobalObject references in the file, but line 707 calls bun.jsc.wtf.releaseFastMallocFreeMemoryForThisThread() and line 706 calls bun.Global.mimalloc_cleanup(false) from the idle-thread futex-timeout path.
RUST: Crate bun_threadpool (Layer 1, deps: std only — NO direct bun*jsc dep). #[repr(transparent)] struct Sync(u32) with manual shift/mask const accessors (idle bits 0-13, spawned 14-27, unused 28, notified 29, state 30-31) backing an AtomicU32 field — no bitflags crate, to guarantee identical CAS encoding. #[repr(C)] pub struct Node { next: *mut Node }and#[repr(C)] pub struct Task { node: Node, callback: unsafe extern "C" fn(*mut Task) }; intrusive, caller-owned storage via raw *mut Task(no Box/Arc).Queue { stack: AtomicUsize, cache: _mut Node }withpush()as acompare_exchange_weak(_, \_, Release, Relaxed)loop andnotify()assync.fetch_or(0, Release)— a relaxed store for push is UNSOUND and forbidden. The two idle-cleanup calls (mimalloc_cleanup + WTF releaseFastMallocFreeMemory) become anon_idle_timeout: Option<fn()>hook on the pool config, injected by the runtime crate, sobun_threadpoolhas no jsc/mimalloc link dep. ABI MIGRATION PRECONDITION: before Zig can enqueue into the Rust pool, every ZigTask.callbackmust be changed to*const fn(*Task) callconv(.C) void(today it is Zig default callconv); until that mechanical pass lands, cross-language enqueue goes through a thin C-ABI shim.unsafeboundaries: (a) raw-pointer Node link traversal in push/pop, (b) callback invocation, (c)@fieldParentPtr-equivalent container_of from *Nodeto*Task.
PERF: schedule() fast path performs exactly: 1 Relaxed load (is_running) + 1 Relaxed fetch_add (wait_group) + 1 Release compare_exchange_weak loop on Queue.stack (Treiber push; typically 1 iteration uncontended) + 1 Release fetch_or on sync. Zero heap allocation per task; tasks are intrusive and size_of::<Task>() == 2*size_of::<usize>() (static_assert). Measure with a microbench scheduling 1M no-op tasks from N producer threads, asserting (a) atomic-op count via instrumented build matches Zig's 4-op fast path and (b) wall-clock throughput within ±5% of the Zig build on the same machine. Any port using a relaxed store for push or a plain load for notify fails the correctness gate before perf is even compared.
FACT: The uSockets event loop is exposed as PosixLoop — an extern struct mirroring C us_loop_t {internal_loop_data align(16), num_polls:i32, num_ready_polls:i32, current_ready_poll:i32, fd:i32, active:u32, pending_wakeups:u32, ready_polls:[1024]EventType} — driven by extern fn us_create_loop/us_loop_run/us_wakeup_loop/us_loop_run_bun_tick. PosixLoop.ref()/unref() bump BOTH num_polls and active (two plain stores); addActive/subActive bump only active (used by FilePoll activate/deactivate). KeepAlive is a 1-byte Status enum declared {active, inactive, done} (so Active=0, Inactive=1, Done=2). KeepAlive is NOT cleanly decoupled from JSC: beyond disable()'s jsc.VirtualMachine.get(), it exposes unrefConcurrently/refConcurrently(vm:*jsc.VirtualMachine) calling vm.event_loop.{un,}refConcurrently(), and unrefOnNextTick[Concurrently] doing @atomicRmw on vm.pending_unref_counter — these touch VM-owned counters, not loop.active. EventLoopTimer is a non-extern intrusive pairing-heap node with FIVE fields {next:timespec, state:State, tag:Tag, heap:IntrusiveField, in_heap:enum{none,regular,fake}}. MiniEventLoop proves the loop layer already runs JS-free (bun build/bun install/shell).
RUST: Crate bun_async (Layer 1, deps: bun*sys; NO bun_jsc). #[repr(C)] pub struct UsLoop opaque, all us_loop*_externs in privatemod ffi(sole unsafe boundary). Decoupling is via trait inversion but the trait must cover ALL counters KeepAlive touches today:pub trait LoopHandle { fn ref_loop(&self); fn unref_loop(&self); /_ num*polls+=1 AND active+=1, Loop.zig:59-69 */ fn add*active(&self,n:u32); fn sub_active(&self,n:u32); /* FilePoll-only path _/ fn ref_concurrently(&self); fn unref_concurrently(&self); /_ atomic, VM-owned counter _/ fn inc_pending_unref(&self); /_ atomic RMW on pending_unref_counter \*/ }. bun_runtime's VirtualMachine and bun_async's MiniEventLoop both implement LoopHandle (Mini's concurrent hooks no-op or hit its own atomic). #[repr(u8)] pub enum KeepAlive { Active=0, Inactive=1, Done=2 }(matches Zig declaration order) withfn ref*<L:LoopHandle>/unref/ref_concurrently/unref_on_next_tick. pub struct EventLoopTimer { next:Timespec, state:State, tag:TimerTag, heap:IntrusiveHeapNode, in_heap:InHeap /* None|Regular|Fake \_/ }—#[repr(C)]is for Rust-internal stable layout /offset_of!only (Zig side is NOT extern, no cross-ABI), pairing-heap in safe Rust overNonNull<EventLoopTimer> with unsafe confined to link/unlink.
PERF: KeepAlive::ref*/unref = 1 branch + 2 non-atomic adds (num_polls, active) — identical to PosixLoop.ref/unref; a trait exposing only add_active would DROP the num_polls bump and change us_loop_run fall-through, so ref_loop/unref_loop must remain a single devirtualized call (generic L:LoopHandle, monomorphized, no dyn). KeepAlive::ref_concurrently/unref_on_next_tick = 1 branch + 1 atomic RMW (monotonic). Timer insert = O(1) pairing-heap meld, allocation-free, intrusive. Measure: assert codegen of KeepAlive::ref*::<VirtualMachine>is branch+2-stores (cargo-asm); microbench 10M ref/unref pairs ≤ Zig baseline; assertsize_of::<EventLoopTimer>()matches Zig@sizeOf(EventLoopTimer) so embedders (TimeoutObject, DNSResolver, …) keep identical footprint.
SRC: /root/bun-5/src/deps/uws/Loop.zig (lines 1-35 PosixLoop extern struct layout {internal_loop_data align(16), num_polls, num_ready_polls, current_ready_poll, fd, active, pending_wakeups, ready_polls:[1024]EventType}); /root/bun-5/src/deps/uws/Loop.zig (lines 59-69 ref()/unref() mutate BOTH num_polls AND active; lines 76-85 addActive/subActive mutate only active (saturating)); /root/bun-5/src/deps/uws/Loop.zig (lines 334-362 extern fn us_create_loop/us_loop_run/us_loop_pump/us_wakeup_loop/us_loop_run_bun_tick/uws_get_loop); /root/bun-5/src/async/posix_event_loop.zig (line 11 Status = enum { active, inactive, done } → Active=0, Inactive=1, Done=2; line 18 disable() calls jsc.VirtualMachine.get()); /root/bun-5/src/async/posix_event_loop.zig (lines 45-55 / 85-97 ref/unref dispatch through jsc.EventLoopHandle / jsc.AbstractVM → platformEventLoop().ref()/unref(); lines 23-38 activate/deactivate call loop.addActive(1)/subActive(1)); /root/bun-5/src/async/posix_event_loop.zig (lines 59-64 unrefConcurrently(vm:*jsc.VirtualMachine)→vm.event_loop.unrefConcurrently(); lines 100-105 refConcurrently; lines 77-82 unrefOnNextTickConcurrently does @atomicRmw(.Add,1,.monotonic) on vm.pending_unref_counter — multiple JSC couplings, not one); /root/bun-5/src/bun.js/VirtualMachine.zig (line 46 pending_unref_counter: i32 = 0 — VM-owned deferred-unref counter targeted by KeepAlive's atomic path); /root/bun-5/src/bun.js/api/Timer/EventLoopTimer.zig (lines 3-9 five fields: next:timespec, state:State, tag:Tag, heap:IntrusiveField, in_heap:enum{none,regular,fake}; non-extern struct (no C ABI to mirror)); /root/bun-5/src/io/heap.zig (lines 1-37 intrusive pairing-heap, allocation-free, insert = single meld with root (O(1))); /root/bun-5/src/bun.js/event_loop/MiniEventLoop.zig (lines 1-22 doc: 'event loop functionality without requiring a JavaScript runtime' for bun build/install/shell — Layer-1 JS-free operation already exists)
[bun_runtime (Layer 3: api/* — generated JSC class wrappers)] crate-layout-classes-ts-v2
FACT: 29 *.classes.ts files drive generate-classes.ts to emit ZigGeneratedClasses.{h,cpp,zig}. Each class becomes a C++ JS<Name> : JSDestructibleObject holding void* m_ctx (the native struct), with wrapped()/offsetOfWrapped() and an IsoSubspace. The C++ side emits a JSC_DEFINE_HOST_FUNCTION(<Name>Prototype__<method>Callback) per method that does dynamicDowncast<JS<Name>>(callFrame->thisValue()) and then calls an extern JSC_CALLCONV EncodedJSValue <Name>Prototype__<method>(void* ptr, JSGlobalObject*, CallFrame*) — i.e. the unwrapped m_ctx is passed as the FIRST argument to native. The Zig side @exports 1338 symbols matching that 3-arg ABI (e.g. ArchivePrototype__blob(thisValue: *Archive, *JSGlobalObject, *CallFrame) callconv(jsc.conv)), plus <Name>Class__construct(*JSGlobalObject,*CallFrame)->?*anyopaque and <Name>Class__finalize(*<Name>) callconv(jsc.conv). jsc.conv = SysV-x86_64 on Windows-x64, C ABI elsewhere. bun:ffi (dlopen/TinyCC) is just one of these 29 — ffi.classes.ts defines a plain FFI class with close/symbols — and is unrelated to the C++↔native binding layer.
RUST: Crate bun_runtime (Layer 3 — the only crate importing both Layer-1 infra AND bun_jsc). Add a jsc_conv! cfg macro in bun_jsc::abi: expands to extern "sysv64" on all(windows, target_arch="x86_64"), extern "C" otherwise (mirrors src/bun.js/jsc.zig:9-12). Extend generate-classes.ts with a Rust emitter that, per class, produces into bun_runtime::generated::<name>: (1) PROTOTYPE METHOD THUNKS — #[no_mangle] pub jsc_conv! fn <Name>Prototype__<method>(this: *mut <Name>, g: *mut JsGlobalObject, cf: *mut CallFrame) -> EncodedJSValue (THREE args, ptr first — exact mirror of ZigGeneratedClasses.cpp:56 / .zig:137). Body: let this = unsafe { core::ptr::NonNull::new_unchecked(this) }; jsc::to_js_host_call(g, <Name>::<method>, (this, g, cf)). The thunk NEVER calls <Name>__fromJS — C++ already did the downcast and handed over wrapped(); we receive the pointer with zero extra FFI hops. (2) CONSTRUCTOR — #[no_mangle] pub jsc_conv! fn <Name>Class__construct(g:*mut JsGlobalObject, cf:*mut CallFrame) -> *mut c_void returning Box::into_raw(Box::new(v)) as *mut c_void (or null on JSError). (3) FINALIZER — #[no_mangle] pub jsc_conv! fn <Name>Class__finalize(this: *mut <Name>) whose body is unsafe { drop(Box::from_raw(this)) }. Symbol name is <Name>Class__finalize (per classSymbolName, generate-classes.ts:33-35), NOT <Name>__finalize, and uses the jsc_conv ABI, NOT plain extern "C". (4) ALIASING DISCIPLINE — generated thunks pass NonNull<Self> (raw) into user impls; user methods take this: NonNull<Self> and dereference per-field. Mutable fields are Cell<T>/UnsafeCell<T>/RefCell<T> so no &mut Self is ever formed across a call that can re-enter JS (host functions are re-entrant; two live &mut to one m_ctx is UB). (5) SHARED-WITH-LAYER-1 OBJECTS — when the native struct is also held by infra (e.g. http callback), the .classes.ts definition gains own: "arc"; codegen then emits Arc::into_raw in construct and Arc::from_raw in a DISTINCT <Name>Class__finalize body, and thunks hand out &<Name> (immutable) only — never &mut through Arc. Box and Arc paths are never mixed for one class. unsafe is confined to these generated thunks (into_raw/from_raw + the jsc_conv extern boundary). bun:ffi lives at bun_runtime::api::ffi as one of the 29 impls, linking tinycc; it does NOT participate in bun_jsc::sys.
PERF: Per JS→native method call: exactly one C++ host-function dispatch + one direct extern call into Rust with m_ctx already in arg0 (no __fromJS round-trip, no vtable, no dyn) — identical hop count to today's Zig path (ZigGeneratedClasses.cpp:1730 ArchivePrototype__blob(thisObject->wrapped(), …)). Measurement: (a) grep -c '@export(' build/debug/codegen/ZigGeneratedClasses.zig == count of #[no_mangle] in the generated Rust module (currently 1338); (b) nm -D on the Rust dylib lists every <Name>Prototype__* / <Name>Class__* symbol the C++ TU declares extern JSC_CALLCONV; (c) perf stat/callgrind on a microbench invoking Archive.prototype.files 1e7× shows ≤1 extra instruction vs the Zig build (the body is a tail call into the user impl, same as @call(bun.callmod_inline, …)).
SRC: /root/bun-5/build/debug/codegen/ZigGeneratedClasses.h (lines 22-78: class JSArchive final : public JSC::JSDestructibleObject with void* m_ctx, wrapped(), offsetOfWrapped(), IsoSubspace template); /root/bun-5/build/debug/codegen/ZigGeneratedClasses.cpp (line 56: extern JSC_CALLCONV JSC::EncodedJSValue … ArchivePrototype__blob(void* ptr, JSGlobalObject*, CallFrame*) — 3-arg ABI, ptr first; line 1730: host-function wrapper calls ArchivePrototype__blob(thisObject->wrapped(), lexicalGlobalObject, callFrame) after dynamicDowncast; lines 53/55/1928/1954: ArchiveClass__construct / ArchiveClass__finalize(m_ctx) symbol names); /root/bun-5/build/debug/codegen/ZigGeneratedClasses.zig (line 137: pub fn ArchivePrototype__blob(thisValue: *Archive, globalObject: *jsc.JSGlobalObject, callFrame: *jsc.CallFrame) callconv(jsc.conv) — native ptr is arg0; line 120: ArchiveClass__finalize(thisValue: *Archive) callconv(jsc.conv); line 125: ArchiveClass__construct(...) -> ?*anyopaque; line 102: extern fn Archive__fromJS is a C++→native IMPORT, not used inside method thunks; lines 171-177 @export(...) block; file totals: 1338 @export( lines vs 2313 callconv(jsc.conv) (latter includes type aliases + extern imports, so not a thunk count)); /root/bun-5/src/bun.js/jsc.zig (lines 9-12: pub const conv = if (isWindows and isX64) .{ .x86_64_sysv = .{} } else .c — JSC callconv is SysV on win-x64, C ABI elsewhere; Rust mirror is a cfg-gated extern "sysv64" / extern "C"); /root/bun-5/src/codegen/generate-classes.ts (lines 25-55: symbolName, protoSymbolName (<Name>Prototype__*), classSymbolName (<Name>Class__*), className (JS<Name>) — the exact symbol generators the Rust emitter must reuse); /root/bun-5/src/bun.js/api/ffi.classes.ts (lines 1-23: define({name:'FFI', construct:true, finalize:true, proto:{close:{fn:'close'}, symbols:{getter:'getSymbols'}}}) — bun:ffi is an ordinary .classes.ts consumer, unrelated to the C++↔Rust binding layer)
[workspace / src/rust/Cargo.toml] crate-layout
FACT: The Layer-0→1→2→3 DAG is aspirational, not already latent. MiniEventLoop (src/bun.js/event*loop/MiniEventLoop.zig:1-19) and the JSValue-free parse pipeline prove a non-JS execution mode exists, but the Layer-1 candidates have real semantic JSC coupling that the original claim's leak taxonomy (typedef / debug / binding-shim) does not cover. bundle_v2.zig has six distinct JSC coupling points outside JSBundler.Plugin: line 50 bun.jsc.hot_reloader.NewHotReloader(BundleV2, EventLoop, true) (comptime cycle with the runtime hot-reloader), lines 1525 & 3564 jsc.ModuleLoader.HardcodedModule.Alias.has/get on the per-import resolve hot path, lines 2259/2958 jsc.Node.fs.NodeFS.writeFileWithPathBuffer, lines 2939/4605 jsc.API.BuildArtifact.OutputKind, line 5047 EventLoop = bun.jsc.AnyEventLoop, line 5083 extern Bun\_\_setupLazyMetafile(*jsc.JSGlobalObject, ...). ThreadPool.zig:707 calls bun.jsc.wtf.releaseFastMallocFreeMemoryForThisThread()(links WTF/bmalloc, not JSC proper). js_printer.zig:397 holds?_bun.jsc.RuntimeTranspilerCache. http.zig depends on jsc.API.ServerConfig.SSLConfig(1817),jsc.CommonAbortReason(2429),jsc.URL (3000/3049/3062). 12 files under src/install/ reference jsc. Achieving the split therefore requires _relocating_ shared data/types into Layer-0 first — HardcodedModule.Alias (already a pure static string table with zero jsc refs of its own), AnyEventLoop, SSLConfig, CommonAbortReason, URL, BuildArtifact.OutputKind, and the WTF malloc shim — rather than asserting the leaks are already shim-only.
RUST: src/rust/Cargo.toml workspace with members [bun_sys, bun_str, bun_threadpool, bun_async, bun_resolve_builtins, bun_http, bun_ast, bun_semver, bun_install, bun_bundler, bun_jsc, bun_runtime]. Enforce the DAG with a tools/check-layers.rs xtask that fails CI if any Layer-0/1 crate's Cargo.toml names bun_jsc or bun_runtime. Per-coupling-point resolution: (a) HardcodedModule.Alias → bun_resolve_builtins (Layer-0 phf::Map<&'static str, Alias>), so bundle_v2's per-import lookup at lines 1525/3564 stays a direct monomorphic call with zero JSC edge; (b) AnyEventLoop + releaseFastMallocFreeMemoryForThisThread + uSockets loop FFI → bun_async (owns the C FFI for uSockets and the extern "C" WTF__releaseFastMallocFreeMemoryForThisThread shim — WTF/bmalloc is a C allocator lib, not JSC); (c) SSLConfig, CommonAbortReason, URL, BuildArtifact.OutputKind, uid_t/gid_t → plain #[repr(C)] structs/enums in bun_sys / bun_http, re-exported by bun_jsc for binding codegen; (d) hot-reloader cycle and Bun__setupLazyMetafile invert via trait BundleHost { type Watcher; fn setup_lazy_metafile(&self, ...); } — bun_bundler::BundleV2<H: BundleHost> is monomorphized inside bun_runtime, while bun_bundler also ships a zero-sized NoHost impl so bun build CLI compiles without bun_jsc; (e) RuntimeTranspilerCache field in the printer becomes Option<&'a dyn TranspilerCache> (cold path: cache hit/miss, not per-token); (f) JSBundler plugin onLoad/onResolve go through &'a dyn PluginHost — accepted as one vtable call per import record. Unsafe policy: drop blanket #![forbid(unsafe_code)]. Instead #![deny(unsafe_op_in_unsafe_fn)] workspace-wide; #![forbid(unsafe_code)] only on pure-logic crates (bun_ast, bun_semver, bun_resolve_builtins); bun_sys, bun_str, bun_async, bun_http, bun_install, bun_jsc each own their own C FFI (mod ffi { #![allow(unsafe_code)] }) for libc/BoringSSL/picohttpparser/uSockets/libarchive/JSC respectively — no god-crate.
PERF: The bundler's per-import-record resolve loop must contain zero dyn calls for the builtin-alias lookup (it becomes a Layer-0 phf lookup, same O(1) hash as today's ComptimeStringMap). The single &dyn PluginHost vtable hop per onResolve/onLoad is bounded by one indirect call per source file, dominated >1000× by lexing/parsing that file; this is the only dyn seam permitted on the hot path. BundleV2<H: BundleHost> monomorphizes the watcher/metafile hooks so steady-state bundling has no added indirection vs Zig. Measure: hyperfine 'bun-zig build ./bench/three.js' 'bun-rust build ./bench/three.js' wall-clock within ±2%, and cargo build -p bun_bundler must succeed with bun_jsc removed from the workspace (proves the dep edge is gone, not just unused).
SRC: /root/bun-5/src/bun.js/event_loop/MiniEventLoop.zig (lines 1-19: 'without requiring a JavaScript runtime' for build/install/shell — verified existence proof of a JS-free execution mode); /root/bun-5/src/bundler/bundle_v2.zig (line 50 bun.jsc.hot_reloader.NewHotReloader(BundleV2, EventLoop, true); lines 1525 & 3564 jsc.ModuleLoader.HardcodedModule.Alias.has/get on the resolve hot path; lines 2259/2958 jsc.Node.fs.NodeFS.writeFileWithPathBuffer; lines 2939/4605 jsc.API.BuildArtifact.OutputKind; line 5047 EventLoop = bun.jsc.AnyEventLoop; line 5083 extern Bun__setupLazyMetafile(*jsc.JSGlobalObject, ...) — six non-plugin JSC coupling points refuting 'plugin-only'); /root/bun-5/src/bun.js/HardcodedModule.zig (427-line file, rg 'JSValue|JSGlobalObject|jsc\.' → 0 matches: the alias table is already pure data, safe to relocate to Layer-0 bun_resolve_builtins with zero behavior change); /root/bun-5/src/threading/ThreadPool.zig (line 707 bun.jsc.wtf.releaseFastMallocFreeMemoryForThisThread() — links WTF/bmalloc (extern WTF__releaseFastMallocFreeMemoryForThisThread in src/bun.js/bindings/WTF.zig:13), so 'zero JSC linkage' is false; the shim moves to bun_async); /root/bun-5/src/js_printer.zig (line 397 runtime_transpiler_cache: ?*bun.jsc.RuntimeTranspilerCache = null — printer holds an optional JSC-side cache pointer; becomes Option<&dyn TranspilerCache> (cold path)); /root/bun-5/src/http.zig (line 1817 jsc.API.ServerConfig.SSLConfig, line 2429 jsc.CommonAbortReason, lines 3000/3049/3062 jsc.URL.hrefFromString/join — Layer-1 HTTP client depends on types currently homed under jsc; these become #[repr(C)] structs in bun_http/bun_sys); /root/bun-5/src/install/ (12 files reference jsc (NetworkTask.zig, PackageInstall.zig, PackageManager.zig, UpdateRequest.zig, patchPackage.zig, security_scanner.zig, PnpmMatcher.zig, dependency.zig, hosted_git_info.zig, install_binding.zig, lifecycle_script_runner.zig, npm.zig) — refutes 'install_binding + UpdateRequest.fromJS only'); /root/bun-5/src/sys.zig (line 370 pub fn fchown(fd: bun.FD, uid: jsc.Node.uid_t, gid: jsc.Node.gid_t) — verified typedef leak; uid_t/gid_t move to bun_sys)
Rust best-practice rules for this codebase
[bun_jsc] R4-not-send-not-sync
FACT: Bun's JSC integration is single-threaded per VM: VirtualMachine is stored in threadlocal var vm: ?*VirtualMachine with is_main_thread flag and threadlocal var cached_global_object. JSValue and *JSGlobalObject are never sent across threads; cross-thread work goes through the task queue (Task = TaggedPointerUnion) which the owning thread drains.
RUST: JSValue, &JSGlobalObject, &VirtualMachine, Strong<T>, and every bun_jsc::raw opaque carry PhantomData<*const ()> so they are !Send + !Sync by construction (no unsafe impl). The only escape hatch is bun_jsc::CrossThreadTask (a #[repr(transparent)] wrapper that IS Send) which owns no JS handles, only a tagged pointer to native data — mirroring ManagedTask/AnyTask. Clippy custom lint: deny unsafe impl Send for any type in bun_jsc::*.
PERF: Zero atomic ops on the JS hot path. JSValue::get_index, Strong::get must contain no lock, fence, or Arc — verify with cargo asm | grep -i 'lock\|mfence' returning empty for bun_jsc release build. Event-loop tick latency p50 unchanged vs Zig (measure via bun bd test bench/event-loop).
SRC: /root/bun-5/src/bun.js/VirtualMachine.zig (L327-328 pub threadlocal var vm: ?*VirtualMachine, threadlocal var cached_global_object: ?*JSGlobalObject; L66 is_main_thread: bool; L454 threadlocal var is_main_thread_vm); /root/bun-5/src/bun.js/event_loop/Task.zig (L4 Task = TaggedPointerUnion(.{ ... }) — cross-thread work is enqueued as tagged native pointers, not JSValues)
FACT: Bun monomorphizes hot loops via comptime bools instead of runtime branches: NewLexer_ takes 8 comptime bool JSON options, NewPrinter takes 5 comptime bool + 1 comptime Writer type, NewServer takes 2 comptime enums (http/https × debug/prod) producing 4 distinct server types, and uws.NewApp(ssl) forks the entire HTTP stack on comptime ssl: bool. Inside these, if (comptime flag) is dead-code-eliminated.
RUST: Rule: where Zig uses comptime bool, Rust uses const generics; where Zig uses comptime Type, Rust uses a generic parameter with a sealed trait. bun_parser::Lexer<const IS_JSON: bool, const ALLOW_COMMENTS: bool, ...>; bun_printer::Printer<W: Write, const ASCII_ONLY: bool, const REWRITE_ESM: bool, const BUN_PLATFORM: bool, const IS_JSON: bool, const SOURCE_MAP: bool>; bun_http::Server<const SSL: bool, const DEBUG: bool>. Runtime enum Mode { … } is permitted ONLY where the Zig code already pays a runtime branch (e.g. String.tag switch). Clippy: large_enum_variant allowed; custom lint denies if cfg.is_json field-reads inside #[hot]-annotated fns when a const-generic exists.
PERF: Each monomorphization has zero branches on the const flags in the inner loop. Measure: perf stat -e branches on bun build ./bench/three.js — branch count within ±0.5% of Zig; binary .text size grows proportionally to instantiation count (proves monomorphization happened, not erased to dyn).
FACT: The event-loop task queue uses TaggedPointerUnion — a packed struct(u64) with 49-bit pointer + 15-bit type tag — over ~90 concrete task types, dispatched by a giant switch in tickQueueWithCount(). There is no vtable; each task is 8 bytes in the queue regardless of payload type.
RUST: #[repr(transparent)] pub struct Task(u64) in bun_runtime::event_loop with const TAG_SHIFT: u32 = 49. A task_union! { Access, AnyTask, AppendFile, ... } declarative macro generates: (a) a #[repr(u16)] enum TaskTag, (b) From<Box<T>> packers, (c) one #[inline(never)] fn run(self, vm) containing the exhaustive match self.tag(). Hard rule: Box<dyn Task> / &dyn Runnable are banned in bun_runtime, bun_http, bun_parser via clippy disallowed_types = ["alloc::boxed::Box<dyn *>"] scoped to those crates. dyn is permitted only in cold paths (CLI arg parsing, error formatting).
PERF: Task enqueue/dequeue is 8-byte copy + one predicted indirect-ish branch (jump table). Measure: size_of::<Task>() == 8; perf record on 1M-timer microbench shows tickQueueWithCount jump-table, not vtable load (callq *0x8(%rax) must NOT appear); throughput ≥ Zig baseline.
SRC: /root/bun-5/src/ptr/tagged_pointer.zig (L1-7 AddressableSize = u49; TaggedPointer = packed struct(u64) { _ptr: u49, data: u15 }; L86 TaggedPointerUnion(comptime Types) builds enum with values 1024-i); /root/bun-5/src/bun.js/event_loop/Task.zig (L1-80 Task = TaggedPointerUnion(.{ Access, AnyTask, ... ~90 types }) with comment 'Update the switch statement in tickQueueWithCount() to run the task')
FACT: Bun systematically encodes single-value wrapper types as integer-backed newtypes so they occupy exactly one register and match C ABI: JSValue = enum(i64) (NaN-boxed EncodedJSValue with sentinel literals js_undefined=0xa, null=0x2, true, false, zero), FD = packed struct(backing_int) where backing_int = if (is_posix) c_int else u64 (Kind is enum(u0) on POSIX so the whole struct collapses to a bare c_int; on Windows it bitpacks {value:u63, kind:u1} into u64), TaggedPointer = packed struct(u64) { _ptr: u49, data: u15 }, IndexType = packed struct(u32) { index: u31, is_overflow: bool }, BabyString = packed struct(u32). Zig's enum(N)/packed struct(N) guarantee identical size/alignment to N and pass-by-value in one register.
RUST: Rule: every newtype over a primitive that crosses FFI or appears in hot-loop signatures is #[repr(transparent)] pub struct T(Backing); — never a bare #[repr(C)] struct or 1-variant enum. Concretely: in bun_jsc: #[repr(transparent)] pub struct JSValue(pub i64); with pub const UNDEFINED: Self = Self(0xa); pub const NULL: Self = Self(0x2); pub const TRUE; pub const FALSE; pub const ZERO: Self = Self(0);. In bun_core::fd: #[cfg(unix)] #[repr(transparent)] pub struct Fd(core::ffi::c_int); / #[cfg(windows)] #[repr(transparent)] pub struct Fd(u64); with const-fn accessors kind()->FdKind (low bit) and value()->u63 implementing the bitpack manually. In bun_core::ptr: #[repr(transparent)] pub struct TaggedPointer(u64); with const fn ptr(&self)->usize { (self.0 & ((1<<49)-1)) as usize } / const fn tag(&self)->u16 { (self.0>>49) as u16 }. In bun_core::alloc: #[repr(transparent)] pub struct IndexType(u32); (bit31 = is_overflow). In bun_core::logger: #[repr(transparent)] pub struct BabyString(u32);. Enforce at compile time: static_assertions::assert_eq_size!(JSValue, i64); #[cfg(unix)] assert_eq_size!(Fd, core::ffi::c_int); #[cfg(windows)] assert_eq_size!(Fd, u64); assert_eq_size!(TaggedPointer, u64); assert_eq_size!(IndexType, u32); plus assert_impl_all!(JSValue: Copy, Send, Sync).
PERF: size_of::<T>() == size_of::<Backing>() and align_of::<T>() == align_of::<Backing>() for every newtype, enforced by static_assertions so a width drift (e.g. accidentally widening Fd to i64 on POSIX) fails to compile. Passed/returned in a single GPR per SysV/Win64 calling convention — verify by inspecting cargo asm for a no-op extern "C" fn id(x: Fd) -> Fd and confirming it is mov eax, edi; ret (POSIX) / mov rax, rcx; ret (Windows), with zero stack spill.
FACT: JSC heap cells are declared opaque {} in Zig and accessed only by *T pointer; 30 such types exist in src/bun.js/bindings/*.zig (JSGlobalObject, JSCell, JSString, JSObject, JSArray, JSMap, JSFunction, JSPromise, JSBigInt, JSUint8Array, AbortSignal, FetchHeaders, Exception, DOMURL, DOMFormData, GetterSetter, CustomGetterSetter, VM, MarkedArgumentBuffer, etc.). For TRUE GC-heap cells, every method body delegates to an extern fn JSC**X**method(*X, ...)or does a zero-cost@ptrCast(@alignCast(this))between opaque cell pointers (JSCell.zig L34/L41 → GetterSetter/CustomGetterSetter). CallFrame is the EXCEPTION: it is declaredopaquebut Zig hard-codes JSC's CallFrameSlot register layout (offset_code_block=2, offset_callee=3, offset_argument_count_including_this=4, offset_this_argument=5, offset_first_argument=6) and readsarguments(), this(), callee(), argumentsCount()via inline@ptrCastto[*]const JSValue + index — ZERO extern calls on the JS→native entry hot path.
RUST: In bun_jsc::raw: each JSC type is a ZERO-SIZED opaque handle (NOT unsized — that needs nightly extern type): #[repr(C)] pub struct JSGlobalObject { _priv: [u8; 0], _pin: PhantomPinned, _no_send_sync: PhantomData<*const ()> } (Nomicon §FFI 'Representing opaque structs'). Public API exposes only &T / NonNull<T>, never owned values; &T and *T are one machine pointer, ABI-identical to Zig *opaque. All extern "C" decls live in one bun_jsc::raw::extern_ module gated #![allow(unsafe_code)]; #[inline(always)] safe wrappers in bun_jsc::cell::* re-export them with lifetime 'vm tied to a VmScope token. Clippy disallowed_methods bans raw::extern_::* outside bun_jsc. ZERO-COST CELL CASTS: provide #[inline(always)] pub unsafe fn JSCell::cast_unchecked<T: JscCell>(&self) -> &T { &*(self as *const _ as *const T) } so getGetterSetter/getCustomGetterSetter compile to no-ops, matching Zig's @ptrCast(@alignCast). CALLFRAME CARVE-OUT (bun_jsc::callframe): CallFrame keeps the same ZST opaque struct, but its accessors do NOT route through extern_. Instead replicate CallFrame.zig L75-111: const OFFSET_CODE_BLOCK: usize = 2; const OFFSET_CALLEE: usize = 3; const OFFSET_ARGC_INCL_THIS: usize = 4; const OFFSET_THIS: usize = 5; const OFFSET_FIRST_ARG: usize = 6; and #[inline(always)] fn as_registers(&self) -> *const JSValue { self as *const Self as *const JSValue }. arguments(), this_value(), callee(), arguments_count() are #[inline(always)] pointer-index loads; argument_count_including_this() reads the low i32 payload of register[4] via a #[repr(C)] union Register { value: JSValue, encoded: EncodedBits, ... } mirroring JSC Register.h. Only isFromBunMain/getCallerSrcLoc/describeFrame go through extern_.
PERF: (1) For heap-cell methods (JSCell::getType, JSString::length, JSGlobalObject::throwOutOfMemory, etc.), cargo asm --release on the safe wrapper must show exactly one call JSC__X__method with no prologue beyond arg moves — identical to what Zig emits for the extern fn delegate. (2) For CallFrame::arguments(), this_value(), callee(), arguments_count(): cargo asm --release must show ZERO call instructions — only mov/lea off rdi at fixed offsets 24/40/32/48 — because Zig emits zero calls here (CallFrame.zig L6-8, L31, L36, L75-111). Any FFI hop on CallFrame argument decoding is a measurable regression on every JS→native call. (3) JSCell::cast_unchecked must compile to zero instructions (pure pointer reinterpret), matching JSCell.zig L34/L41. Measure with cargo asm bun_jsc::callframe::CallFrame::arguments and diff against objdump -d of the Zig build's CallFrame.arguments.
SRC: /root/bun-5/src/bun.js/bindings/CallFrame.zig (L4 pub const CallFrame = opaque {; L6-8 arguments() returns self.asUnsafeJSValueArray()[offset_first_argument..][0..self.argumentsCount()]; L24-26 argumentsCount(); L30-31 this() = asUnsafeJSValueArray()[offset_this_argument]; L35-36 callee() = [offset_callee]; L75-77 inline fn asUnsafeJSValueArray(self) [*]const JSValue { return @ptrCast(@alignCast(self)); }; L80-84 hardcoded offset_code_block=2, offset_callee, offset_argument_count_including_this, offset_this_argument, offset_first_argument; L88-111 argumentCountIncludingThis() hand-ports JSC Register extern union and reads .encoded_value.as_bits.payload — NO extern fn on the hot path.); /root/bun-5/src/bun.js/bindings/JSCell.zig (L1 pub const JSCell = opaque {; L4-6/L16-18/L21-24 delegate to extern fn JSC__JSCell__getObject/toObject/getType (L48-54). L30-42 getGetterSetter/getCustomGetterSetter do @as(*GetterSetter, @ptrCast(@alignCast(this))) — zero-cost opaque-to-opaque pointer cast, NOT an extern call. Rust must provide an equivalent zero-instruction cast.); /root/bun-5/src/bun.js/bindings/JSGlobalObject.zig (L1 pub const JSGlobalObject = opaque {; L5 extern fn JSGlobalObject__throwStackOverflow(*JSGlobalObject) void; L10 extern fn JSGlobalObject__throwOutOfMemoryError — pure opaque + extern-fn pattern (no field access).); /root/bun-5/src/bun.js/bindings/JSString.zig (L1 pub const JSString = opaque {; L2-7 six extern fn JSC__JSString__* decls (toObject, toZigString, eql, iterator, length, is8Bit) — pure opaque + extern-fn pattern.); /root/bun-5/src/bun.js/bindings/JSPromise.zig (L1 pub const JSPromise = opaque { — one of 30 = opaque { declarations grep-verified in src/bun.js/bindings/*.zig (also JSArray.zig:1, JSObject.zig:3, JSMap.zig:2, JSFunction.zig:1, JSBigInt.zig:1, JSUint8Array.zig:1, AbortSignal.zig:1, FetchHeaders.zig:1, Exception.zig:2, VM.zig:1, GetterSetter.zig:1, CustomGetterSetter.zig:1, MarkedArgumentBuffer.zig:1, DOMURL.zig:1, DOMFormData.zig:1).)
[bun_jsc::gc] rust-rules-strong-handleslot
FACT: jsc.Strong is a move-only RAII wrapper around a JSC::HandleSlot (a raw JSC::JSValue* allocated from vm.heap.handleSet()->allocate() — NOT a heap-boxed JSC::Strong<JSValue> object). create→Bun__StrongRef__new (handleSet allocate + writeBarrier), deinit→Bun__StrongRef__delete (handleSet deallocate), set→Bun__StrongRef__set (writeBarrier + slot store), clear→Bun__StrongRef__clear. get() is zero-FFI: it @ptrCasts the *Impl directly to *jsc.DecodedJSValue and reads the slot in-process. No clone/dup FFI exists. Strong.Optional (impl: ?*Impl) is a nullable slot with allocation-reuse semantics — .empty, clearWithoutDeallocation (calls Bun__StrongRef__clear, KEEPS the slot), and set which reuses an existing slot via Bun__StrongRef__set instead of new/delete. ~86 jsc.Strong textual references across src/ (~65 in src/bun.js/), each freed by explicit .deinit() in the owner's deinit.
RUST: In bun_jsc::gc: #[repr(transparent)] pub struct Strong { slot: NonNull<EncodedJSValue>, _no_send: PhantomData<*const ()> } (!Send/!Sync). impl Drop { fn drop(&mut self) { unsafe { Bun__StrongRef__delete(self.slot.as_ptr()) } } }. #[inline] pub fn get(&self) -> JSValue { unsafe { JSValue::from_encoded(*self.slot.as_ptr()) } } — direct slot read, zero FFI, matching Zig L123-127. Explicitly no Clone impl (enforced by simply not deriving/implementing it; the type system rejects .clone() — no clippy disallowed_macros needed, that lint cannot scope by target module). #[must_use] on the type. For the nullable-with-reuse case, a DISTINCT #[repr(transparent)] pub struct OptionalStrong { slot: Option<NonNull<EncodedJSValue>> } (8 bytes via NonNull niche, same as Zig ?*Impl): pub const EMPTY: Self; pub fn clear_without_dealloc(&mut self) → if Some(s) call Bun__StrongRef__clear(s) and KEEP Some; pub fn set(&mut self, g, v) → if Some(s) call Bun__StrongRef__set(s,g,v) else self.slot = Some(Bun__StrongRef__new(g,v)); impl Drop → if Some(s) call Bun__StrongRef__delete(s). A plain Option<Strong> is NOT used for this because setting it to None would run Drop→delete and lose the reusable slot. Custom dylint strong_field_bounded_by: every struct field of type Strong/OptionalStrong must carry a /// bounded-by: <Drop or finalize path> doc-attr or CI fails.
PERF: Identical Bun__StrongRef__new/delete/set/clear FFI call counts vs Zig baseline on a clear→set reuse cycle (specifically: OptionalStrong::clear_without_dealloc + set = 1×clear + 1×set, 0×new, 0×delete — same as Zig Optional.clearWithoutDeallocation + set at L64-67/L107-114). Strong::get() is a single pointer load with no FFI call. size_of::<Strong>() == 8 and size_of::<OptionalStrong>() == 8. Measure by instrumenting the four extern fns with atomic counters in a debug build and running test/js/bun/http/serve.test.ts; counts must match the Zig build ±0.
SRC: /root/bun-5/src/bun.js/bindings/StrongRef.cpp (L9-28: Bun__StrongRef__new returns JSC::HandleSlot (a JSC::JSValue*) from vm.heap.handleSet()->allocate() then writeBarrier<false> + slot store; Bun__StrongRef__delete calls HandleSet::heapFor(slot)->deallocate(slot). L30-49: Bun__StrongRef__set = writeBarrier + *handleSlot = value; Bun__StrongRef__clear = writeBarrier + *handleSlot = {}. No JSC::Strong<JSValue> object is ever constructed.); /root/bun-5/src/bun.js/Strong.zig (L5-20 Strong = @This(); impl: *Impl; create→Impl.init; deinit→impl.deinit(). L123-127 Impl.get@ptrCasts *Impl to *jsc.DecodedJSValue and reads it directly — zero-FFI get. L144-147 only four extern fns: new/delete/set/clear; no clone/dup exists.); /root/bun-5/src/bun.js/Strong.zig (L43-67 Optional = struct { impl: ?*Impl } with .empty (L46) and clearWithoutDeallocation (L64-67: calls ref.clear(), keeps impl populated). L107-114 Optional.set reuses existing *Impl via ref.set(global, value) when present, only calling Impl.init when impl == null — the allocation-reuse path that Option<Strong> cannot express.); /root/bun-5/src/bun.js/webcore/Request.zig (L90 function: jsc.Strong.Optional = .empty — typical field usage requiring the bounded-by annotation in the Rust port.); /root/bun-5 (rg -c 'jsc\.Strong' src/bun.js/ sums to 65; rg -c 'jsc\.Strong' src/ sums to 86 (verified 2026-05-03). The previously stated 405 figure has no basis under any counting methodology.)
[bun_string] rust-rules-wtfstring-refcount
FACT: WTFStringImpl uses an intrusive relaxed-atomic refcount: the C++ field is std::atomic<uint32_t> m_refCount, and ref()/deref() do fetch_add/fetch_sub(s_refCountIncrement=2, std::memory_order_relaxed) with the low bit (s_refCountFlagIsStaticString=0x1) reserved as the static-string flag; deref() calls StringImpl::destroy(this) when the pre-value equals s_refCountIncrement. Zig's extern struct mirrors m_refCount as a plain u32 purely for ABI layout and debug-assertion reads — Zig never mutates it directly; all ownership transfers go through the FFI thunks Bun__WTFStringImpl__ref/deref, which Zig calls manually with no automatic cleanup. Field reads (is8Bit, length, byteSlice) are inline loads of the mirrored struct with zero FFI.
RUST: In crate bun_string (part of the FFI-boundary set, unsafe allowed): #[repr(transparent)] pub struct WtfString(NonNull<WtfStringImpl>); over #[repr(C)] pub struct WtfStringImpl { m_ref_count: AtomicU32, m_length: u32, m_ptr: StringPtr /* union { *const u8, *const u16 } */, m_hash_and_flags: u32 }. m_ref_count is AtomicU32 for layout/type fidelity but is opaque: Rust must NEVER fetch_add/fetch_sub it directly — deref() carries C++-side destruction logic (StringImpl::destroy) that cannot be replicated. Hand-written impl Clone { fn clone(&self) -> Self { unsafe { Bun__WTFStringImpl__ref(self.0.as_ptr()) }; Self(self.0) } } and impl Drop { fn drop(&mut self) { unsafe { Bun__WTFStringImpl__deref(self.0.as_ptr()) } } }. Rule: NEVER #[derive(Clone)] on this type — Clone has FFI side-effects. Do NOT impl Send/Sync despite the atomic counter: destruction may touch per-thread AtomString tables / JSC heap state, so thread-affinity must be preserved (matches Zig, which keeps these on the JS thread). Accessors is_8bit(), len(), latin1_slice(), utf16_slice(), byte_slice() read the #[repr(C)] fields directly (m_hash_and_flags & S_HASH_FLAG_8BIT_BUFFER, m_length, m_ptr) with no FFI call, exactly matching Zig's inline reads.
PERF: Clone/Drop = exactly one extern "C" call each (one relaxed atomic RMW on the C++ side), identical to Zig's manual ref()/deref(). is_8bit()/len()/byte_slice() = zero FFI calls, single inlined load+mask/load, identical codegen to Zig's inline fns. Measure: (a) objdump -d on a release build of WtfString::is_8bit/len shows no call instruction; (b) microbench cloning+dropping 1M WtfStrings vs. Zig loop calling ref()+deref() 1M times — wall-clock within ±2%; (c) assert size_of::<WtfStringImpl>() == 24 (LP64) and align_of == 8 to match the C++ StringImplShape layout.
SRC: /root/bun-5/vendor/WebKit/Source/WTF/wtf/text/StringImpl.h (L163 std::atomic<uint32_t> m_refCount;; L593 static constexpr uint32_t s_refCountIncrement = 0x2;; L1190 m_refCount.fetch_add(s_refCountIncrement, std::memory_order_relaxed);; L1202-1206 fetch_sub(..., relaxed) then if (oldRefCount != s_refCountIncrement) return; StringImpl::destroy(this); — refcount is relaxed-atomic and deref owns destruction.); /root/bun-5/src/string/wtf.zig (L3-7 extern struct layout m_refCount: u32, m_length: u32, m_ptr: union{latin1,utf16}, m_hashAndFlags: u32 (ABI mirror only); L25 s_refCountFlagIsStaticString = 0x1; L28 s_refCountIncrement = 0x2; L32-33 refCount() reads m_refCount / s_refCountIncrement (debug-assertion read, never mutates).); /root/bun-5/src/string/wtf.zig (L89-99 deref() calls bun.cpp.Bun__WTFStringImpl__deref(self) via FFI (surrounded only by debug asserts); L101-107 ref() calls bun.cpp.Bun__WTFStringImpl__ref(self) via FFI — Zig never bumps the counter itself.); /root/bun-5/src/string/wtf.zig (L56-58 is8Bit = inline m_hashAndFlags & s_hashFlag8BitBuffer != 0; L60-62 length = inline m_length; L52-54 byteSlice = inline ptr+len — zero FFI for field reads.); /root/bun-5/src/bun.js/bindings/BunString.cpp (L53-60 extern "C" void Bun__WTFStringImpl__deref(WTF::StringImpl* impl) { impl->deref(); } and Bun__WTFStringImpl__ref(...) { impl->ref(); } — FFI thunks are thin wrappers over the atomic C++ ref/deref.)
[bun_sys (syscall wrapper crate)] rust-rules
FACT: Bun centralizes OS calls in src/sys.zig. On Linux many wrappers emit the raw syscall instruction via std.os.linux (no libc, no errno TLS); others still call libc. macOS/FreeBSD route through std.c; Windows mixes libuv/kernel32/ntdll. Every wrapper returns sys.Maybe(T), a Zig tagged union union(enum){err: sys.Error, result: T} where sys.Error is a non-extern struct {errno: u16, fd: bun.FD, from_libuv: void|bool, path: []const u8, syscall: sys.Tag, dest: []const u8}. Neither Maybe nor Error has a defined C ABI (tagged union, slice fields), so callers see a safe Zig-only result type and never raw c_int/errno.
RUST: bun_sys crate is the sole owner of raw OS calls. Linux backend uses rustix with the linux_raw feature (or linux-raw-sys + inline syscall!) so it matches Zig's libc-free path; macOS/FreeBSD backend uses libc::*; Windows backend wraps libuv/ntdll via bun_uv. Public API: pub fn open(path: &CStr, flags: O) -> Maybe<Fd> etc., where pub enum Maybe<T> { Ok(T), Err(SysError) } and pub struct SysError { errno: Errno, syscall: SyscallTag, fd: Fd, path: Option<PathSlice>, dest: Option<PathSlice> } are Rust-internal types with NO cross-language layout guarantee — Zig's union(enum) and []const u8 have no C ABI, so by-value interop is unsound. For incremental migration, the FFI edge is a separate bun_sys::ffi module exposing extern "C" fn bun_sys_open(path: *const c_char, flags: u32, out_fd: *mut i32, out_err: *mut SysErrorC) -> u8 where #[repr(C)] struct SysErrorC { errno: u16, syscall: u16, fd: i32, path_ptr: *const u8, path_len: usize, dest_ptr: *const u8, dest_len: usize }; the Zig shim reconstructs its native Maybe/Error from that flat struct. Unsafe boundary: #![deny(unsafe_op_in_unsafe_fn)] on bun_sys; workspace Cargo.toml sets [workspace.lints.rust] unsafe_code = "forbid" and only bun_sys, bun_ffi, bun_uv, bun_jsc::raw, and codegen output override it with unsafe_code = "allow". CI greps for unsafe outside that allowlist.
PERF: On Linux the baseline is the inline syscall instruction (NOT glibc): bun_sys::open must compile to mov rax,NR; syscall; cmp rax,-4095; jae .err with ≤2 extra instructions (tag store + errno narrow) over what std.os.linux.open emits today — verified by cargo asm bun_sys::open diffed against zig build-obj -OReleaseFast objdump. End-to-end: strace -c on bun install and test/js/node/fs shows identical syscall counts ±0, and hyperfine --warmup 3 on the fs microbench in bench/ stays within ±2% of the Zig build. macOS/Windows: identical dtruss/ETW syscall counts.
SRC: /root/bun-5/src/sys.zig (L1-3 doc comment: "On linux, many of these functions emit direct system calls directly (std.os.linux). Others call libc APIs. Windows uses a mix of libuv, kernel32 and ntdll. macOS uses libc." — confirms 'many', not 'all', and zero-libc Linux path.); /root/bun-5/src/sys.zig (L53-58: pub const syscall = switch (Environment.os) { .linux => std.os.linux, .mac, .freebsd => std.c, .windows, .wasm => @compileError(...) }; — Linux bypasses libc entirely.); /root/bun-5/src/sys.zig (L333 pub const Error = @import("./sys/Error.zig"); and L336-338 pub fn Maybe(comptime ReturnTypeT: type) type { return bun.api.node.Maybe(ReturnTypeT, Error); } — uniform safe result type.); /root/bun-5/src/bun.js/node.zig (L61-74: pub fn Maybe(...) type { ... return union(Tag) { err: ErrorType, result: ReturnType, pub const Tag = enum { err, result }; ... } } — plain (non-extern, non-packed) tagged union; Zig spec gives it no defined C layout, so Rust #[repr(C)] cannot mirror it.); /root/bun-5/src/sys/Error.zig (L11 pub const Int = u16; and L16-21 fields errno: Int, fd: bun.FD, from_libuv: bool|void, path: []const u8, syscall: sys.Tag, dest: []const u8 — non-extern struct with Zig slices and platform-dependent void; no C ABI, must be flattened to {ptr,len} pairs at any FFI edge.)
FACT: Bun's 616 extern fn declarations under src/bun.js/ (94 in JSValue.zig) are reached via THREE distinct patterns, not one. (a) ~119 C++ functions carry [[ZIG_EXPORT(tag)]] attributes and are called through auto-generated bun.cpp.* wrappers emitted by src/codegen/cppbind.ts into build/*/codegen/cpp.zig; the generator recognizes FIVE tags (cppbind.ts:400 type ExportTag = "check_slow" | "zero_is_throw" | "false_is_throw" | "null_is_throw" | "nothrow") and emits a different error-detection shape per tag. (b) ~107 call sites use the hand-written fromJSHostCall (host_fn.zig:111-126, JSValue-only, value == .zero sentinel) or fromJSHostCallGeneric (host_fn.zig:128-152, non-JSValue, calls scope.returnIfException()) for locally-declared externs not yet migrated to cppbind. (c) The remainder are called raw with no wrapper — e.g. JSValue.zig:65-68 getDirectIndex invokes JSC__JSValue__getDirectIndex directly. Critically, the five tags encode different exception-detection ABIs: nothrow → bare extern; zero_is_throw → if (value == .zero) return error.JSError; false_is_throw → if (value == false) return error.JSError; null_is_throw → if (value == null) return error.JSError; check_slow → a SECOND extern call Bun__RETURN_IF_EXCEPTION(globalObject) (bindings.cpp:6214) that reads the VM exception slot, because the return value (e.g. int32_t for JSC__JSValue__coerceToInt32, bindings.cpp:4523) carries no sentinel.
RUST: Extend src/codegen/cppbind.ts (NOT generate-classes.ts) to additionally emit bun_ffi/src/gen/cpp.rs alongside cpp.zig, reusing the same parsed [[ZIG_EXPORT(tag)]] table. For each function emit (1) a private raw decl in mod raw { unsafe extern "C" { fn JSC__X(...) -> CRet; } } and (2) a public #[inline(always)] safe thunk whose body is selected PER TAG: nothrow → pub fn x(...) -> CRet { unsafe { raw::JSC__X(...) } }; zero_is_throw → return type Result<JSValue, JSError>, body let v = unsafe { raw::JSC__X(...) }; if v.0 == 0 { Err(JSError) } else { Ok(v) }; false_is_throw → Result<(), JSError> keyed on !v; null_is_throw → Result<NonNull<T>, JSError> keyed on v.is_null(); check_slow → let r = unsafe { raw::JSC__X(...) }; if unsafe { raw::Bun__RETURN_IF_EXCEPTION(global) } { Err(JSError) } else { Ok(r) }. The hand-written fromJSHostCall/fromJSHostCallGeneric analogues live in bun_ffi/src/thunk.rs as two #[inline(always)] generics: from_host_call(global, || unsafe { raw_fn(...) }) -> Result<JSValue, JSError> (zero-sentinel) and from_host_call_generic<T>(global, || unsafe { raw_fn(...) }) -> Result<T, JSError> (probes Bun__RETURN_IF_EXCEPTION). Unsafe confinement: #![deny(unsafe_code)] at the bun_ffi crate root (NOT forbid, which is non-overridable and would trigger rustc E0453), with #[allow(unsafe_code)] applied only on mod gen and mod thunk; every other crate in the workspace gets #![forbid(unsafe_code)] so hand-written code can only reach C++ through the generated safe thunks.
PERF: Per-tag, the Rust thunk's release-mode codegen must match the existing Zig release path in build/release/codegen/cpp.zig: nothrow → tail call to extern, zero added instructions; zero_is_throw/false_is_throw/null_is_throw → exactly 1 cmp/test + 1 conditional branch after the extern call (matches cppbind.ts:720-722 emission if (value == <sentinel>) return error.JSError;); check_slow → exactly 1 additional extern call to Bun__RETURN_IF_EXCEPTION + 1 test + 1 branch (matches cppbind.ts:681-683 / generated cpp.zig:558-560 for JSC__JSValue__coerceToInt32). Verify with cargo asm --release bun_ffi::gen::jsc_jsvalue_coerce_to_int32 vs objdump -d build/release/bun --disassemble-symbols=JSC__JSValue__coerceToInt32 callers — both must show call JSC__JSValue__coerceToInt32; call Bun__RETURN_IF_EXCEPTION; test al,al; jne. The debug-only ExceptionValidationScope/TopExceptionScope instrumentation (host_fn.zig:118-120, cppbind.ts:674-678/712-716) is gated on Environment.ci_assert in Zig and must be #[cfg(debug_assertions)] in Rust so it contributes zero instructions to release.
SRC: /root/bun-5/src/codegen/cppbind.ts (L400 type ExportTag = "check_slow" | "zero_is_throw" | "false_is_throw" | "null_is_throw" | "nothrow"; L646-726 per-tag wrapper emission: nothrow→bare pub extern fn, check_slow→raw.X(...) then if (Bun__RETURN_IF_EXCEPTION(global)) return error.JSError (L681-683), sentinel tags→if (value == .zero/false/null) return error.JSError (L718-722). This is the generator a Rust port must extend, not generate-classes.ts.); /root/bun-5/src/bun.js/bindings/bindings.cpp (L4523 [[ZIG_EXPORT(check_slow)]] int32_t JSC__JSValue__coerceToInt32(JSC::EncodedJSValue, JSC::JSGlobalObject*) — returns raw i32 (0 is valid), so exception detection MUST go through L6214 extern "C" [[ZIG_EXPORT(nothrow)]] bool Bun__RETURN_IF_EXCEPTION(JSC::JSGlobalObject*), not a zero-sentinel.); /root/bun-5/build/debug/codegen/cpp.zig (L548-562 generated JSC__JSValue__coerceToInt32 wrapper: ci_assert branch uses TopExceptionScope + scope.returnIfException(); release branch is const result = raw.JSC__JSValue__coerceToInt32(...); if (Bun__RETURN_IF_EXCEPTION(arg1)) return error.JSError; return result; — proves check_slow is two extern calls in release, not 1 cmp+jmp.); /root/bun-5/src/bun.js/jsc/host_fn.zig (L111-126 fromJSHostCall: opens ExceptionValidationScope, calls extern, if (value == .zero) error.JSError else value — JSValue-only zero-sentinel path. L128-152 fromJSHostCallGeneric: opens TopExceptionScope, calls extern, try scope.returnIfException() — non-JSValue path that probes VM state. Two distinct hand-written shapes the Rust thunk module must mirror.); /root/bun-5/src/bun.js/bindings/JSValue.zig (Three coexisting patterns in one file: L43-45 coerceToInt32 → bun.cpp.JSC__JSValue__coerceToInt32 (cppbind-generated, check_slow); L55-63 isJSXElement → bun.jsc.fromJSHostCallGeneric(globalThis, @src(), JSC__JSValue__isJSXElement, .{...}) (hand-written wrapper); L65-68 getDirectIndex → raw JSC__JSValue__getDirectIndex(this, globalThis, i) with no wrapper. Refutes any "single wrapper / ONLY place" framing.)
FACT: The bun install perf fixture is bench/install/ — a create-t3-app project (Next.js 16, tRPC, Drizzle, Tailwind, NextAuth, react 19) with a committed bun.lock. It exercises the full resolver + hoister + tarball-extract path with ~hundreds of transitive deps. The repo's standard for CLI wall-clock measurement is hyperfine --warmup 1 (used verbatim in bench/test/README.md for the bun test suites and recommended in docs/project/benchmarking.mdx).
RUST: In bun-bench::install: fn measure_install(fixture: &Path, flag: bool) -> HyperfineResult shells hyperfine --warmup 1 --runs 5 --export-json - --prepare 'rm -rf node_modules' '<bun> install --ignore-scripts' with BUN_RS_Install={0|1} injected into env. #[derive(serde::Deserialize)] struct HyperfineResult { results: Vec<HfRun> } / struct HfRun { mean: f64, median: f64, stddev: f64 }. The Rust install impl lives in bun-install and must keep the lockfile binary format byte-identical (see PERF-07), so the toggle only swaps the in-process resolver/extractor — the on-disk artifacts are diffable.
PERF: hf_rs.median <= 1.02 * hf_zig.median on the T3-stack fixture with cold node_modules (warm npm cache). Secondary invariant: sha256(bun.lock) is unchanged and diff -r node_modules.zig node_modules.rs is empty — i.e. the shadow run is bit-identical, which is the precondition for trusting the timing comparison. CI gate fails the PR if either the 2% wall-clock or the diff check trips.
SRC: /root/bun-5/bench/install/package.json (create-t3-app fixture: next@^16.1.0, @trpc/*@^11, drizzle-orm, react@^19, tailwindcss@^4 — representative real-world dep graph); /root/bun-5/bench/test/README.md (lines 14-34: hyperfine --warmup 1 'bun test ...' '...' — established pattern for CLI A/B timing in this repo); /root/bun-5/docs/project/benchmarking.mdx (line 24: 'For benchmarking scripts or CLI commands, we recommend hyperfine')
FACT: Bun already has a first-class env-var feature-flag registry: src/env_var.zig defines pub const feature_flag = struct { ... } where each entry is newFeatureFlag("BUN_FEATURE_FLAG_X", .{}) returning a lazily-cached typed accessor with .get() -> bool. ~40 flags exist (DISABLE_ASYNC_TRANSPILER, DISABLE_STREAMING_INSTALL, EXPERIMENTAL_HTTP2_CLIENT, NO_LIBDEFLATE, …) and they are read at the dispatch site to pick between two live code paths in the same binary. bench/test/README.md already demonstrates A/B benchmarking by setting BUN_FEATURE_FLAG_DISABLE_ISOLATION_SOURCE_CACHE=1 inside hyperfine.
RUST: Crate bun-feature-flags: a build.rs-generated enum RsImpl { TextDecoder, Glob, HttpServer, Install, Bundler, ... } (one variant per migrated subsystem) plus #[inline(always)] pub fn use_rust(which: RsImpl) -> bool that reads BUN_RS_<Name> via std::env::var_os once and caches in a static OnceLock<[bool; N]>. Export each as #[no_mangle] extern "C" fn bun_rs_use_<Name>() -> bool so Zig call sites can branch without linking Rust types. The Zig side adds matching entries to env_var.zig's feature_flag struct so both languages see the same truth. Unsafe boundary: none — this is plain env reads; the extern "C" fns are trivially safe.
PERF: The flag read itself must cost ≤ 1 ns after first call (cached) — assert via a mitata snippet bench/snippets/feature-flag.mjs that the branch overhead is unmeasurable (< 1% of the cheapest gated op). More importantly this is the mechanism by which every other invariant is measured: every PERF-* CI gate runs the same binary twice with BUN_RS_<X>=0 then =1, so a single build artifact yields both baseline and candidate numbers, eliminating compiler/link-time noise from the comparison.
SRC: /root/bun-5/src/env*var.zig (lines 158-219: pub const feature_flag = struct { BUN_FEATURE_FLAG_DISABLE*\* = newFeatureFlag(...) }— typed, cached, env-driven boolean toggles); /root/bun-5/bench/test/README.md (line 31:hyperfine -n 'bun --isolate (cache off)' 'BUN_FEATURE_FLAG_DISABLE_ISOLATION_SOURCE_CACHE=1 bun test ...' — precedent for env-flag A/B timing); /root/bun-5/src/feature_flags.zig (lines 1-6 header comment: 'If you are adding feature-flags to this file, you are in the wrong spot. Go to env_var.zig instead.')
FACT: Bun pins struct layouts at compile time wherever bytes cross a language or disk boundary: Zig uses comptime { if (@sizeOf(Npm.PackageVersion) != 240) @compileError(...) } (npm.zig:882-885) and assertNoUninitializedPadding(T) (padding_checker.zig) over every lockfile-serialized extern struct; C++ uses static_assert(sizeof(WTF::StringBuilder) == 24, "StringBuilder.zig assumes ...") (StringBuilderBinding.cpp:5) and static_assert(sizeof(TopExceptionScope) == 56) (TopExceptionScopeBinding.cpp:13). These are tripwires that fail the build the instant a layout drifts.
RUST: Crate bun-abi provides macro_rules! assert_layout { ($T:ty, size = $s:expr, align = $a:expr) => { const _: () = assert!(core::mem::size_of::<$T>() == $s && core::mem::align_of::<$T>() == $a); } } plus a #[proc_macro_derive(NoPadding)] that walks fields with core::mem::offset_of! and const-asserts offset[i] + size[i] == offset[i+1] (mirroring assertNoUninitializedPadding). Every Rust mirror of a Zig extern struct is #[repr(C)] and carries assert_layout!(PackageVersion, size = 240, align = 8). For cross-language drift detection, bun-codegen emits a generated layout_asserts.cpp containing static_assert(sizeof(rs::Foo) == sizeof(zig::Foo)) for every shared type, compiled into the binary so CI cannot ship a mismatch.
PERF: Layout equality IS the perf invariant for any subsystem that ships bytes (lockfile, npm manifest cache, JSC binding thunks): if size_of::<T>() != @sizeOf(T) the Rust path either reads garbage or forces a copy/translate step that did not exist in Zig. Measurement is compile-time (const _: () = assert!(...) / static_assert) — zero runtime cost, 100% coverage. The CI gate is simply 'the build links'.
SRC: /root/bun-5/src/install/npm.zig (lines 882-886: comptime { if (@sizeOf(Npm.PackageVersion) != 240) @compileError(...) } — hard size pin on a serialized extern struct); /root/bun-5/src/install/padding_checker.zig (lines 1-50: assertNoUninitializedPadding(T) recursively rejects implicit padding in lockfile-serialized structs); /root/bun-5/src/bun.js/bindings/StringBuilderBinding.cpp (line 5: static_assert(sizeof(WTF::StringBuilder) == 24, "StringBuilder.zig assumes WTF::StringBuilder is 24 bytes")); /root/bun-5/src/bun.js/bindings/TopExceptionScopeBinding.cpp (line 13: static_assert(sizeof(TopExceptionScope) == 56) — Zig↔C++ layout contract)
[tools/bun-bench (host-side perf-gate crate, not linked into the runtime)] perf-gates
FACT: Bun's microbench harness is a thin wrapper over mitata@1.0.20: bench/runner.mjs re-exports bench/group/summary and overrides run() to set opts.format = 'json' whenever process.env.BENCHMARK_RUNNER is truthy. mitata 1.0.20's JSON format is {layout, benchmarks, context} where each benchmarks[] entry is {kind, alias, group, baseline, args, runs: [{name, args, stats?, error?}]} and stats (nested under runs[], not at the top level) has {min, max, avg, p25, p50, p75, p99, p999, ticks, kind, samples}. bench/snippets/runner-entrypoint.js — explicitly marked // note: this isn't done yet / // TODO: finish this — scans bench/snippets/ for files containing a // @runtime bun,node,deno tag (currently only 8 files are tagged; ~105 of 120 .mjs snippets import ../runner.mjs but most lack the tag and are skipped), spawns each tagged file once per runtime with BENCHMARK_RUNNER=1, JSON-parses stdout, and persists into SQLite as two tables: benchmarkResults(id INTEGER PK, results TEXT) holding JSON.stringify(benchmarks) as an opaque blob, and runs(id INTEGER PK, runtime TEXT, runtimeVersion TEXT, benchmarkID TEXT, timestamp INTEGER, elapsed REAL, benchmarkResultID INTEGER). There are no per-percentile numeric columns; percentiles live only inside the JSON blob. This prototype is the only structured perf-history store in the repo.
RUST: Crate bun-bench (workspace member under tools/, host orchestration only, zero unsafe). Serde model that matches mitata 1.0.20's actual nested shape: #[derive(Deserialize)] struct MitataReport { layout: serde_json::Value, context: serde_json::Value, benchmarks: Vec<Trial> }, struct Trial { alias: String, kind: String, group: Option<serde_json::Value>, runs: Vec<TrialRun> }, struct TrialRun { name: String, #[serde(default)] stats: Option<Stats>, #[serde(default)] error: Option<serde_json::Value> }, struct Stats { avg: f64, min: f64, max: f64, p25: f64, p50: f64, p75: f64, p99: f64, p999: f64 }. DB layer mirrors the existing two-table opaque-blob schema exactly so history is shared with the JS prototype: struct RunRow { runtime: Runtime, runtime_version: String, benchmark_id: String, timestamp: i64, elapsed: f64, benchmark_result_id: i64 } + struct BenchmarkResultRow { id: i64, results_json: String }, written via rusqlite with the identical CREATE TABLE IF NOT EXISTS DDL. Percentiles are recovered at compare-time by serde_json::from_str::<Vec<Trial>>(&row.results_json) — no schema migration. #[repr(u8)] enum Runtime { Bun, BunRs, Node, Deno } extends the existing // @runtime tag vocabulary with bun-rs so one snippet runs against both Zig-Bun and Rust-Bun. fn compare(baseline: &Stats, candidate: &Stats, tol_pct: f64) -> Verdict { Pass | Regress { metric, delta_pct } } operates on parsed Stats, not on DB columns.
PERF: For every // @runtime-tagged snippet, Runtime::BunRsstats.avg and stats.p99 must be within tol_pct (default 5%) of the Runtime::Bun (Zig) baseline row that shares the same benchmark_id on the same host. Measured by: bun-bench spawns each snippet under both runtimes with BENCHMARK_RUNNER=1, deserializes the nested benchmarks[].runs[].stats from stdout, INSERTs into the existing runs/benchmarkResults tables, then for each (benchmark_id, trial.alias, run.name) tuple calls compare() and exits non-zero on any Regress. CI gate fails on non-zero exit; the SQLite file (runs-{platform}-{arch}.db) is the historical record for trend detection.
SRC: /root/bun-5/bench/runner.mjs (lines 4-15: const asJSON = !!process?.env?.BENCHMARK_RUNNER; run() sets opts.format = 'json' when asJSON; re-exports Mitata.bench, Mitata.group, Mitata.summary.); /root/bun-5/bench/node_modules/mitata/src/main.mjs (lines 98-109 build each benchmark as {kind, args, alias, group, baseline, runs: [{stats, error, args, name}]}; lines 316-331 (json formatter) emit JSON.stringify({layout, benchmarks, context}) — stats is nested inside runs[], top-level name field is alias.); /root/bun-5/bench/node_modules/mitata/src/lib.d.mts (lines 37-44: interface stats { debug, ticks, samples, kind, min, max, avg, p25, p50, p75, p99, p999 } — the exact field set the Rust Stats struct must deserialize.); /root/bun-5/bench/snippets/runner-entrypoint.js (line 1 // note: this isn't done yet, line 198 // TODO: finish this; lines 21-90 getEntry() only picks files containing // @runtime ; lines 107-115 spawn env sets BENCHMARK_RUNNER: '1'; lines 174-196 create benchmarkResults(id, results TEXT) and runs(id, runtime, runtimeVersion, benchmarkID, timestamp, elapsed, benchmarkResultID) — no percentile columns; lines 225-242 INSERT INTO benchmarkResults(results) VALUES(JSON.stringify(benchmarks)) RETURNING id then INSERT INTO runs(...).); /root/bun-5/bench/package.json (line 19: "mitata": "1.0.20" — exact pin, so the JSON shape above is stable.); /root/bun-5/bench/snippets (120 .mjs files total; 105 import ../runner.mjs; only 8 .mjs/.js files contain a // @runtime tag and are actually executed by runner-entrypoint.js — 'every snippet' is incorrect, the harness is opt-in via the tag.)
[bun-codegen] perf-gates
FACT: The 29 *.classes.ts files declare the Zig-backed subset of Bun's native JS classes (hand-written C++ bindings under src/bun.js/bindings/*.cpp — 20+ files with their own HashTableValue arrays — and the separate *.bind.ts/generate-jssink.ts surfaces are NOT covered). Each proto/klass entry is a Field union of seven variants: {getter}, {value}, {setter}, {accessor}, {fn, length?, DOMJIT?}, {internal}, {builtin} (class-definitions.ts:30-84). Only the DOMJIT block carries machine-readable argument types — 10 methods across 3 files (crypto/server/encoding) out of ~753 fn: entries; the other ~743 expose only length?: number. Several .classes.ts files (crypto, sockets, streams, sql) build their definitions with .map()/spread/loops, so they are executable TS, not static data. For DOMJIT methods, generate-classes.ts emits a JSC::DOMJIT::Signature whose target is the C++ ${fn}WithoutTypeChecksWrapper JIT_OPERATION (line 124, defined 142-169); that wrapper sets up JITOperationPrologueCallFrameTracer and then calls the Zig extern via reinterpret_cast<JSClass*>(thisValue)->wrapped() — the JIT never calls the native symbol directly. The Zig extern is declared callconv(jsc.conv) (generate-classes.ts:95-98), where jsc.conv is .x86_64_sysv on Windows-x64 and .c elsewhere (jsc.zig:9-12), matching SYSV_ABI/JIT_OPERATION_ATTRIBUTES in WebKit.
RUST: In bun-codegen (workspace build.rs): (a) DO NOT AST-parse .classes.ts — crypto.classes.ts:3-4/74, sockets.classes.ts, streams.classes.ts, sql.classes.ts use .map()/spread/for-loops that require JS evaluation. Instead, extend the existing TS codegen to dump classes.json and #[derive(serde::Deserialize)] it into struct ClassDef { name: String, proto: IndexMap<String, FieldDef>, klass: IndexMap<String, FieldDef> } with #[serde(untagged)] enum FieldDef { Getter{getter:String,..}, Value{value:String}, Setter{setter:String}, Accessor{accessor:Accessor}, Fn{fn_:String, length:Option<u8>, domjit:Option<DomJit>}, Internal{internal:bool}, Builtin{builtin:String, length:Option<u8>} }. (b) Emit per-method Rust externs that mirror ZigDOMJITFunctionType/the host-call shape using a cfg-gated ABI macro: #[cfg(all(windows, target_arch="x86_64"))] pub type JscFn<...> = unsafe extern "sysv64" fn(...); #[cfg(not(...))] ... extern "C" ... — plain extern "C" is ABI-unsound on win-x64 because the C++ side is SYSV_ABI. The only unsafe lives at this FFI boundary. (c) Emit bench/generated/<Class>.mjs mitata snippets ONLY for the 10 DOMJIT-annotated methods, deriving fixtures from DOMJIT.args (JSUint8Array→new Uint8Array(64), JSString→'a'.repeat(64), int→42, bool→true, JSValue→{}); for the ~743 non-DOMJIT fn/setter fields and for instance construction (many classes set noConstructor:true or need ctor args), read a hand-maintained bench/fixtures.json keyed by <Class>.<method> — the generator skips entries with no fixture rather than guessing.
PERF: Because the DOMJIT fast path is JIT → C++ ...WithoutTypeChecksWrapper → native extern in BOTH the Zig and Rust backends (the wrapper is generated C++ and unchanged), the per-call overhead delta between Zig and Rust is exactly the body of the extern, not the dispatch. Gate each generated bench at rust_ns / zig_ns ≤ 1.02 (same slack as all other native calls — no special 1.00× rule, since the 'JIT bypasses the thunk' premise is false). Measure with mitata on the emitted bench/generated/*.mjs against both binaries; CI fails if any DOMJIT-covered method exceeds 1.02× or if a Rust extern's exported symbol is missing/ABI-mismatched (link-time check via nm/dumpbin that the sysv64 symbol exists on win-x64).
SRC: /root/bun-5/src/codegen/class-definitions.ts (lines 30-84: Field union with all seven variants — line 40 { value: string }, line 41 { setter }, lines 42-46 { accessor }, lines 47-69 { fn, length?, DOMJIT?: { returns, args?, pure? } }, line 70 { internal: true }, lines 71-84 { builtin, length? }. Non-DOMJIT fn fields carry no arg types, only length?: number.); /root/bun-5/src/codegen/generate-classes.ts (lines 57-79: DOMJITName/argTypeName/DOMJITType map bool|int|JSUint8Array|JSString|JSValue → C++ param types and SpecBoolean|SpecInt32Only|SpecUint8Array|SpecString|SpecHeapTop. lines 95-98: ZigDOMJITFunctionType emits callconv(jsc.conv). lines 110-134: DOMJITFunctionDeclaration builds JSC::DOMJIT::Signature DOMJITSignatureFor${fn}(${fn}WithoutTypeChecksWrapper, ...) — Signature targets the C++ Wrapper, not the native symbol. lines 142-169: JSC_DEFINE_JIT_OPERATION(...Wrapper) does JITOperationPrologueCallFrameTracer then ${sym}WithoutTypeChecks(reinterpret_cast<${jsClassName}*>(thisValue)->wrapped(), lexicalGlobalObject, ...) — DOMJIT always goes through this C++ wrapper.); /root/bun-5/src/bun.js/jsc.zig (lines 9-12: pub const conv = if (isWindows and isX64) .{ .x86_64_sysv = .{} } else .c; — on Windows-x64 the Zig externs use SysV, not Win64; a Rust extern "C" on that target would mismatch.); /root/bun-5/vendor/WebKit/Source/WTF/wtf/PlatformCallingConventions.h (line 37: #define SYSV_ABI __attribute__((sysv_abi)); line 45: JSC_HOST_CALL_ATTRIBUTES SYSV_ABI; line 84: JIT_OPERATION_ATTRIBUTES SYSV_ABI — the C++ side of host/JIT-operation calls is sysv on win-x64.); /root/bun-5/src/bun.js/webcore/encoding.classes.ts (lines 23-31: decode: { fn: 'decode', length: 1, DOMJIT: { returns: 'JSString', args: ['JSUint8Array'] } } — one of only 10 DOMJIT-annotated methods (across 3 files: crypto/server/encoding) out of ~753 fn: entries repo-wide.); /root/bun-5/src/bun.js/api/crypto.classes.ts (lines 3-4: const names = [...]; const named = names.map(name => define({...})); line 74: ...named spread into the export array — .classes.ts are executable TS, so the Rust side must consume JSON emitted by the existing codegen, not AST-parse.)
FACT: Bun's documented load-testing protocol for Bun.serve is oha (or bombardier) with Accept-Encoding: identity, explicitly because Node-based clients (autocannon) cannot saturate the server. The canonical fixtures are bench/react-hello-world/react-hello-world.jsx (Response built from renderToReadableStream) and bench/express/express.mjs, each bound to a fixed port and hit with oha http://localhost:PORT -n 500000 -H 'Accept-Encoding: identity'. There is no machine-readable capture today — the README expects a human to read oha's TUI. On the server side, the only extern-C per-request hook that exists is uws_method_handler (void (*)(uws_res_t*, uws_req_t*, void*)), registered via uws_app_any/uws_app_get; the older us_socket_context_t opaque has been removed from Bun's uSockets fork, so there is no transport-layer request callback to mirror.
RUST: In crates/bun-bench, add mod oha: fn run_oha(url: &str, n: u64) -> OhaOutput shells out via std::process::Command to oha <url> -n <n> --no-tui --output-format json -H 'Accept-Encoding: identity' (oha has no -j; --output-format json is the only JSON switch). Deserialize with #[derive(serde::Deserialize)] struct OhaOutput { summary: Summary, #[serde(rename="latencyPercentiles")] latency_percentiles: Percentiles }, struct Summary { #[serde(rename="requestsPerSec")] requests_per_sec: f64 }, struct Percentiles { p50: f64, p99: f64 } — do NOT bind the top-level rps key (that is oha's windowed-RPS distribution, not aggregate throughput). The harness boots each JS fixture twice under the same bun binary, toggling the server impl via env (e.g. BUN_FEATURE_FLAG_RUST_HTTP=0|1), and diffs the two OhaOutputs. No unsafe in the harness. The Rust server path it gates lives in crates/bun-uws and slots in at the existing C ABI seam: it exposes pub extern "C" fn rust_uws_on_request(res: *mut uws_res_t, req: *mut uws_req_t, user_data: *mut c_void) matching the uws_method_handler typedef, which src/bun.js/api/server.zig passes to uws_app_any() in place of its Zig onRequest thunk when the flag is set. It does NOT attempt to #[repr(C)]-mirror any uSockets transport struct — us_socket_context_t no longer exists in this tree, and the transport layer (SocketGroup + Zig vtable) has no HTTP-request slot.
PERF: For both fixtures (react-hello-world.jsx on :3001, express.mjs on :3000), with oha -n 500000 --no-tui --output-format json -H 'Accept-Encoding: identity': rust.summary.requestsPerSec >= 0.98 * zig.summary.requestsPerSec AND rust.latencyPercentiles.p99 <= 1.05 * zig.latencyPercentiles.p99. Measured by crates/bun-bench running each fixture under both env-flag values back-to-back on the same host; gate fails CI if either inequality is violated.
SRC: /root/bun-5/bench/express/README.md (lines 31-37: oha http://localhost:3000 -n 500000 -H "Accept-Encoding: identity"; recommends oha/bombardier, warns autocannon's node:http client cannot measure Bun's throughput; explains the Accept-Encoding: identity header); /root/bun-5/bench/react-hello-world/react-hello-world.jsx (lines 6-28: Bun.serve({ port, async fetch(req){ return new Response(await renderToReadableStream(<App/>), headers) } }) — the SSR throughput fixture, port from process.env.PORT || 3001); /root/bun-5/docs/project/benchmarking.mdx (lines 19-23: 'For load testing, you must use an HTTP benchmarking tool that is at least as fast as Bun.serve()… autocannon is not fast enough. We recommend bombardier, oha, http*load_test'); /root/bun-5/src/deps/_libusockets.h (line 130: typedef void (*uws*method_handler)(uws_res_t *response, uws*req_t *request, void *user_data);— the only extern-C per-HTTP-request callback signature in the tree; this is the ABI a Rust handler must match); /root/bun-5/src/deps/uws/App.zig (lines 396, 407-416:pub const uws_method_handler = ?*const fn (*uws.uws_res, *Request, ?*anyopaque) callconv(.c) void;andpub extern fn uws_app_any(ssl, app, pattern, pattern_len, handler: uws_method_handler, user_data)— Zig side of the same C seam whereserver.zig's onRequest is registered); /root/bun-5/src/bun.js/api/server.zig (line 2291: pub fn onRequest(this: *ThisServer, req: *uws.Request, resp: *App.Response) void— the Zig request handler that is wired throughuws*app_any; this is the function a Rust impl would replace behind the env flag); /root/bun-5/src/deps/uws.zig (lines 13-15: 'The opaque us_socket_context_tis gone; this namespace now only carries the SSL-options extern struct' — confirms a#[repr(C)]mirror ofus_socket_context_twould match nothing the runtime links against); /root/bun-5/src/deps/libuwsockets.cpp (lines 58, 328:void uws_app_get(int ssl, uws_app_t \_app, const char \_pattern_ptr, size_t pattern_len, uws_method_handler handler, void \_user_data)/uws_app_any(...)— the C++ shim that stores the extern-C handler into uWSHttpContext<SSL>'s router; proves the registration path is uws_method_handler, not a transport-layer us_socket*\* callback)
[bun-bench / perf-gates] perf-gates-bundler
FACT: Bun's only bundler macro-benchmark today is bench/bundle/run-bench.sh, which clones the colinhacks/esbuild fork and runs make bench-three (10 concatenated copies of three.js — esbuild's standard throughput corpus). bench/bundle/ itself contains no fixtures (its index.ts is a one-line hello-world stub and package.json lists only bun-types); there is no React/JSX bundling benchmark, and there is no JSON-emitting harness. The bundler entry point is Zig-native with no C ABI: src/cli/build_command.zig:345 calls BundleV2.generateFromCLI(&this_transpiler, allocator, EventLoop, ...) which takes *Transpiler/std.mem.Allocator/EventLoop and returns a Zig BuildResult. Per the bundle_v2.zig header, the pipeline relies on mimalloc threadlocal heaps as arenas and operates directly on in-memory Zig AST (js_ast Part/Stmt/Symbol/Ref), so parse→link→print are not separable across an FFI boundary.
RUST: Crate bun-bench (bench harness only — no runtime code). bun-bench::bundler::prepare() vendors/generates the three.js×10 corpus under bench/bundle/fixtures/three/entry.js (same input make bench-three produces). bun-bench::bundler::measure(bun_exe: &Path, fixture: &Path) -> serde_json::Value shells out to hyperfine --warmup 3 --runs 10 --export-json {tmpfile} -- '{bun_exe} build {fixture} --outdir {tmp_out}' and parses the JSON file (hyperfine cannot write JSON to stdout). The A/B toggle lives at the process boundary, not inside bundle_v2.zig: src/cli/build_command.zig gains an if (bun.getenvZ("BUN_FEATURE_FLAG_RUST_BUNDLER") != null) branch before the BundleV2.generateFromCLI call (L345) that instead invokes extern fn bun_rs_build_from_cli(argc, argv, outdir_ptr) c_int from crate bun-bundler. Because the Zig pipeline's AST and threadlocal-heap ownership cannot be split, bun-bundler owns parse→link→print end-to-end (oxc/swc parser + its own linker/printer); it does not call back into Zig js_parser/js_printer. No function-pointer swap or extern "C" shim is added to bundle_v2.zig itself.
PERF: Gate = three.js×10 corpus only. Run bun-bench bundler --baseline $BUN_RELEASE --candidate $BUN_RELEASE with BUN_FEATURE_FLAG_RUST_BUNDLER unset vs =1. PASS requires: (a) hyperfine mean for candidate ≤ 1.00× baseline (zero wall-clock regression, Welch t-test p<0.05 from hyperfine's stddev/runs); (b) candidate output byte-size within [0.99, 1.01] of baseline (tree-shaking parity); (c) semantic equivalence — execute both output bundles via bun run out/entry.js and require identical stdout/exitCode (NOT cmp byte-equality, since symbol-rename order, chunk hashes, and helper placement legitimately differ between two implementations). Any of (a)/(b)/(c) failing blocks merge.
SRC: /root/bun-5/bench/bundle/run-bench.sh (git clone git@github.com:colinhacks/esbuild.git / cd esbuild / make bench-three — three.js×10 is the bundler throughput corpus; no JSON output, no fixtures in-repo.); /root/bun-5/bench/bundle/package.json (devDependencies contains only bun-types@^0.7.0; index.ts is console.log("Hello via Bun!") — confirms no React/JSX bundling fixture exists under bench/bundle/.); /root/bun-5/src/cli/build_command.zig (L345: const build_result = BundleV2.generateFromCLI(&this_transpiler, allocator, bun.jsc.AnyEventLoop.init(ctx.allocator), ...) — the sole CLI seam where an env-gated Rust path can branch; caller passes Zig-native structs, no C ABI.); /root/bun-5/src/bundler/bundle_v2.zig (L1544-1553 pub fn generateFromCLI(transpiler: *Transpiler, alloc: std.mem.Allocator, event_loop: EventLoop, ...) !BuildResult — Zig-native signature, no extern/dispatch table. Header L11-28: 'Bun's bundler relies on mimalloc's threadlocal heaps as arena allocators … A threadlocal heap cannot allocate memory on a different thread … you will get a segfault' — parse/link/print share threadlocal AST, so a Rust orchestrator cannot keep parser/printer in Zig without a per-file AST-serialization FFI hop the Zig path lacks.)
[bun-jsc-classes] perf-gates
FACT: ClassDefinition carries two memory-reporting knobs: estimatedSize?: boolean (emits extern JSC_CALLCONV size_t <Type>__estimatedSize(void* ptr) which JSCell::estimatedSize and DEFINE_VISIT_CHILDREN/visitor.reportExtraMemoryVisited call so the GC accounts for off-heap bytes; documented as "Called from any thread" because JSC's concurrent collector invokes it off-main-thread) and memoryCost?: boolean (main-thread-only, used by heap snapshots, "not used for GC"). generate-classes.ts also emits vm.heap.reportExtraMemoryAllocated(instance, size) at construction. Crucially, JSC_CALLCONV is "C" SYSV_ABI on Windows (WTF defines SYSV_ABI as __attribute__((sysv_abi))), and the Zig export uses callconv(jsc.conv) which resolves to .x86_64_sysv on Windows x64 — so the FFI symbol is SysV-ABI on every platform, NOT the platform-default C ABI on Windows. If a Rust impl under-reports, GC runs less often → RSS climbs; if it over-reports, GC runs too often → throughput drops.
RUST: In crate bun-jsc-classes: (1) define pub mod jsc_abi { #[cfg(all(windows, target_arch = "x86_64"))] #[macro_export] macro_rules! jsc_extern { () => { extern "sysv64" }; } #[cfg(not(all(windows, target_arch = "x86_64")))] #[macro_export] macro_rules! jsc_extern { () => { extern "C" }; } } mirroring jsc.conv / JSC_CALLCONV exactly. (2) Every migrated class implements pub unsafe trait JscExtraMemory { unsafe fn estimated_size(this: *const Self) -> usize; unsafe fn memory_cost(this: *const Self) -> usize { Self::estimated_size(this) } } — the method takes *const Self (NOT &self) and is unsafe because per class-definitions.ts:194 it is invoked from GC worker threads concurrently with main-thread mutation; forming &T here would be aliasing UB in Rust's model. Implementors must read size-contributing fields via raw-pointer projection or AtomicUsize/UnsafeCell and may not call anything that transitively creates &Self. (3) Codegen emits #[no_mangle] pub jsc_extern!() fn <Type>__estimatedSize(ptr: *mut c_void) -> usize { unsafe { <T as JscExtraMemory>::estimated_size(ptr as *const T) } } (and analogous <Type>__memoryCost) — same symbol name and same SysV calling convention the existing C++ already declares, so zero C++ changes. (4) Debug shadow-check: behind a --cfg bun_shadow_zig build, the generator gives the Zig export a distinct name (<Type>__estimatedSize_zig, still callconv(jsc.conv)); Rust declares jsc_extern!() { fn <Type>__estimatedSize_zig(ptr: *mut c_void) -> usize; } and the Rust shim does let rs = ...; let zg = <Type>__estimatedSize_zig(ptr); debug_assert!((rs as i64 - zg as i64).abs() < 64); rs — no duplicate-symbol link error because only the Rust function owns the canonical name.
PERF: FFI hop count is unchanged (C++→Rust replaces C++→Zig, one call). To gate: run bun:jscheapStats() before/after a fixed allocation workload (e.g. 10k Response/Blob constructions) on both builds; require |heapStats().extraMemorySize_rust − extraMemorySize_zig| / extraMemorySize_zig ≤ 0.05 and heapSize delta ≤ 5%. Additionally, p50 wall-time of the workload under --smol (which makes GC pacing sensitive to extra-memory reporting) must be ≤ 1.02× the Zig baseline. The bun_shadow_zig debug assert (|rs − zig| < 64 per-object) provides per-call verification during the migration window.
SRC: /root/bun-5/src/codegen/class-definitions.ts (lines 192-213: estimatedSize?: boolean — "reports external allocations to GC. Called from any thread."; memoryCost?: boolean — "always called on the main thread and not used for GC"); /root/bun-5/src/codegen/generate-classes.ts (line 1382: extern JSC_CALLCONV size_t ${symbolName(typeName,'estimatedSize')}(void* ptr);; lines 1644-1657: wired into DEFINE_VISIT_CHILDREN via visitor.reportExtraMemoryVisited(size); lines 699-702/1910-1975: vm.heap.reportExtraMemoryAllocated(instance, size) at construction; lines 1737-1768: memoryCost override + estimatedSize JSCell override; lines 2643-2647: #if !OS(WINDOWS) #define JSC_CALLCONV "C" #else #define JSC_CALLCONV "C" SYSV_ABI #endif; lines 2160-2173: Zig export pub fn <Type>__estimatedSize(thisValue: *T) callconv(jsc.conv) usize); /root/bun-5/src/bun.js/jsc.zig (lines 9-12: pub const conv: std.builtin.CallingConvention = if (bun.Environment.isWindows and bun.Environment.isX64) .{ .x86_64_sysv = .{} } else .c; — proves the FFI ABI is SysV on Win-x64, so Rust must use extern "sysv64" there, not extern "C"); /root/bun-5/vendor/WebKit/Source/WTF/wtf/PlatformCallingConventions.h (lines 37-39: #define SYSV_ABI __attribute__((sysv_abi)) on Windows, empty elsewhere — the C++ side of the same ABI contract); /root/bun-5/docs/project/benchmarking.mdx (lines 28-45: import { heapStats } from 'bun:jsc' exposing heapSize, extraMemorySize, objectCount — documented measurement surface for the perf invariant)
FACT: The fetch/HTTP-client throughput bench is bench/fetch/bun.js (lines 1-15): a mitata bench that fires 100 concurrent fetch('https://www.example.com/?cachebust='+i).then(r=>r.text()), awaits Promise.all, and goes through bench/runner.mjs which sets opts.format="json" when process.env.BENCHMARK_RUNNER is set. Sibling node.mjs/deno.js exist. The HTTP client itself is pure Zig with NO C ABI surface: requests are enqueued via HTTPThread.schedule(batch) (src/http/HTTPThread.zig:702) which pushes *AsyncHTTP onto an UnboundedQueue and wakes a dedicated uSockets loop; per-request kickoff is HTTPClient.start() (src/http.zig:1178). Completion flows through HTTPClientResult (src/http.zig:2408-2484) — a NON-extern Zig struct containing fail: ?anyerror, body_size: union(enum){total_received,content_length,unknown}, and ?HTTPResponseMetadata/?CertificateInfo, passed BY VALUE through Callback.Function = *const fn(*anyopaque,*AsyncHTTP,HTTPClientResult) void with default Zig callconv (line 2471). The BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT/_HTTP3_CLIENT flags (src/http.zig:226,240) only gate ALPN-h2-offer and Alt-Svc-h3-lookup INSIDE this one client — they are precedent for the bun.feature_flag env mechanism, not for whole-impl swap. Keep-alive reuse is NewHTTPContext.existingSocket() scanning pending_sockets: PooledSocketHiveAllocator (src/http/HTTPContext.zig:85,615) with releaseSocket() (line 287) returning sockets to the pool; no reuse counter currently exists.
RUST: Crate bun-http-client does NOT mirror HTTPClientResult — that struct has no C layout (?anyerror, tagged union, Zig optionals, Zig callconv) and cannot cross FFI. Instead: (1) define a NEW Zig pub const HTTPClientResultC = extern struct { body: ?*MutableString, flags: u8, fail_code: i32, body_size_tag: u8, body_size_val: usize, metadata: ?*HTTPResponseMetadata, cert_info: ?*CertificateInfo } plus pub const CallbackC = *const fn(*anyopaque,*AsyncHTTP,*const HTTPClientResultC) callconv(.c) void in src/http.zig, with a Zig helper that translates HTTPClientResultC -> HTTPClientResult (mapping fail_code through an explicit error-enum table) before invoking the existing Callback.Function. Rust side declares an identical #[repr(C)] pub struct HTTPClientResultC and calls the C-callconv callback. (2) The seam is HTTPThread.schedule / the queue-drain on the http thread: add a NEW flag bun.feature_flag.BUN_FEATURE_FLAG_RUST_HTTP_CLIENT (following the existing pattern at src/http.zig:226) and at the point where a dequeued *AsyncHTTP would call client.start() (src/http.zig:1178), branch to extern fn bun_rs_http_start(req: *AsyncHTTP, cb: CallbackC, ctx: *anyopaque) void which owns its own socket loop on the same dedicated thread — NOT a single synchronous send(). (3) bun-bench rewrites the snippet at gen-time to start a local Bun.serve({port:0}) echo and target http://127.0.0.1:${port}/?i=${i} so CI is hermetic (the upstream bench hits public internet).
PERF: With a local echo server (100 concurrent × N iters), p50 wall-time and allocations/op under BUN_FEATURE_FLAG_RUST_HTTP_CLIENT=1 must be ≤ the flag-off Zig path on the same build, captured via mitata JSON (BENCHMARK_RUNNER=1 bun bench/fetch/bun.js). Keep-alive reuse parity is measured by ADDING an explicit std.atomic.Value(usize) hit-counter to NewHTTPContext.existingSocket() (src/http/HTTPContext.zig:615) and a matching Rust-side counter, both read out via a test-only extern fn — there is no pre-existing BUN_DEBUG_* reuse counter to rely on (the only scoped loggers are .fetch at src/http.zig:41 and .HTTPThread at src/http/HTTPThread.zig:720, and they emit log lines, not metrics). Reuse-hit count over the 100-request batch must match (≥99 after warm-up) or the gate fails.
SRC: /root/bun-5/bench/fetch/bun.js (lines 1-15: bench(\fetch(https://example.com) x ${count}`, async () => { ... requests[i]=fetch(...).then(r=>r.text()); await Promise.all(requests); })with count=100, imports from ../runner.mjs); /root/bun-5/bench/runner.mjs (lines 1-13:const asJSON = !!process?.env?.BENCHMARK_RUNNER; ... if(asJSON) opts.format="json"; return Mitata.run(opts);— JSON output gate); /root/bun-5/bench/fetch (directory listing: bun.js, deno.js, node.mjs — cross-runtime variants present); /root/bun-5/src/http/HTTPThread.zig (line 702:pub fn schedule(this:*@This(), batch:Batch) void { ... this.queued_tasks.push(http); ... this.loop.loop.wakeup(); }— real enqueue seam; line 720:Output.scoped(.HTTPThread,.visible)); /root/bun-5/src/http.zig (line 1178: pub fn start(this:*HTTPClient, body:HTTPRequestBody, body_out_str:*MutableString) void— per-request kickoff on http thread; line 41:const log = Output.scoped(.fetch,.visible); lines 226,240: bun.feature_flag.BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT/_HTTP3_CLIENT.get()gating ALPN/Alt-Svc only); /root/bun-5/src/http.zig (lines 2408-2484:pub const HTTPClientResult = struct { ... fail: ?anyerror, body_size: BodySize (union(enum)), metadata: ?HTTPResponseMetadata, certificate_info: ?CertificateInfo }; line 2471: pub const Function = *const fn(*anyopaque,*AsyncHTTP,HTTPClientResult) void— non-extern, Zig callconv, by-value: NOT C-ABI representable); /root/bun-5/src/http/HTTPContext.zig (line 72:PooledSocketHiveAllocator = bun.HiveArray(PooledSocket,pool_size); line 85: pending_sockets: PooledSocketHiveAllocator; line 287: pub fn releaseSocket(...); line 615: fn existingSocket(...) ?ExistingSocket { ... var iter = this.pending_sockets.used.iterator(...) }` — keep-alive pool, no counter present)
DROPPED (do not include):
codegen-contract: The STATEMENT's core distinction (wrapper force-inlined, user fn left to .auto) is correct for proto fns, getters, setters, and the lifecycle hooks, and bun.zig:20 / jsc.zig:9-12 / host_fn.zig:96 are all verified verbatim. However, the claim asserts that "every proto/class fn, the thunk force-inlines jsc.toJSHostCall (generate-classes.ts:2262, 2310, 2327)". This is factually wrong for class fn and the cited lines do not support it:
Line 2310 is } (end of proto fn block).
Line 2327 is the class GETTER .error_union else branch (@call(bun.callmod_inline, ${typeName}.${getter}, ...)), not a class fn.
The actual class-fn thunk is at line 2361 and force-inlines jsc.toJSHostFn(${typeName}.${fn}), NOT jsc.toJSHostCall. toJSHostFn (host_fn.zig:16-22) returns a wrapper struct fn that calls the user fn via a plain call (functionToWrap(globalThis, callframe)) into toJSHostFnResult — a different code path with no @call(.auto, ...) and no ExceptionValidationScope.
So the evidence cited for "class fn → toJSHostCall" is not present at the cited locations, and the statement is incorrect for that case. The proto-fn citation is also off (actual line 2307, not 2310), and several other citations drift by 1-2 lines (getter 2263/2265 vs cited 2262/2264; setter 2280/2283 vs 2279/2282; DOMJIT 2299 vs 2301), though those are within tolerance.
The Rust design itself is otherwise defensible (NonNull receiver avoids &mut reentrancy UB; extern "sysv64"/extern "C" cfg-gating matches jsc.zig:9; #[inline(always)] wrapper + monomorphized closure is the right parity target). But because the STATEMENT misidentifies the class-fn wrapper and the cited evidence at 2310/2327 does not say what is claimed, the Rust emitter spec built on it ("proto/class fn thunk body is to_js_host_call(global, || <$T>::$fn(this, global, cf))") would mis-generate class (static) fn thunks — they have no this receiver and Zig routes them through toJSHostFn, not toJSHostCall.
codegen-contract-domjit: Core STATEMENT and RUST DESIGN are verified (class-definitions.ts:294/302 strip DOMJIT; ZigGeneratedClasses.{cpp,zig} grep -c == 0; slow-path thunk at generate-classes.ts:2305-2308 is the only live per-fn export; dormant Zig DOMJIT thunk at 2296-2300 returns bare jsc.JSValue while TextDecoder.decodeWithoutTypeChecks:193 and ServerWebSocket.publishBinaryWithoutTypeChecks:580 return bun.JSError!JSValue → would not compile; jsc.conv at jsc.zig:9-12 matches the sysv64-on-win-x64 design). However, two cited facts are FALSE:
(1) PERF INVARIANT is wrong: nm build/debug/bun-debug | grep -c WithoutTypeChecks returns 19, NOT 0. Hand-written (non-.classes.ts) DOMJIT lives outside the generator: src/bun.js/bindings/JSBuffer.cpp:2518-2600 defines jsBufferConstructorAlloc{,Unsafe,UnsafeSlow}WithoutTypeChecks via JSC_DEFINE_JIT_OPERATION, and src/bun.js/api/FFIObject.zig exports Reader.{u8,i8,u16,i16,u32,i32,f32,f64,i64,u64,ptr,intptr}WithoutTypeChecks + ptrWithoutTypeChecks. The proposed nm-based parity check would fail immediately on a correct Zig build and is therefore an invalid invariant. The correct invariant is the one already stated for ZigGeneratedClasses.{cpp,zig} only — not the whole binary.
(2) EVIDENCE error: "CryptoHasher.update from crypto.classes.ts:56" is wrong. crypto.classes.ts:56 is the DOMJIT block for randomUUID (on the Crypto class), not CryptoHasher.update. CryptoHasher.update (lines 25-28 and 99-102) has NO DOMJIT: annotation, so it is not a valid re-enable benchmark target.
Minor: the assertion that "JITOperationPrologueCallFrameTracer presence proves throwing is permitted" is overstated reasoning — the tracer is mandatory for every JIT operation to set vm.topCallFrame regardless of whether it throws; the conclusion (DOMJIT fast paths may throw) is still correct, but the cited line is not the proof.
install-arch: The STATEMENT's central enumeration — "The methods install/ actually invokes on the event-loop abstraction are exactly: loop(), wakeup(), tick(), tickOnce(), iterationNumber()" — is factually false, and the RUST DESIGN's trait surface is therefore incomplete and would not compile.
Evidence the claim itself cites contradicts it:
/root/bun-5/src/install/lifecycle_script_runner.zig:58 — pub fn eventLoop(this) *jsc.AnyEventLoop { return &this.manager.event_loop; }. This accessor is consumed by bun.io.BufferedReader / PipeReader (the OutputReader at L48), which at /root/bun-5/src/io/PipeReader.zig:458,576,642 calls parent.vtable.eventLoop().pipeReadBuffer() and at L280 calls Async.FilePoll.init(this.eventLoop(), ...) (which routes to AnyEventLoop.filePolls/putFilePoll). So install's lifecycle-script subprocess path requires pipeReadBuffer() and filePolls()/putFilePoll() on the event-loop abstraction — methods the proposed trait omits.
/root/bun-5/src/install/PackageManager/security_scanner.zig:920 — same eventLoop() -> *jsc.AnyEventLoop pattern, same downstream PipeReader needs.
/root/bun-5/src/install/lifecycle_script_runner.zig:222 and /root/bun-5/src/install/PackageManager/security_scanner.zig:844 — jsc.EventLoopHandle.init(&manager.event_loop) passed into bun.spawn options. The trait gives no equivalent for constructing the spawn machinery's loop handle; the design only mentions EventLoopHandle at PackageManager.zig:880 and never accounts for these two spawn-time call sites.
Consequence for the RUST DESIGN: trait EventLoop { uws_loop, wakeup, tick, tick_once, iteration_number } is insufficient for bun_install to host LifecycleScriptSubprocess / SecurityScanSubprocess. Either bun_install must also depend on a FilePoll/pipe-buffer abstraction exposed through the trait, or those subprocess types must be moved out — neither is stated. The "exactly 5 methods" claim and the derived trait are unsupported by the cited evidence.
Secondary issue: the trait is declared : Send, but MiniEventLoop (owns a *uws.Loop raw pointer and a thread-local-style FilePoll store) is single-thread-affine in Zig today; requiring Send is an unjustified constraint and likely unsound for the JS variant as well.
What does check out: PackageManager.zig field/init lines, resolver.zig:529-532 sole .js construction path, install_binding.zig 72-line parseLockfile-only shim, AnyEventLoop.zig method line numbers, dependency.zig:287 / hosted_git_info.zig:171,1681,1711 JS leaves, MiniEventLoop.zig JsVM helper (~L302-330). Those citations are accurate. But the load-bearing "exactly 5 methods" claim that justifies the trait shape is wrong.
bundler-threadpool-worker-registry: The STATEMENT is accurate — every cited line in /root/bun-5/src/bundler/ThreadPool.zig (1-365) and /root/bun-5/src/bundler/bundle_v2.zig (~992-1001, ~2820-2840) matches: doc comment lines 2-6, fields lines 7-12, IOThreadPool singleton lines 16-80 with max_threads=@max(@min(getThreadCount(),4),2), init/initWithPool lines 82-101, usesIOPool lines 120-137, getWorker locking unconditionally lines 180-203, Worker fields lines 205-224, deinitSoon/pushIdleTask lines 232-244, get/unget push/pop lines 246-267, WorkerData/create lines 269-315, ThreadLocalArena=MimallocArena line 360, and bundle_v2.zig per-BundleV2 ThreadPool creation + shutdown loop.
However the RUST DESIGN is unsound on two points and must be refuted:
(1) fn get_worker(&self, id: ThreadId) -> &mut Worker is an unsound safe signature. It takes &self, drops the Mutex<HashMap> guard, then hands back &mut Worker. Nothing prevents let a = pool.get_worker(id); let b = pool.get_worker(id); — two live &mut aliases to the same Worker, instant UB, even single-threaded. Zig returns *Worker (no aliasing rules); Rust &mut carries a uniqueness guarantee the design cannot uphold. The downstream WorkerGuard<'a>(&'a mut Worker) inherits this: two Worker::get(ctx) calls yield two overlapping guards. Fix: return NonNull<Worker> (or make it unsafe fn) and confine the &mut materialization to an unsafe block inside Worker::get, ideally with a thread-local reentrancy flag so the safe WorkerGuard API actually enforces uniqueness.
(2) The optional TLS micro-opt says "clear in ThreadPool::drop". Drop runs on exactly one thread; it cannot reach other threads' thread_local! CACHED slots. After drop, worker threads still hold a stale (NonNull<ThreadPool>, NonNull<Worker>). A subsequent ThreadPool allocated at the same address (ABA) passes the identity check and dereferences a freed Worker → UAF. The clear must happen inside the per-thread deinit_task (which already runs on the owning thread), not in ThreadPool::drop.
ast-allocator: The STATEMENT is accurate — all cited evidence checks out at the cited locations:
/root/bun-5/src/ast/NewStore.zig L33-39: Block is plain struct (auto-layout, not extern), size = largest_size * count * 2, Size = IntFittingRange(0, size + largest_size), fields exactly as claimed.
L47-60: tryAlloc is alignForward + bump, no header. ✓
L102-115: reset() = current = firstBlock(); current.bytes_used = 0; (chain untouched in release). ✓
L117-142: allocate() slow path: if current.next exists, lazily zero its bytes_used and reuse; else backing_allocator.create(Block) + zero + link. ✓
However, the RUST DESIGN is unsound as written and will not compile:
The design states the size/align constants are obtained via extern "C" { static BUN_EXPR_STORE_ALIGN: usize; static BUN_EXPR_BLOCK_SIZE: usize; ... } exported from Zig, and then used in (a) const_assert!(BUN_EXPR_STORE_ALIGN <= 16) and (b) pub type ExprStore = BlockStore<BUN_EXPR_BLOCK_SIZE> (a const-generic argument).
In Rust, an extern "C" static is a link-time symbol whose value is only available at runtime (and reading it is unsafe). It is NOT a const expression. Therefore:
const_assert! / static_assertions::const_assert! cannot evaluate it — compiler error E0013 "constants cannot refer to statics".
It cannot be passed as a const-generic parameter <const N: usize> — const generic arguments must be const expressions, not statics.
So both the alignment guard and the ExprStore/StmtStore type aliases as specified are impossible. The cross-language size/align verification mechanism — which the design explicitly identifies as the only thing that IS cross-checked ("only the block SIZE/ALIGN constants are cross-checked") — does not work as described.
ast-allocator: Most of the STATEMENT is accurate and the cited line numbers check out (ASTMemoryAllocator.zig L1/8-16/24-48/50-57/59-70/72-76, ThreadPool.zig Worker.create + ThreadLocalArena=MimallocArena, Stmt.zig:334, Expr.zig:3182, page_size_min=4096 on x86_64-linux). However, one load-bearing factual claim is wrong and is contradicted by the very evidence cited:
initWithoutStack() does NOT give a zero-length scratch buffer. /root/bun-5/src/ast/ASTMemoryAllocator.zig:79-86 sets .fixed_buffer_allocator = .init(&.{}) but then immediately calls this.stack_allocator.get() on L85. Per /root/bun-5/vendor/zig/lib/std/heap.zig:403-408, StackFallbackAllocator.get() unconditionally executes self.fixed_buffer_allocator = FixedBufferAllocator.init(self.buffer[0..]), overwriting the empty FBA with one backed by the full inline [size]u8 buffer. So after initWithoutStack() the scratch IS active (same as reset()/enter()); it does NOT "forward every alloc to the arena," and it does NOT "prove the scratch is optional." The .init(&.{}) is dead/vestigial. The STATEMENT's interpretation and the derived Rust init_without_stack -> InlineBump::empty() therefore diverge from actual Zig behavior.
Secondary issues: (a) the Rust design hardcodes InlineBump<4096>, but Zig's size is @min(8192, page_size_min) — 8192 on aarch64-macos (page_size_min=16384), 4096 on x86_64; (b) the PERF INVARIANT asserts Zig's bump_allocator.create(T) "devirtualizes" — bump_allocator is a type-erased std.mem.Allocator stored in a struct field, so the hot path is an indirect vtable call into SFA::alloc, not a guaranteed devirtualized branch. The proposed Rust scratch-hit path (inlined, 0 indirect calls) is fine perf-wise, but the claim mischaracterizes the Zig baseline.
async-natives: The STATEMENT and RUST DESIGN both assert that the extern-C thunks the native side must export are named via symbolName(typeName, name) = "${typeName}__${name}" (citing generate-classes.ts:25-26), and that the Rust ABI surface is therefore #[no_mangle] extern "C" fn TCPSocket__write(...) / TLSSocket__write(...). This is factually wrong against the cited codegen.
Prototype methods/getters/setters are emitted with protoSymbolName(typeName, name) = "${typeName}Prototype__${name}" (generate-classes.ts:29-31), not symbolName. symbolName is only used for the C++-side JSC wrapper names (e.g. TCPSocket__writeCallback, TCPSocket__writeGetterWrap) and class-static externs — not for the extern-C thunk the native object exports. Verified in the actual generated artifacts:
A Rust build that exports TCPSocket__write / TLSSocket__write would fail to link (unresolved TCPSocketPrototype__write / TLSSocketPrototype__write). The evidence bullet citing lines 25-26 as "the extern-C thunk naming scheme the Rust #[no_mangle] exports must match" points to the wrong function and does not support the claim.
All other cited evidence (NewSocket at socket.zig:39+, TCPSocket/TLSSocket aliases at :1720-1721, NewSocketHandler at uws/socket.zig:10, m_ctx opaque void* at generate-classes.ts:1482/1509, bench/snippets/tcp-echo.bun.ts, us_socket_write at us_socket_t.zig:198/269) checks out, but the ABI-symbol assertion is the load-bearing part of the Rust design and it is incorrect.
std-not-ptr: Two cited evidence items are wrong at the cited locations:
(1) /root/bun-5/src/bun.js/api/server/AnyRequestContext.zig L6-13 — claim states the union contains H2Server.RequestContext, H2SServer.RequestContext. Those types do not exist. Actual file contents at L6-13: HTTPServer.RequestContext, HTTPSServer.RequestContext, DebugHTTPServer.RequestContext, DebugHTTPSServer.RequestContext, HTTPSServer.H3RequestContext, DebugHTTPSServer.H3RequestContext. The last two variants are H3 (QUIC) request contexts, not H2, and are nested under HTTPSServer/DebugHTTPSServer — there is no H2Server or H2SServer symbol. The evidence as quoted is fabricated/stale.
(2) /root/bun-5/src/sourcemap/SavedSourceMap.zig — file does not exist at this path. Actual location is /root/bun-5/src/bun.js/SavedSourceMap.zig (L37 there does contain pub const Value = bun.TaggedPointerUnion(...)).
Minor: event_loop.zig Queue is at L113, not L112 (off-by-one).
All other evidence verified correct: tagged_pointer.zig L1-43 (u49+u15 packed u64, from/to *anyopaque bitcast), Task.zig L4-100+ (~95-variant TaggedPointerUnion), HTTPContext.zig ActiveSocket = TaggedPointerUnion({DeadSocket, HTTPClient, PooledSocket, H2.ClientSession}) written into uSockets ext as *anyopaque, process.zig L100-121 ProcessExitHandler 12 variants, posix_event_loop.zig L169 FilePoll.Owner, bench/snippets/microtask-throughput.mjs and test/js/node/process/process-nexttick.test.js both exist.
The Rust design itself (plain #[repr(u8)] enum at 16B internally, repr(transparent) usize wrapper only at C void* boundaries, transitional extern "C" bun_enqueue_task) is sound and does not add an FFI hop on the hot dequeue path. The perf-invariant reasoning is plausible. But per verifier rules, fabricated/mis-located evidence ⇒ refuted.
crate-layout-bun_sys: All cited Zig evidence is accurate (verified at /root/bun-5/src/sys.zig L53-58, L331, L337-339, L370, L793/1749/3490/3514/3556, L4551-4565; /root/bun-5/src/sys/Error.zig L11-21; /root/bun-5/src/fd.zig L1-30; file is 4596 lines; grep confirms L331 and L4554 are the only non-POD jsc.* references). The STATEMENT portion is correct.
However, the RUST DESIGN's PERF INVARIANT is internally contradictory and unsatisfiable, which fails the "unsound / would regress perf" test:
The design defines SysError as { errno: u16, syscall: u16, fd: Fd, [cfg(windows)] from_libuv: bool, path: Slice<u8>, dest: Slice<u8> } where Slice<u8> = {*const u8, usize} (16 bytes on 64-bit). That gives size_of::() ≥ 40 bytes on unix-64 (2+2+4+16+16) and ≥ 48 on Windows. Maybe<isize> = enum { Ok(isize), Err(SysError) } is therefore ≥ 48 bytes (6 machine words) — Rust enum size is max-variant + discriminant, and no niche optimization can shrink a 40-byte payload below 40 bytes.
The claim then asserts a compile-time invariant const _: () = assert!(size_of::<Maybe<isize>>() <= 2*size_of::<usize>()) (= 16 bytes). This assertion is mathematically impossible to satisfy with the SysError layout as specified; the crate would not compile. The accompanying rationale ("niche-optimized so hot-path returns stay in registers") is also wrong about the baseline: Zig's Maybe(isize) with the same Error struct (fd:8 + errno:2 + path:16 + dest:16 + syscall + from_libuv) is likewise ~48+ bytes and is returned via sret, not in two registers — so the invariant neither matches Zig nor is achievable in Rust without redesigning SysError (e.g., boxing or out-of-line storage), which would contradict the "field-equivalent to sys/Error.zig" claim.
Because the design's own stated compile-time assertion is provably false given its own type definitions, the Rust design is internally inconsistent → refuted.
crate-layout: Source-location evidence checks out (js_parser.zig/js_lexer.zig have 0 jsc refs; js_printer.zig:397 is the sole ref; the six src/ast/ files, NewStore.zig:9-61/102-115, and Expr.zig:2132-2180 raw-pointer Data union are all exactly as cited). However, the RUST DESIGN section contains two verifiably false factual assertions that its surgeries depend on:
(3) "jsc.math.pow … is pure math, no JSC linkage" — FALSE. /root/bun-5/src/bun.js/jsc.zig:273-277 defines it as extern "c" fn Bun__JSC__operationMathPow(f64,f64) f64, implemented at /root/bun-5/src/bun.js/bindings/bindings.cpp:6208 as a thin wrapper over JSC's operationMathPow (vendor/WebKit/Source/JavaScriptCore/runtime/MathCommon.h:39). It IS a JSC linkage, deliberately chosen so TS constant-folding of ** matches ECMA-262 (e.g. 1 ** Infinity === NaN, integer-exponent fast path) rather than libm pow. "Inline into bun_ast as a local fn" is not a no-op move; it is a behavior change unless the JSC algorithm is reimplemented, so the claim's characterization is wrong and the surgery as stated risks a correctness regression.
(4) "jsc.URL.fileURLFromString … already JSC-independent string formatting" — FALSE. /root/bun-5/src/bun.js/bindings/URL.zig:47-51 calls extern URL__getFileURLString, defined in C++ at /root/bun-5/src/bun.js/bindings/BunString.cpp:558 using WTF::URL. It is a WebKit/WTF binding, not pure string formatting, so "replace with bun_url helper" is a port, not the trivial relabel the claim asserts.
Additionally, the PERF INVARIANT cites non-existent benchmarks: there is no bench/react-ssr/ (only bench/react-hello-world/) and no bench/transpiler/many-files.ts in /root/bun-5/bench/, so the stated ±2% gate is unverifiable as written.
The arena/Store design and pointer-variant enum are sound and match Zig's NewStore; the refutation is on the false "no JSC linkage" / "already JSC-independent" claims and the fabricated bench paths.
crate-layout-layer1-jsc-surface: The claim's enumeration of src/http/ jsc.* surface is materially incomplete, which falsifies both the STATEMENT ("falls into THREE categories", "FOUR hard JSC couplings") and the RUST DESIGN ("bun_http is Layer 1, NO bun_jsc, achievable after A+B+C"). The src/http/websocket_client/ subtree — which is part of src/http/ per the repo's own organization (CLAUDE.md: "src/http/ ... websocket_client/") — contains a fifth, unlisted, and far larger hard JSC coupling than any of the four enumerated:
/root/bun-5/src/http/websocket_client.zig:41 globalThis: *jsc.JSGlobalObject (struct field), L50 event_loop: *jsc.EventLoop (the full JS event loop, NOT MiniEventLoop — this IS a JSC-heap-aware type), L1092 writeBlob(blob_value: jsc.JSValue) (JSValue on send hot path), L1107 jsc.WebCore.Blob, L1238 jsc.AnyTask.
These are not category-1 thin JSHostFns (JSValue/JSGlobalObject are stored as struct fields and used on the data path), not category-2 namespace renames (*jsc.EventLoop and *jsc.VirtualMachine are the real JSC runtime, not MiniEventLoop/AnyEventLoop), and not in the claim's category-3 list. Therefore the PERF INVARIANT "cargo deny rule that bun_http has no transitive edge to bun_jsc — achievable after A+B+C" is false: after A+B+C as specified, websocket_client/ still requires bun_jsc. The design either needs to excise websocket_client/ from bun_http or add it as a fifth (substantial) redesign — neither is stated.
Secondary issues that also undermine the claim: (a) Method.zig:151 Bun__HTTPMethod__toJS and Headers.zig:18 toFetchHeaders are additional category-1 fns in src/http/ not enumerated; (b) "NodeFS → bun_sys::fs" is questionable — /root/bun-5/src/bun.js/node/node_fs.zig contains JSPromise.Strong, JSGlobalObject, VirtualMachine, Debugger.AsyncTaskTracker (L132-361), so the type is not a clean fs-ops POD that re-homes by namespace rename; (c) SSLConfig is not pure POD — /root/bun-5/src/bun.js/api/server/SSLConfig.zig:52/368-461 has fromJS taking JSGlobalObject/JSValue, so moving it to bun_http::tls also requires splitting out its JS constructor (unstated). The verified parts (semver enumeration, http.zig file-level enumeration, PnpmMatcher/hosted_git_info/security_scanner/PackageManager line cites, event_loop.zig re-exports, CommonAbortReason enum, lockfile/ zero refs) are accurate, but the omitted websocket_client coupling alone invalidates the Layer-1 bun_http crate boundary as designed.
ffi-repr-c-mirror: The claim's central invariant — that BunString crosses the extern "C" boundary "BY POINTER, not by value" and the Rust design rule "BunString is NEVER passed by value into C++" — is factually false in the current codebase. Multiple extern "C" functions take BunString by value as a PARAMETER (not just as an sret return), and these genuinely cross the Zig↔C++ boundary:
• /root/bun-5/src/bun.js/bindings/InternalModuleRegistry.cpp:18-19 — extern "C" bool BunTest__shouldGenerateCodeCoverage(BunString sourceURL) and extern "C" void ByteRangeMapping__generate(BunString sourceURL, BunString code, int); Zig side at /root/bun-5/src/cli/test_command.zig:1323 is export fn BunTest__shouldGenerateCodeCoverage(test_name_str: bun.String) callconv(.c) bool — by value.
• /root/bun-5/src/bun.js/bindings/StringBuilderBinding.cpp:43,58 — extern "C" void StringBuilder__appendString(WTF::StringBuilder*, BunString str) / StringBuilder__appendQuotedJsonString(..., BunString str).
• /root/bun-5/src/bun.js/bindings/BakeAdditionsToGlobalObject.cpp:104 — extern "C" SYSV_ABI ... Bake__bundleNewRouteJSFunctionImpl(..., BunString url); Zig side /root/bun-5/src/bake/DevServer.zig:4561 takes url: bun.String by value.
• /root/bun-5/src/bun.js/bindings/ZigSourceProvider.cpp:50,51,57,69 and /root/bun-5/src/bun.js/bindings/RegularExpression.cpp:9,30,38 — all take BunString by value.
So the STATEMENT overgeneralizes ("passed across extern "C" functions BY POINTER, not by value" is wrong — pointer-passing is common but not universal), and the RUST DESIGN encodes this wrong invariant as a hard rule. Enforcing "extern fns take *mut/*const BunString only" in bun_ffi would mismatch ≥10 existing C++ signatures and break ABI; the cbindgen-diff check in PERF INVARIANT step (1) would itself fail against the real codebase.
The cited evidence that DOES exist is accurate (string.zig L33-46, L71, L855-857; headers-handwritten.h L19-22, L37-48, L58-60, L324-326; BunString.cpp L109, L370, L427, L468; wtf.zig L3-11; schema.zig L831-836 all check out), but it does not support the universal "never by value" claim — it only shows that SOME hot-path entry points use pointers.
Secondary soundness concern: the design's #[repr(u8)] enum BunStringTag carries a Rust validity invariant (must be 0..=4) that C++'s enum class : uint8_t does not. improper_ctypes will NOT flag this — rustc treats #[repr(u8)] enums as FFI-safe — so the proposed lint enforcement does not cover the case where C++ hands back an out-of-range tag byte (instant UB in Rust). Best practice for a boundary type whose bytes are written by C++ is #[repr(transparent)] struct BunStringTag(u8) with associated consts, not a Rust enum.
packed-struct-mapping: Evidence for cases (a) and (b) checks out at the cited locations (Watcher.zig:175, bundle_v2.zig:4538, copy_file.zig:41, StandaloneModuleGraph.zig:322, http.zig:586/592/598, dns.zig:41, DevServer.zig:4160/4291, bundle_v2.zig:4638, base.zig:97, LinkerContext.zig:1551/1569 — a couple are off-by-one but content matches). However, the case (c) Rust design and its perf invariant are factually wrong.
The claim asserts Zig's StableRef = packed struct(u96) is 12 bytes and that Rust must use #[repr(C, packed)] with size_of == 12 and align_of == 1 to achieve "byte-identical footprint" in Vec<StableRef> vs Zig's ArrayList(StableRef). I verified empirically with the vendored Zig compiler (/root/bun-5/vendor/zig/zig):
Zig pads non-power-of-2 integer widths to the next power-of-2 alignment (u96 → 16-byte size/align on x86_64). So ArrayList(StableRef).items has 16-byte stride, not 12. The claim's arithmetic "u32 + u64 = 12 bytes" conflates @bitSizeOf with @sizeOf.
Consequently:
The stated perf invariant "size_of equals … 12" is false for Zig — the proposed const_assert_eq!(size_of::<StableRef>(), 12) would assert a value that does NOT match Zig.
The "do NOT widen to u128" prescription is backwards: a #[repr(transparent)] struct StableRef(u128) or a #[repr(C, align(16))] { u32, Ref } would actually match Zig's 16-byte/16-align layout exactly, whereas the prescribed #[repr(C, packed)] 12-byte struct does not.
#[repr(C, packed)] additionally places the u64-backed ref_ at offset 4 (unaligned), which forbids &self.ref_ (rustc E0793) and forces unaligned loads in the hot sort comparator — a regression Zig does not have (Zig's StableRef is 16-aligned).
Since the claim's core perf invariant and the case-(c) design rationale rest on an incorrect size premise that I positively disproved with the project's own toolchain, the claim is refuted.
perf-gates-ws-broadcast: Three independent grounds, each sufficient to refute.
(1) STATEMENT misidentifies the hot path. chat-server.bun.js sets publishToSelf:true. In /root/bun-5/src/bun.js/api/server/ServerWebSocket.zig publishText the branch is if (!publish_to_self ...) this.websocket().publish(...) else uws.AnyWebSocket.publishWithOptions(ssl, app, ...). With publish_to_self==true the else branch fires. AnyWebSocket.publishWithOptions (src/deps/uws/WebSocket.zig:184-188) is a static fn taking app: *anyopaque and dispatches to NewApp(tls).publishWithOptions (src/deps/uws/App.zig:70-81) which calls extern uws_publish(ssl, *uws_app_t, ...) at src/deps/libuwsockets.cpp:587 — the APP-level publish whose first non-ssl arg is uws_app_t* (cast to uWS::App*/uWS::SSLApp*), not uws_websocket_t*. The bench never calls uws_ws_publish_with_options at all. The cited evidence ("publishWithOptions -> extern C uws_ws_publish_with_options") is wrong for this benchmark.
(2) RUST DESIGN is ABI-unsound / cannot be link-swapped at the uwsws_ boundary. The websocket handle is not allocated by any uwsws_ function; it is allocated by C++ in uwsres_upgrade (libuwsockets.cpp:1617) via uWS::HttpResponse::upgrade<void>() inside a uWS::WebSocketContext created by uws_ws(ssl, app, ...) route registration. Zig's open/message callbacks receive that C++-allocated uWS::WebSocket<SSL,true,void*>* as the opaque RawWebSocket. Swapping only the uws_ws_send/publish/subscribe symbols means the Rust impls receive a C++ object pointer, not an RsWebSocket* — instant UB. Further, the TopicTree lives on the C++ uWS::TemplatedApp; a Rust uws_ws_subscribe would populate a Rust tree while the hot-path uws_publish(app,...) (still C++) drains the C++ tree — zero fan-out. And libuwsockets.cpp is one TU; you cannot link "either libuwsockets.cpp or libbun_uws.a for the uws_ws** symbol set" without duplicate symbols or splitting the .cpp, which the design claims to avoid. To actually substitute, the crate must also own uws_create_app, uws_ws (route reg), uws_res_upgrade, uws_publish, and the HTTP layer — i.e. the whole uWS C++ stack, not "the WebSocket layer built directly on uSockets' C API".
(3) bun-bench::measure_ws as specified cannot parse the output. chat-client.mjs prints JSON.stringify(runs, null, 2) — pretty-printed across ~12 lines ([\n N,\n ...\n]). "Scans stdout for the last line that serde_json::from_str::<Vec> accepts" matches no line (last line is ], prior lines are N,). So "No patch to chat-client.mjs is required" is false for the stated parser.
Consequence for PERF INVARIANT: since the hot path is app-level uwspublish (not any uws_ws* symbol), the proposed Rust build would still execute C++ TopicTree fan-out for this bench; the invariant does not measure the Rust port at all.
WRITE TO: /root/bun-5/docs/rust-rewrite-plan.md
DOCUMENT REQUIREMENTS:
Single coherent narrative. No "v1/v2", no "corrections", no meta.
Structure:
Approach (3-lang binary, strangler-fig, no cross-LTO dependency)
JSC GC model (Riptide: non-moving/generational/concurrent/conservative-stack; marking constraints; WriteBarrier; what runs on which thread)
Binding codegen contract (what stays C++ vs Rust; exact symbol tables; sysv64; exception protocol)
26 divergences found across three structural categories where Zig idioms do not map 1:1 to Rust.
Risk
Count
refcount-transfer-vs-plus1
deinit-vs-drop
error-union-vs-result
HIGH
4
2
1
1
MEDIUM
11
2
4
5
LOW
11
3
5
3
Total
26
7
10
9
Category notes
refcount-transfer-vs-plus1 — Zig toJS()transfers the caller's +1 to the JS wrapper; Rust to_js()adds a fresh +1 (FileSink.rs:977-984 documents this inversion). Any call site ported verbatim from Zig leaks one ref.
deinit-vs-drop — Zig pools recycle with value.* = undefined (no destructor); Rust HiveArray::put runs drop_in_place. Types that had deinit() but no impl Drop leak; types with both risk double-free.
error-union-vs-result — Zig functions with !T signatures whose bodies never error were ported with real Err arms. Every transliterated catch unreachable → .expect("unreachable") is now reachable.
Top-5 by shipping risk
Bun.spawn({stdin:'pipe'}) leaks one FileSink per proc.stdin read — fd exhaustion in long-running parents (subprocess/Writable.rs:403-468)
Bun.spawn({stdin: ReadableStream}) leaks FileSink at rc≥2 — compounds #1 by an extra +1 on the stream-stdin path (subprocess/Writable.rs:233/340)
Body::Value has no Drop → every Request/Response GC leaks WTFString refs + Blob content-type buffers — monotonic RSS growth under HTTP load (hive_array.rs:478 + Body.rs:1511)
convert_utf16_to_utf8_in_buffer widened from infallible to fallible — ~10 Windows path call sites now panic or silently swallow where Zig succeeded (bun_core/lib.rs:2317)
FileSink::to_js protocol inversion — root cause of #1/#2; every future init→to_js port site is a latent leak until adapted (FileSink.rs:1199-1216)
HIGH risk (4)
H1. Bun.spawn stdin-pipe getter leaks one FileSink per access
Zig: src/runtime/api/bun/subprocess/Writable.zig:244-272 — .pipe arm of toJS: this.* = .{ .ignore = {} }; … return pipe.toJS(globalThis) / pipe.toJSWithDestructor(...). Zig pipe.toJS()transfers the enum's +1 to the JS wrapper (createObject without ref).
Rust: src/runtime/api/bun/subprocess/Writable.rs:403-468 — to_jsPipe arm: subprocess.stdin.replace(Writable::Ignore) then pipe.to_js(global_this) / pipe.to_js_with_destructor(...) returned withoutpipe_release(pipe_nn). Rust FileSink::to_js/to_js_with_destructor (FileSink.rs:1199-1215) call self.ref_() first, adding a +1 for the wrapper. The local pipe_nn: NonNull<FileSink> (holding the enum's original +1, no Drop) is dropped without FileSink::deref. Net: ref_count stays at 2 forever; after JS wrapper GC's finalize derefs once → rc=1, never reaches 0. Same in both the has-exited branch (line 441) and the destructor branch (line 461). weak_file_sink_stdin_ptr is weak (no owned ref) so doesn't compensate.
Symptom: Every Bun.spawn({stdin:'pipe'}) whose proc.stdin getter is read leaks one native FileSink (heap allocation + writer buffers + possibly the underlying fd via writer.owns_fd not closing). Observable as $__INTERNAL_DO_NOT_USE_OR_YOU_WILL_BE_FIRED__fileSinkLiveCount() growing unbounded across spawn loops, and as fd exhaustion in long-running parent processes that spawn many children with stdin pipes.
Test coverage: ❌ none — no test asserts FileSink live-count returns to baseline after a spawn-with-stdin-pipe loop.
Recommended fix: After pipe.to_js(...) / pipe.to_js_with_destructor(...) returns the JSValue, call FileSink::deref(pipe_nn) before returning (mirror the Blob.rs:1899-1902 pattern). Apply at both line 441 and line 461. Add a regression test that spawns N children with stdin:"pipe", reads proc.stdin, awaits exit, GC's, and asserts fileSinkLiveCount() is unchanged.
H2. Bun.spawn({stdin: ReadableStream}) leaks FileSink at rc≥2
Zig: src/runtime/api/bun/subprocess/Writable.zig:115/193 — pipe.assignToStream(&stdio.readable_stream, ...). Zig version takes no extra ref; create's +1 stays with the enum.
Rust: src/runtime/api/bun/subprocess/Writable.rs:233/340 — pipe.assign_to_stream(rs, global). Rust version takes a +1 inside (FileSink.rs:1543); combined with H1, the ReadableStream-stdin path ends at rc≥2 after wrapper GC vs Zig's rc=0. Flow: FileSink::create* (rc=1) → assign_to_stream adds +1 (rc=2) → stored in Writable::Pipe(pipe_nn). If proc.stdin is later read, to_js adds another +1 (rc=3) and the local is dropped → controller-finalize + sink-finalize bring it to rc=1, never freed.
Symptom: Passing a ReadableStream as stdin to Bun.spawn leaks the FileSink (and its IOWriter buffers / fd) for the life of the process, even after the stream completes and the Subprocess is GC'd.
Test coverage: ❌ none.
Recommended fix: After pipe.assign_to_stream(rs, global) at Writable.rs:233 and :340, call FileSink::deref(pipe) to release the +1 that assign_to_stream added on behalf of the controller (the controller's finalize will release it again — no, the controller holds its own +1; the enum still holds create's +1). Correct fix: leave assign_to_stream's +1 owned by the controller, and ensure the enum's +1 is released when Writable::Pipe is consumed (i.e. in to_js per H1, and in Writable's deinit/close path if stdin is never read). Audit Writable::close/deinit for the same missing deref.
H3. Body::Value recycled without Drop → WTFString / Blob content-type leak per Request/Response GC
Rust: src/collections/hive_array.rs:478-490 (HiveRef::unref → (*pool).put(self) only; comment claims "Zig's @hasDecl deinit maps to T::Drop") + src/runtime/webcore/Body.rs:1511 (Value has pub fn reset(&mut self) but noimpl Drop). Variant payloads WTFStringImpl (Copy raw ptr, no Drop) leak +1 WTF refcount; Blob.content_type: Cell<*const [u8]> (raw ptr, no Drop) leaks heap when content_type_allocated==true. Only store: StoreRef, name: OwnedStringCell, and Locked.readable.held: strong::Optional have implicit drop glue.
Symptom: Memory leak per Request/Response GC: WTFString refcounts never reach zero (string bodies pin WTF heap), and Blob content-type buffers leak. Observable as monotonic RSS growth under HTTP load with string/typed bodies; would fail leak-detector tests.
Test coverage: ✅ partial — test/js/bun/http/serve-leak.test.ts exercises Request/Response churn but does not assert WTFString live-count.
Recommended fix: Either (a) impl Drop for Body::Value { fn drop(&mut self) { self.reset() } }, ensuring reset() is idempotent (it must leave fields in a validly-droppable empty state since HiveArray::put will drop_in_place again — see M4); or (b) restore explicit value.reset() before pool.put() in HiveRef::unref. Prefer (a) so the invariant "Drop = deinit" holds for all hive-pooled types.
H4. convert_utf16_to_utf8_in_buffer widened from ∅ error set to {buffer-too-small}
Zig: src/bun_core/string/immutable/unicode.zig:1564-1578 — pub fn convertUTF16toUTF8InBuffer(buf, input) ![]const u8 has !T signature but body never returns an error (just calls simdutf and slices buf[0..result]). Effective error set = ∅.
Rust: src/bun_core/lib.rs:2317-2345 — pub fn convert_utf16_to_utf8_in_buffer(...) -> Result<&mut [u8], EncodeIntoResult>does return Err when simdutf reports a surrogate and the Vec-fallback exceeds out.len().
Symptom: Every transliterated catch unreachable → .expect("unreachable") is now a reachable panic; every catch {…} → Err(_) => … now executes a recovery path Zig never took. Cascades to ~10 Windows wide-path call sites (see M7–M11). Panics or silently-swallowed conversions where Zig would have written past the buffer (UB) or simply succeeded for the common simdutf-handles-it case.
Test coverage: ❌ none — no test passes a u16 path whose UTF-8 expansion exceeds the destination buffer.
Recommended fix: Either (a) restore Zig's contract — make the function infallible by allocating into the provided slice and debug_assert!ing capacity (matching Zig's implicit precondition), removing Result from the signature; or (b) keep the new Err and audit every caller (M7–M11) to handle it correctly instead of .expect("unreachable") / .unwrap_or(0). Prefer (a) for parity; (b) is the safer-than-Zig option but requires ~10 call-site changes.
MEDIUM risk (11)
M1. FileSink::to_js protocol inversion — every unaudited caller is a latent leak
Rust: src/runtime/webcore/FileSink.rs:1199-1216 — to_js/to_js_with_destructor call self.ref_()beforeJSSink::create_object; wrapper gets a fresh +1, caller keeps its +1. Intentional protocol inversion (documented at FileSink.rs:977-984): Rust makes the per-wrapper +1 explicit, requiring every caller that allocates via init/create* then calls to_js* to also FileSink::deref() afterward. Blob.rs:1899/1956 were already fixed; subprocess Writable.rs (H1/H2) was not.
Symptom: Silent FileSink leak at any future call site that ports Zig's init→toJS pattern verbatim. Not user-visible by itself, but every unaudited caller is a latent leak.
Test coverage: ✅ test/js/bun/io/filesink-leak.test.ts covers the Blob path.
Recommended fix: Add #[must_use = "caller still owns +1; call FileSink::deref after to_js"] doc on to_js*, or invert back to transfer semantics and remove self.ref_() (then drop the compensating deref at Blob.rs:1902/1960). Grep for all to_js/to_js_with_destructor callers and confirm each has a paired deref.
M2. FileSink::assign_to_stream leaks +1 on error early-return
Zig: src/runtime/webcore/FileSink.zig:798-814 — assignToStream: only a transient this.ref(); defer this.deref(); guard around the extern call; no extra ref before JSSink.assignToStream.
Rust: src/runtime/webcore/FileSink.rs:1543-1549 — assign_to_stream: self.ref_() taken for the controller wrapper beforeJSSink::assign_to_stream; on the to_error() early-return at lines 1547-1549 that +1 is not released. If ${abi}__assignToStream returns an Error without having created a controller (so no future finalize will fire), the speculative +1 leaks. Non-error paths are balanced (controller's finalize derefs).
Symptom: FileSink leak when piping a ReadableStream into a FileSink and the C++ assignToStream path errors before constructing the controller (e.g. JS exception in pipeTo setup). Rare path; manifests as native FileSink live-count growing under stream-error stress.
Test coverage: ❌ none.
Recommended fix: On the error branch at FileSink.rs:1547-1549, call self.deref() before returning to_error(). Alternatively, move the self.ref_() to after the extern call succeeds (match Zig's transient-guard structure).
M3. NumberRenamer intermediate scopes leak global-heap name_counts per print job
Zig: src/js_printer/renamer.zig:592-623 — assignNamesRecursiveWithNumberScope: loop creates child scopes; defer if (s != initial_scope) { s.deinit(temp_allocator); pool.put(s) } only cleans the finals; intermediate scopes' name_counts are arena-backed via temp_allocator, bulk-freed in NumberRenamer.deinit:465 → arena.deinit().
Rust: src/js_printer/renamer.rs:740-790 — same control flow: only final s is put; intermediate scopes never returned. renamer.rs:847: name_counts: StringHashMap<u32> is global-heap, not arena. No impl Drop for HiveArrayFallback. Zig's intermediate leaks were harmless because of the arena; Rust dropped the arena param, so intermediate scopes stay marked "used" in the hive and their name_counts HashMaps leak when NumberRenamer drops.
Symptom: Bundler/minifier memory leak proportional to nested-scope chain depth × scope count, per print job. Long-running dev-server / watch-mode rebuilds would grow unbounded.
Test coverage: ❌ none.
Recommended fix: Either (a) restore arena allocation for name_counts (use bumpalo::collections::HashMap or equivalent backed by the renamer's arena); or (b) track all allocated NumberScopes in a Vec and put each on NumberRenamer::Drop; or (c) impl Drop for HiveArrayFallback to walk used slots and drop them.
M4. HiveArray::put now runs drop_in_place — root semantic divergence
Rust: src/collections/hive_array.rs:158-179 — put: drop_in_place(value); poison; unset. PORT NOTE acknowledges this as intentional. But every Zig caller that did value.deinit(); pool.put(value) is now deinit() + drop_in_place() — safe only if the manual deinit leaves the struct in a validly-droppable state (idempotent). Conversely, callers that relied on T having no Drop (e.g. Body::Value, H3) now silently leak.
Symptom: Either double-free (if a caller's manual pre-deinit leaves a field in a state Drop frees again — none confirmed) or leak (if T has no Drop but Zig had deinit — confirmed for Body::Value). Per-site audit required.
Test coverage: ❌ none specifically; covered indirectly by hot paths.
Recommended fix: Document the invariant in HiveArray::put doc-comment: "T::Drop must be idempotent after manual deinit, OR callers must not manually deinit before put". Add a debug_assertions-only sentinel that detects double-drop for hive-pooled types. Audit all put() callers (≈14) against this invariant.
M5. NetworkTask self-referential field drop order is correct but unenforced
Zig: src/install/PackageManager/runTasks.zig:599,650 — defer manager.preallocated_network_tasks.put(task.request.*.network) — Zig put is no-op-drop; url_buf/response_buffer/request_buffer leak per cycle.
Rust: src/install/PackageManager/runTasks.rs:962-974,1045-1056 — manually assume_init_drop() on unsafe_http_client then put(net_ptr) which drop_in_place's url_buf: Box<[u8]>, request_buffer/response_buffer: MutableString, response: HTTPClientResult, apply_patch_task, tarball_stream. Mostly a fix — but unsafe_http_client: MaybeUninit<AsyncHTTP> borrows url_buf (self-referential, NetworkTask.rs:46) and is dropped beforeput drops url_buf, so order is correct. response: HTTPClientResult<'static> (NetworkTask.rs:64) also lifetime-erases borrows of url_buf; field-declaration order has response before url_buf so response drops first — OK. Any future field reorder or added borrow would UAF.
Symptom: Currently a leak fix vs Zig. Risk is latent UAF if field declaration order changes or a new self-referential borrow is added after url_buf. No crash today.
Test coverage: ❌ none for drop-order invariant.
Recommended fix: Add a // SAFETY: field declaration order is load-bearing — responseandunsafe*http_clientborrowurl_buf and MUST be declared before it comment at NetworkTask.rs:46-64, and a static_assertions-style compile check (e.g. const *: () = assert!(offset_of!(..., response) < offset_of!(..., url_buf))) if available.
M6. RequestContext recycle: explicit deinit + drop_in_place on ~30 fields
Zig: src/runtime/server/RequestContext.zig:306 — server.request_pool_allocator.put(this) — Zig put is no-op-drop; deinit() already cleared owned fields.
Rust: src/runtime/server/mod.rs:3040-3060 — release_request_context → (*self.request_pool).put(ctx) runs drop_in_place on the entire RequestContext. Same explicit clears in RequestContext.rs:870-898, then put drops every field including those not explicitly cleared in deinit() — e.g. request_body_readable_stream_ref, signal, byte_stream. Any field with non-idempotent manual-deinit + Drop would double-free. None found, but RequestContext has ~30 fields and only a subset are explicitly reset.
Symptom: Potential double-free/UAF if any RequestContext field has both an explicit .deinit() call in finalize_without_deinit/deinitand a non-idempotent Drop. Currently the Strong-based fields are idempotent (Optional::Drop uses take()). New fields added without this discipline would crash on every request completion.
Test coverage: ✅ test/js/bun/http/serve.test.ts exercises request lifecycle heavily; would catch a regression.
Recommended fix: Replace explicit per-field .deinit() calls in RequestContext.rs:870-898 with field-reassignment to default (self.x = Default::default()), letting Drop handle the old value exactly once. Then put's drop_in_place drops only defaults. Add a comment at the struct definition: "all fields must have idempotent Drop; recycled via HiveArray::put".
M7. path.resolve Windows env-var transcoding swallows new error to 0
Zig: src/runtime/node/path.zig:2496 — bufSize = std.unicode.wtf16LeToWtf8(buf2, r); — different stdlib fn, infallible (returns bare usize).
Rust: src/runtime/node/path.rs:3185-3187 — buf_size = strings::convert_utf16_to_utf8_in_buffer(dst, &r).map(|s| s.len()).unwrap_or(0);. Port swapped infallible wtf16LeToWtf8 for fallible convert_utf16_to_utf8_in_buffer then swallowed the new error to 0.
Symptom: On Windows, path.resolve() reading the per-drive =C: env var would compute against an empty string → falls back to drive root instead of the env-stored cwd, when the env value contains unpaired surrogates or is very long.
Test coverage: ✅ test/js/node/path/resolve.test.js (but no surrogate/long-path case).
Recommended fix: Port wtf16LeToWtf8 (WTF-8, infallible by design — surrogates encode as 3 bytes) instead of using the strict-UTF-8 helper. This is the correct semantic for Windows paths anyway.
M8. bun_paths::PooledBuf::append panics on long/surrogate Windows paths
Zig: src/paths/Path.zig:133 — convertUTF16toUTF8InBuffer(this.pooled[this.len..], characters) catch unreachable — body is infallible so catch unreachable is dead.
Rust: src/paths/Path.rs:336-338 — strings::convert_utf16_to_utf8_in_buffer(dest, src).expect("unreachable").len(). .expect("unreachable") is now reachable (see H4).
Symptom: Hard panic in bun_paths::PooledBuf::append when constructing a Path from a long/surrogate-heavy Windows wide path (e.g. install/hardlinker, run_command temp-dir).
Test coverage: ❌ none.
Recommended fix: Depends on H4 resolution. If H4(a), this becomes infallible again. If H4(b), grow the pooled buffer on Err and retry, or propagate Err up to the caller.
M9. Path<u16>::relative — new code path with two reachable .expect("unreachable")
Zig: src/paths/Path.zig — relative() calls relativeBufZ which is u8-only; a u16 instantiation would @compileError (Zig lazy eval hides it; per PORT NOTE no u16 caller exists in Zig).
Rust: src/paths/Path.rs:1260-1262 — new u16 branch transcodes via convert_utf16_to_utf8_in_buffer(...).expect("unreachable") twice. Runtime u16 code path with no Zig spec to compare against.
Symptom: Panic computing Path<u16>::relative() between long Windows wide paths whose UTF-8 expansion overflows the pooled scratch buffer.
Test coverage: ❌ none.
Recommended fix: Same as M8. Additionally, since this path has no Zig reference, add an explicit unit test for Path<u16>::relative with multi-BMP-plane inputs.
M10. Bun.which() panics on Windows for found paths whose UTF-8 form > MAX_PATH_BYTES
Symptom: Bun.which() / shell which panics on Windows for a found executable whose UTF-8 path > MAX_PATH_BYTES (Zig would have buffer-overrun / truncated).
Test coverage: ✅ test/js/bun/util/which.test.ts (but no long-path case).
Recommended fix: Allocate the output buffer at result.len() * 3 (worst-case UTF-8 expansion) instead of MAX_PATH_BYTES, or apply H4(a).
M11. bun --bun run on Windows fails with new InvalidUtf16 for long %TEMP%
Zig: src/runtime/cli/run_command.zig:629 — try bun.strings.convertUTF16toUTF8InBuffer(...) — try on a fn whose body never errors; effectively infallible.
Rust: src/runtime/cli/run_command.rs:1733-1737 — .map_err(|_| bun_core::err!("InvalidUtf16"))?. Propagates a brand-new InvalidUtf16 error that Zig could never produce.
Symptom: On Windows with very long %TEMP%, bun --bun run fails with error: InvalidUtf16 instead of succeeding (or UB).
Test coverage: ❌ none.
Recommended fix: Apply H4(a), or size the buffer at wide.len() * 3 so the error case is genuinely unreachable, then .expect().
LOW risk (11)
| # | Category | Zig site | Rust site | Divergence | Symptom | Test |
| --- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | --- |
| L1 | refcount-transfer | Blob.zig:2951/2989return sink.toJS(globalThis) | Blob.rs:1899-1902/1956-1960let js = sink.to_js(..); FileSink::deref(sink); Ok(js) | Correctly adapted to new protocol — reference "fixed" site for pattern-matching against H1/H2 | None — already fixed under #53265 | ✅ |
| L2 | refcount-transfer | streams.zig:1535NetworkSink::toJS (not refcounted; finalizeAndDestroy frees) | streams.rs:2376-2378 + :2406→:2203finalize only detach_writable, never frees | Parity (both leak on GC-only finalize) but client.rs:551 comment "ownership transfers to JS wrapper" is misleading — neither side frees on finalize | If only GC finalize fires (not finalize_and_destroy), Box<NetworkSink> leaks in both Zig and Rust. Not a port divergence; pre-existing | ❌ |
| L3 | refcount-transfer | Sink.zig:296-311construct: bun.new → createObject (transfer) | Sink.rs:866-872js_construct: heap::into_raw → create_object_extern (transfer, no ref_()) | Correct, but means two protocols in Rust: construct transfers, to_js adds. Any refactor routing construct through to_js would leak | No runtime symptom; latent inconsistency | ❌ |
| L4 | deinit-vs-drop | ErrorReportRequest.zig:345-353 stack Log + defer log.deinit() | ErrorReportRequest.rs:502-523arena.alloc(Log::init()); comment "log dropped at scope exit" is wrong — mi_heap_destroy doesn't run destructors | Log{msgs:Vec, owned_strings:Vec<Box<[u8]>>} global-heap Vecs leak | Small leak per dev-server error-report request, only if TOML lexer emits diagnostics (shouldn't for synthetic empty source) | ❌ |
| L5 | deinit-vs-drop | RuntimeTranspilerStore.zig:275-278promise.deinit(); deinit(); store.put(this) (Zig put no-op) | RuntimeTranspilerStore.rs:511-538/593-599reset_for_pool clears all → put drops again; promise.deinit() called at :593 and inside reset_for_pool:534 | Redundant double-clear (idempotent today). Would become double-deref if bun_core::String ever gains Drop | Currently benign; fragile | ❌ |
| L6 | deinit-vs-drop | pool.zig:248-257destroyNode: skips deinit for ByteList (acknowledged leak) | pool.rs:514-529 unconditionally assume_init_drop() incl. Vec<u8> | Rust frees overflow Vec capacity; Zig leaked it. Behavior change in the fix direction | Lower memory usage in Rust. Intentional improvement | ❌ |
| L7 | deinit-vs-drop | bake.zigerrdefer for (file_system_router_types[0..i]) | \*fsr | fsr.style.deinit() | bake_body.rs:960-961// TODO(port): errdefer ... — Style should impl Drop | Style has no impl Drop; on ? early-return, style resources leak | Leak of FrameworkRouter Style resources when Bun.serve({app:{fileSystemRouterTypes:[...]}}) validation fails partway. Tracked TODO | ❌ |
| L8 | deinit-vs-drop | HTTPContext.zig:469-483 explicit ssl_config.deinit(); proxy_tunnel.deref(); free(target_hostname); h2_session.deref() then put (no-op) | HTTPContext.rs:248-262release_parked_refs (take()+deref()) then :1232 put drops empty fields | IntrusiveRc has no impl Drop, so rp.deref() then drop-of-rp is single decrement — correct. If IntrusiveRc ever gains Drop, becomes double-decrement. Same hazard for h2_session: Option<NonNull> | Correct today; latent double-deref if IntrusiveRc gains Drop. Pattern at :254-261 and :808-813 | ❌ |
| L9 | error-union-vs-result | spawn.zig:221,236,247,263 per-site NAMETOOLONG => unreachable / BADF => unreachable | posix_spawn.rs:303-312 single spawn_errno() maps NAMETOOLONG => Err("NameTooLong"), BADF => Err("InvalidFileDescriptor"); only INVAL stays unreachable!() | Widened error set: errnos Zig declared impossible now propagate as Err. Documented in Rust comment | Buggy libc returning NAMETOOLONG from posix_spawn_file_actions_addclose: Zig crashes; Rust surfaces spawn error to JS | ✅ |
| L10 | error-union-vs-result | bun.zig:627-629assert((fcntl(...) catch unreachable) & O.NONBLOCK != 0) — fcntl always executed | bun.rs:505-509debug_assert!((fcntl(...).expect("unreachable") & O::NONBLOCK) != 0) — compiled out in release | Zig assert evaluates argument unconditionally; Rust debug_assert! strips it. fcntl syscall + check skipped in release | None user-observable (self-check). Divergence in syscall count under strace | ❌ |
| L11 | error-union-vs-result | exit.zig:18-21parseInt(u8) catch \|err\| switch { .Overflow => @intCast((parseInt(usize) catch fail) % 256), .InvalidCharacter => fail } | exit.rs:58-60parse_decimal::<u64>(s).map(\|n\| (n % 256) as ExitCode) → None on any failure | Zig distinguishes Overflow (retry wider) from InvalidCharacter (fail). Rust collapsed both into Option<u64>; two-stage retry gone. For inputs in (u8::MAX, usize::MAX] both produce n % 256 — semantics match by accident on 64-bit | None on 64-bit. Loses explicit error-variant routing — future parse_decimal changes would silently diverge | ✅ |