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
{{ message }}
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
Found while removing the pg native binding (PR #10677): there were two native pg implementations, both
defining 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).
Nothing reported this. There is no gate that notices two crates exporting one extern "C" surface; the linker
just 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
mysql2 shows the same two-location shape: crates/perry-stdlib/src/mysql2/ (~1870 lines) alongside a crates/perry-ext-mysql2 crate. That one is being untangled now as part of its own removal, but the pattern
suggests the #466 migration (moving bindings from perry-stdlib into perry-ext-* crates) left duplicates behind
in more than one place.
There are currently ~38 crates/perry-ext-* crates. Any of them whose package also has a crates/perry-stdlib/src/<pkg>/
directory is a candidate.
Suggested work
Enumerate it. For each perry-ext-* crate, check whether a same-named perry-stdlib/src/ module exists, and
whether the two export overlapping extern "C" symbols.
Add a gate. A duplicate #[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.
Delete the dead side of each pair. Where both are live-ish, decide which is canonical before the removal
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.
Third confirmed instance: axios. The removal PR (#10679) found axios also had two native locations — crates/perry-ext-axios/ and crates/perry-stdlib/src/axios.rs.
So the pattern now holds for every package the removal campaign has touched:
Three 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.rs callable-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-mysql2 and crates/perry-stdlib/src/mysql2/ (~1870 lines, behind perry-stdlib's default-onbundled-mysql2 feature) exported the identical js_mysql2_* symbol names, but registered handles in separate, documented-as-disjoint integer spaces — crates/perry-ffi/src/handle.rs states perry-ffi's and perry-stdlib's common::handle registries are distinct. The stdlib copy's dispatch_mysql2_method/dispatch_mysql2_property fallback (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 --check and every_dispatch_entry_has_manifest_counterpart all passed, and the removal still left dangling references that only grepping found:
explicit -p perry-ext-mysql2 args in .github/workflows/test.yml
the same in run_doc_tests.sh and run_doc_tests.ps1
hardcoded row/provider counts in scripts/native_result_ledger.tsv and .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.
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 the is_valid_obj_ptr catch 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.
The enumeration half, done — and the artifact evidence corrects the scope
17 perry-ext-* crates have a same-named perry-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 each crates/perry-ext-<pkg>/**/*.rs against crates/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_* 0
Zero. js_net_socket_connect, js_pg_client_new, js_ws_connect, js_zlib_crc32 and js_argon2_hash are each defined by libperry_stdlib.a and by neither ext archive; libperry_ext_net.a exports no js_* or perry_* symbol at all despite being 48 MB. So in the default build (perry-stdlib's default = ["full"], which turns on every bundled-*) 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-* / feature cfgexcept net, which is declared unconditionally (crates/perry-stdlib/src/lib.rs:174). net is 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 to js_*/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 cfg arms. 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 pg pair (10 symbols) and the mysql2 pair (15) are removed outright by the combined removal in merge train 245 — crates/perry-ext-pg, crates/perry-ext-mysql2 and both perry-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.
Activity
proggeramlug commentedon 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:
perry-ext-pg/+perry-stdlib/src/pg/(sameextern "C"symbols, link-order resolved)perry-ext-mysql2/+perry-stdlib/src/mysql2/(~1870 lines)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.proggeramlug commentedon Sep 18, 2026
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:-p perry-ext-mysql2args in.github/workflows/test.ymlrun_doc_tests.shandrun_doc_tests.ps1scripts/native_result_ledger.tsvand.pyNone 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.proggeramlug commentedon Sep 19, 2026
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.refactor(stdlib): remove jsonwebtoken native binding
refactor(stdlib): remove validator native binding
refactor(stdlib): remove validator native binding
refactor(stdlib): remove dotenv native binding
refactor(stdlib): remove uuid native binding
refactor(stdlib): remove uuid native binding
refactor(stdlib): remove dotenv native binding
refactor(stdlib): remove uuid native binding
refactor(stdlib): remove dotenv native binding
refactor(stdlib): remove dotenv native binding
refactor(stdlib): remove nanoid native binding
refactor(stdlib): remove dotenv native binding
refactor(stdlib): remove nanoid native binding
refactor(stdlib): remove nanoid native binding
proggeramlug commentedon Sep 20, 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: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:
Zero.
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.