Repository navigation
Two native pg implementations exported the same extern "C" symbols, resolved by link order — audit the perry-ext-*/perry-stdlib pairs and gate duplicate symbol exports #10678
Description
Activity
- addedpackage-auditFound by the 2026 package audit: compiling real npm packages from source instead of native bindingsFound by the 2026 package audit: compiling real npm packages from source instead of native bindings
on Sep 18, 2026 Third confirmed instance: axios. The removal PR (#10679) found axios also had two native locations —
crates/perry-ext-axios/andcrates/perry-stdlib/src/axios.rs.So the pattern now holds for every package the removal campaign has touched:
package locations pg perry-ext-pg/+perry-stdlib/src/pg/(sameextern "C"symbols, link-order resolved)mysql2 perry-ext-mysql2/+perry-stdlib/src/mysql2/(~1870 lines)axios perry-ext-axios/+perry-stdlib/src/axios.rsThree for three. That is no longer a suspicion about the #466 migration — it is the default state, and the enumeration in step 1 of this issue should be treated as expected-to-find-many rather than exploratory.
One more thing worth folding into the audit, from #10679: removing axios also deleted compiler-level special-casing that existed solely for it — HIR "axios→Response" local-instance tagging across four files, and an
emit.rscallable-default case for which axios was the only user. So these bindings are not merely duplicated Rust; some of them have tendrils in HIR and codegen. Any estimate of removal effort that counts only the crate directories will be low.mysql2's duplicate was worse than pg's, and the removal found a gate blind spot. Both from PR #10680.
On the duplication: pg's two copies were resolved by link order — bad, but at least one consistent implementation won. mysql2's were not equivalent. Both
crates/perry-ext-mysql2andcrates/perry-stdlib/src/mysql2/(~1870 lines, behind perry-stdlib's default-onbundled-mysql2feature) exported the identicaljs_mysql2_*symbol names, but registered handles in separate, documented-as-disjoint integer spaces —crates/perry-ffi/src/handle.rsstates perry-ffi's and perry-stdlib'scommon::handleregistries are distinct. The stdlib copy'sdispatch_mysql2_method/dispatch_mysql2_propertyfallback (used for Drizzle's interface-typed fields) could therefore only resolve handles it had created itself.So this was a live footgun in the default build, not dead code: two implementations claiming one symbol surface, with mutually unintelligible handles. Add "do the duplicates share a handle space?" to the audit — symbol collision alone understates the risk.
On the gate blind spot:
binding_governance.py --check,binding_pins.mjs --checkandevery_dispatch_entry_has_manifest_counterpartall passed, and the removal still left dangling references that only grepping found:- explicit
-p perry-ext-mysql2args in.github/workflows/test.yml - the same in
run_doc_tests.shandrun_doc_tests.ps1 - hardcoded row/provider counts in
scripts/native_result_ledger.tsvand.py
None of the binding-registry gates know about build-script or workflow references to a crate. Worth extending the audit in step 1 of this issue to include a plain repo-wide grep for the crate name, and worth considering whether a gate should assert that no
-p perry-ext-*argument names a crate absent from the workspace.- explicit
Sharper framing of the mysql2 case, from the merge lane, worth stating plainly because my earlier comment undersold it:
Two implementations sharing
js_mysql2_*symbol names while using disjoint handle spaces is not dead code — it is a handle from one space being decoded by the other's accessors. That is the same failure family as theis_valid_obj_ptrcatch earlier in this campaign: a plausible-looking value handed to the wrong owner's decoder.And the consequence is worse than a crash. Had that path been exercised, the symptom would be arbitrary field reads, not a clean failure — an integer valid in one registry indexes something unrelated in the other. No guard fires, nothing throws; you get wrong data.
So for the audit in this issue,
do the duplicates share a handle space?is not a secondary question. Two copies that share a symbol surface and a handle space are merely redundant. Two that share a symbol surface and have separate spaces are a silent-corruption hazard, and should be triaged ahead of the merely-duplicated ones.- added 14 commits that reference this issue
on Sep 19, 2026 The enumeration half, done — and the artifact evidence corrects the scope
17
perry-ext-*crates have a same-namedperry-stdlib/src/<pkg>module, and between each pair the two source trees define 320 identical#[no_mangle] extern "C"symbol names:pkg dup symbols pkg dup symbols pkg dup symbols fetch 76 mongodb 26 ws 17 decimal 25 zlib 23 streams 17 events 19 cheerio 18 net 17 ioredis 18 sharp 18 mysql2 15 pg 10 ethers 8 argon2 5 bcrypt 5 nodemailer 3 Derived by matching
#[no_mangle] (pub )?(unsafe )?extern "C(-unwind)?" fn <name>in eachcrates/perry-ext-<pkg>/**/*.rsagainstcrates/perry-stdlib/src/<pkg>/**and intersecting.But that source count overstates the live hazard, and the shipped archives say so. Against the v0.5.1623 artifacts:
libperry_stdlib.a 74899 defined symbols libperry_ext_net.a 4915 defined symbols defined in BOTH 4314 <- all compiler-rt / libcore boilerplate ... of which js_* 0Zero.
js_net_socket_connect,js_pg_client_new,js_ws_connect,js_zlib_crc32andjs_argon2_hashare each defined bylibperry_stdlib.aand by neither ext archive;libperry_ext_net.aexports nojs_*orperry_*symbol at all despite being 48 MB. So in the default build (perry-stdlib'sdefault = ["full"], which turns on everybundled-*) the stdlib side is the only definer, and the ext crates are not contributing these symbols to the link.That does not make the issue wrong — it locates it. The pg incident was real precisely because both definitions were reachable, and "whichever won the link order silently shadowed the other" is not something any check would have reported. The 320 are latent, not currently colliding.
One gating detail worth recording: every one of these stdlib modules is behind a
bundled-*/ featurecfgexceptnet, which is declared unconditionally (crates/perry-stdlib/src/lib.rs:174).netis also the one whose ext crate ships as a pinned artifact. It does not collide today, for the reason above, but it is the pair with the least between it and a collision.The gate half
A
nm-based check is cheap and discriminating: intersect the defined-symbol sets of the shipped archives, filter tojs_*/perry_*, fail on any non-empty intersection. On the current tree it reports 0, so it can be added green and promoted after one run rather than blocking every open PR (the repo's standard sequencing for a new required context).Its blind spot must be stated, because an unstated one is how a gate stops being able to fail: an artifact check only sees the feature set that was built. The source-level intersection above sees all 320 regardless of features, but cannot tell a real collision from two mutually-exclusive
cfgarms. The two are complementary and a useful gate wants both — the artifact check as the hard failure, the source enumeration as a ratchet whose count may only go down.Status
The
pgpair (10 symbols) and themysql2pair (15) are removed outright by the combined removal in merge train 245 —crates/perry-ext-pg,crates/perry-ext-mysql2and bothperry-stdlib/src/<pkg>trees are deleted, so those two rows go to zero rather than being gated. That closes the incident this issue was opened from.Leaving this issue open for the remaining 15 pairs and the gate itself; the enumeration above is the input both need.
Found while removing the
pgnative binding (PR #10677): there were two nativepgimplementations, bothdefining the same
extern "C"symbols, and link order silently decided which one was live.crates/perry-ext-pg/— the registered binding (sqlx::postgres + tokio via perry-ffi).crates/perry-stdlib/src/pg/— an older duplicate that survived the Native package ecosystem v1: open, extensible, with zero developer-facing change #466 migration behind abundled-pgfeature, exporting the same symbols.
Nothing reported this. There is no gate that notices two crates exporting one
extern "C"surface; the linkerjust picks one. Whichever it picked is what users got, and a fix applied to the other one would have looked
inert — the same failure shape as the stale-archive trap CLAUDE.md documents, but caused by duplication rather
than staleness.
This is probably not unique to pg
mysql2shows the same two-location shape:crates/perry-stdlib/src/mysql2/(~1870 lines) alongside acrates/perry-ext-mysql2crate. That one is being untangled now as part of its own removal, but the patternsuggests the #466 migration (moving bindings from
perry-stdlibintoperry-ext-*crates) left duplicates behindin more than one place.
There are currently ~38
crates/perry-ext-*crates. Any of them whose package also has acrates/perry-stdlib/src/<pkg>/directory is a candidate.
Suggested work
perry-ext-*crate, check whether a same-namedperry-stdlib/src/module exists, andwhether the two export overlapping
extern "C"symbols.#[no_mangle]/extern "C"symbol across crates should be a build or lint failure,not a link-order coin flip. This repo already ratchets several similar properties (addr-class, raw-handle,
unrooted-local-shape), so the machinery exists.
campaign reaches that package — otherwise a removal PR deletes one and leaves the other quietly serving traffic.
Why it matters for the current work
The owner's goal is to delete these hand-written Rust reimplementations in favour of compiling the real npm
packages. A removal that deletes only the registered copy would leave a shadow implementation in the tree that
still claims the symbols — the package would appear to be "removed" while a duplicate continued to answer. #10677
avoided that only because its agent went looking.