Skip to content

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

Closed

Description

@proggeramlug

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.

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

  1. 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.
  2. 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.
  3. 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.

Activity

  1. added
    package-auditFound by the 2026 package audit: compiling real npm packages from source instead of native bindings
    on Sep 18, 2026
  2. proggeramlug commented on Sep 18, 2026

    @proggeramlug
    ContributorAuthor

    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:

    package locations
    pg perry-ext-pg/ + perry-stdlib/src/pg/ (same extern "C" symbols, link-order resolved)
    mysql2 perry-ext-mysql2/ + perry-stdlib/src/mysql2/ (~1870 lines)
    axios perry-ext-axios/ + perry-stdlib/src/axios.rs

    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.

  3. proggeramlug commented on Sep 18, 2026

    @proggeramlug
    ContributorAuthor

    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-on bundled-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.

  4. proggeramlug commented on Sep 19, 2026

    @proggeramlug
    ContributorAuthor

    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.

  5. proggeramlug commented on Sep 20, 2026

    @proggeramlug
    ContributorAuthor

    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 cfg except 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    package-auditFound by the 2026 package audit: compiling real npm packages from source instead of native bindings

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions