Skip to content

chore(bindings): remove axios native binding, compile real axios from source - #10679

Closed
proggeramlug wants to merge 126 commits into
fix/10662-namespace-export-equals-fallbackfrom
wip/axios-native-binding-removal
Closed

proggeramlug wants to merge 126 commits into
fix/10662-namespace-export-equals-fallbackfrom
wip/axios-native-binding-removal

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Summary

Removes the axios native binding (perry-ext-axios, perry-stdlib/src/axios.rs, the
js_axios_* FFI surface, and every NATIVE_MODULES/manifest/HIR/codegen special-case
that existed only to route it) so import axios from "axios" resolves to the real npm
package instead of Perry's hand-written reimplementation. Removal only — no other
binding touched, no behavior changes to anything that stays.

Do not merge before #10673 (fix/10662-namespace-export-equals-fallback). This
branch is based on it, not on main — axios does not compile without #10673's
agent-base/https-proxy-agent namespace export = fallback fix. If this PR merges
first, real-source axios (and anything depending on it) breaks.

What was deleted

  • crates/perry-ext-axios (crate + Cargo.toml workspace membership)
  • crates/perry-stdlib/src/axios.rs and its mod/pub use in lib.rs, and the axios
    response-property arm in common/dispatch/property_dispatch.rs
  • The [bindings.axios] block in well_known_bindings.toml
  • The "axios" NATIVE_MODULES entry and its 11 manifest method rows (entries.rs,
    entries/part_4.rs)
  • Codegen dispatch for axios's static HTTP methods and response-property access
    (lower_call/options/fetch.rs), and the js_axios_* FFI declarations
    (runtime_decls/stdlib_ffi/third_party.rs) plus their runtime/no-op-stub
    implementations (perry-runtime/src/closure/v8_stubs.rs + closure/mod.rs's
    re-export, perry-ui-android/src/stdlib_stubs.rs)
  • The HIR "axios.get/post/… returns a Response" local-instance tagging that existed
    only to route .status/.data/.statusText through the now-deleted native dispatch
    (local_natives.rs, destructuring/var_decl/native_new.rs, lower/module_decl.rs,
    lower/stmt.rs)
  • emit.rs's "callable default export" special-case, which existed only for axios's
    axios(config) shape (its two axios-specific .d.ts tests went with it)
  • The axios-only HIR test (axios_response_property_lowering.rs) and the native-dispatch
    NaN-boxing regression test (test_issue_340_axios_response_props.ts + its
    known_failures.json entry) — both tested internals of the deleted shim
  • workspace-architecture.json's crate entry and baseline counts (workspace_members
    83→82, decision_counts.externalize 33→32)

Regenerated docs/api/perry.d.ts, docs/src/api/reference.md, and
docs/src/native-libraries/governance.md's table. Dropped axios's row/section from
docs/native-libraries.md and its line from the perry native list example in
docs/src/cli/commands.md. Swapped unimplemented_api_check.rs's
supported_module_with_unknown_member_is_rejected witness from axios to node-fetch
(still native) since the test needs a live NATIVE_MODULES member.

Left alone (still legitimate as npm-package-name examples, unrelated to the native
binding): axios entries in crates/perry/src/commands/install/scanner/typosquat.rs +
top_packages.txt (typosquat popularity data), crates/perry-hir/src/capability.rs
(per-package capability-policy test fixtures), and comments across
perry-stdlib/perry-runtime/perry-codegen describing real axios behavior that
motivated generic (non-axios-specific) fixes.

The acceptance test that matters

A plain import axios from "axios" with no perry.compilePackages entry for axios at
all
compiles and runs correctly. Verified two ways:

  1. tests/release/packages/axios-get — its package.json already had no
    compilePackages block (it didn't need one when axios was native) and needs none now
    either. bash fixture.sh passes: real GET/HEAD/OPTIONS round-trip against an
    in-process node:http server, diffed against expected.txt.
  2. A from-scratch repro with just {"dependencies": {"axios": "^1"}} + npm install,
    compiled and run against a live server — RESULT: PASS.

No package.json configuration beyond a plain "axios" dependency is required.
Perry's default "compile npm package source when nothing claims the specifier natively"
path picks up axios and its full transitive dependency graph (agent-base,
https-proxy-agent, follow-redirects, form-data, combined-stream, mime-types, debug, and
the rest) automatically — perry compile reports "130 module(s): 130 native, 0
JavaScript" for them. The earlier probe's perry.compilePackages list (used to force
native-source compilation before this removal) is not needed post-removal — that was
testing something different from what a user actually needs to do.

Validation

  • cargo check --workspace --tests (minus cross-host UI crates): clean except two
    pre-existing breaks on this base branch, confirmed via git stash A/B (neither
    touches anything this PR changed): perry-codegen's test target fails to compile
    (ImportedClass missing constructor_has_synthetic_arguments in two unrelated test
    files) and perry-runtime --tests carries one pre-existing dead-code warning in
    box.rs.
  • cargo test: perry-api-manifest 41/41, perry-hir all suites 0 failed,
    perry-stdlib 139/139 (RUST_TEST_THREADS=1), perry 1136 lib tests + the 5
    integration tests whose comments mention axios (incoming_message_pipe,
    issue_10662_namespace_export_equals_fallback, issue_5174_headers_http_pump_hang,
    response_stream_body_pull) — none depend on axios functionally, all green. Not
    run
    : the full crates/perry/tests/*.rs sweep (~100 files, ~3 min each on the shared
    build host) — unrelated to this diff, and CI's e2e-scoped only runs integration
    tests the diff actually names, which is none here.
  • run_lint_gates.sh SKIP_COMPILE_GATES=1: 76 of 77 script gates pass. The one red
    ("Public benchmark evidence freshness") is pre-existing on every PR in this repo.
    Compile tier not run (known-red on Linux per the campaign brief).
  • cargo fmt --all -- --check: clean.
  • Grepped for axios/js_axios_/perry-ext-axios across the whole repo before and
    after; the only post-removal matches are historical (CHANGELOG.md, dated audit
    snapshots, docs/po/*.po), illustrative comments/examples unrelated to the native
    binding (typosquat data, capability-policy test fixtures, generic bug-history prose),
    and the still-passing axios-get release fixture.

Acceptance result: this is an improvement, not a neutral swap

Real axios with its full 27-package transitive graph, no perry.compilePackages entry: 130 modules, all native, correct status codes and response bodies for GET and POST, exit 0, matching Node 26.5.1.

One divergence exists — response.headers is an empty object — and it is pre-existing, filed as #10744, ruled out as this PR's doing in four steps: a dependency-free node:http control does not diverge; the key is absent rather than miscased or mistyped; it is not #10668's documented rawHeaders casing limitation (that PR explicitly states res.headers is correct); and on pristine main with the native binding verified to be serving the import, status, data and headers are all undefined and the program crashes with Cannot read properties of undefined (reading 'content-type').

So real-source axios is strictly better than the binding this PR removes: status and data correct with headers incomplete, versus nothing correct followed by a crash. This removal is a clear improvement with one known remaining gap, not a neutral swap that introduces one.

Counts corrected, twice, as main moved. This PR previously recorded workspace_members 83→82 / externalize 33→32 (stale against pre-train-223 main), then 79/30/44 (stale against pre-train-225 main, from when #10701's uuid removal hadn't landed yet). Main is now 053b9ccac4 (train 225, v0.5.1604), which already carries the uuid removal, so it starts at 79 workspace members / 30 externalize / 44 keep, not 80/31/44. Removing the axios crate (decision: "externalize") from that base gives workspace_members = 78, externalize = 29, keep = 44 (merge=1, remove=1, review=3 unchanged; sum 78). workspace_architecture.py --check --print-summary independently reproduces 78/29/44 from the resolved tree.

This rebase hit the exact false-merge trap #10739 describes: workspace-architecture.json's baseline numbers and the deleted perry-ext-axios crate entry live on separate lines, so git's 3-way merge silently kept 79/30/44 (both this branch's prior resolution and train 225's uuid removal had independently rewritten the same line to the same value, so git saw "no conflict" rather than "two decrements need composing"). Caught by recomputing from the tree rather than trusting the clean auto-merge; corrected by hand before continuing.

unrooted_local_shape_baseline.json (578, unchanged — no diff vs main, confirmed rather than assumed) and native_result_ledger (green on main post-#10738: 376 rows / 326 providers, EXPECTED_ROWS untouched — no axios rows in the tsv on either side) both re-derived clean. Cargo.toml version takes main's (0.5.1604); Cargo.lock regenerated via cargo metadata --offline, not hand-merged. Confirmed none of the at-cap files (perry-codegen/src/stmt/let_stmt.rs, perry-hir/src/lower/stmt_loops.rs, perry-runtime/src/gc/tests/copying.rs, perry-runtime/src/object/native_module/module_keys.rs, and others within #10750's list) are touched by this diff.

#10691 and #10701 also computed to 79/30/44 against the pre-225 main; #10701 has since landed (consuming that value in 225), #10691 is next to rebase and — depending on landing order relative to this PR — will need either 78/29/44 or 77/28/44, re-derived at that time rather than copied from here.

@coderabbitai

coderabbitai Bot commented Sep 18, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: b37c1034-1c27-4ae5-9535-bd9efad090ec

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

proggeramlug pushed a commit that referenced this pull request Sep 18, 2026
Renamed the axios-removal changeset fragment now that the PR number is
known, and pointed PENDINGCS-compile-smoke-known.md's dangling
placeholder reference at it.
Ralph Küpper and others added 25 commits September 19, 2026 10:25
Several event/listener dispatch loops clone listener callbacks and/or
call arguments into plain Rust locals (a Vec<Listener>, a Vec<f64>, or
a raw array pointer), then call into user code that can allocate and
trigger a moving minor collection, then reuse those unrooted copies
for the next listener. Root every such copy through a
RuntimeHandleScope and re-read the current (possibly relocated) value
before each dispatch, instead of trusting the pre-call copy.

Reproduced at crates/perry-runtime/src/node_stream_event_emitter.rs's
emit_stream_event/call_listener_args (the path `class X extends
EventEmitter` actually dispatches through): a 3-listener emitter whose
second listener allocates heavily segfaults dereferencing the third
listener's stale closure pointer, reliably, with no GC env knobs. Gone
under PERRY_GEN_GC=0 (full mark-sweep, non-moving). Under
PERRY_GC_DIAG=1 PERRY_GC_PROTECT_FROMSPACE=1 the from-space quarantine
reports the exact fault: a retired-from-space deref of a GC_TYPE_CLOSURE
object, matching a symbolized backtrace through js_native_call_value <-
call_listener_args <- emit_stream_event.

The same pattern is fixed at the four other sites an earlier code-read
audit named: perry-stdlib's events.rs (js_event_emitter_emit/emit0,
dispatch_error_monitor, emit_meta_event), domain.rs
(emit_domain_event), worker_threads/worker_surface.rs
(stream_emit_event), and events/warnings.rs (emit_warning). events.rs's
own asynchronous dispatch branch already rooted its callback/receiver/
args via js_async_resource_run_in_async_scope; the synchronous branch
did not, which is the same finding by construction, independent of the
runtime repro.
#8595's entry outliner only ever chunked hir.init. For a CommonJS
module, cjs_wrap::wrap_commonjs_for_target wraps the whole body as
text inside a `function __perry_cjs_factory() {...}` closure nested in
an anonymous IIFE; hir.init ends up with only a handful of wrapper
statements, so admission never fired and the real body stayed one
giant function. On typescript 5.9.3's _tsc.js this was a single
463,716-instruction/6.30 MB closure, past the machine-pipeline budget.

find_cjs_factory_closure(_mut) locates that closure by walking
hir.init's statement/expression tree (it is a Stmt::Let naming an
Expr::Closure, not a hir.functions entry, since it is lexically
nested). outline_entry_module now tries hir.init first (unchanged
#8595 behavior) and falls back to the factory's body with the
identical chunk_statements/analyze_stmts_outlining machinery, so a
module is only ever outlined from one origin per compile.

The factory always captures its own name from the wrapper's IIFE
scope (`__cjs_module.__perry_cjs_factory = __perry_cjs_factory;`,
which perry-runtime's module_require.rs calls through on a
circular-require recovery path). A chunk is a plain, non-capturing
function and can't read a captured id, so classify_for_chunking keeps
any statement referencing one inline in the residual body rather than
promoting it to a module global -- a global would turn a
per-invocation-fresh capture into one program-wide instance and could
silently break that recovery path.

module_globals_emit.rs folds the factory's own logical statements into
the same cross-chunk-let promotion emit_module_globals already does
for hir.init, so a var shared across the factory's new chunks gets the
same @perry_global_* treatment hir.init cross-chunk lets get.

Verified on a synthetic 2107-statement CJS fixture (cross-chunk vars
plus a closure created early and invoked from far-later statements)
against Node's own output, and on a full typescript 5.9.3 build: the
463,716-instruction closure is gone, entry-outline reports
"cjs factory: ... candidate=true", nm shows __perry_entry_chunk_*
symbols, and `--noEmit demo.ts` / `--version` output and exit codes
are byte-identical to before.
… numeric conversion

refresh() called set_timer_ref_state(id, true) unconditionally, so
refreshing an unrefd Timeout/Interval re-refd it -- hasRef() flipped to
true and the process stayed alive for a callback that had been
deliberately detached from the event loop. Node refresh() reschedules
only and never touches ref state; drop the forced ref-state write and
let the existing entry (set at schedule time, updated by any
ref()/unref() since, pinned while the timer is queued) stand.

js_number_coerce gave setImmediate handles the same numeric-conversion
shortcut as Timeout handles, so +setImmediate(...) returned a number
instead of NaN. Node only gives Timeout (setTimeout/setInterval) a
numeric conversion; Immediate has none. Add is_immediate_timer_id and
gate the shortcut on it so an Immediate falls through to the generic
toPrimitive/toString path, which already yields NaN.

Fixes #10541
Fixes #10542
…lue, not just the bare-import name

class X extends AsyncResource threw "Class constructor AsyncResource
cannot be invoked without 'new'" at super() for every heritage shape
except a bare import binding. A local alias, a namespace member, and a
CJS destructured require() all resolve to the identical bound native
export value the canonical import does, but only the bare import shape
was recognized statically at HIR-lowering time, so super() fell through
to a plain call of the export -- which AsyncResource throws on by
design.

Recognize the bound export VALUE in js_fetch_or_value_super, exactly as
the existing WASI arm does, and run the same native-backing init the
canonical path already uses.
…per-evaluation identity

A class expression returned from a function (a mixin/factory —
function withCommands(Base) { return class extends Base {}; }) had no
per-evaluation identity when the function lived in a non-entry module:
every call returned the SAME shared-template class object, re-parented
to the most recently passed Base. specialize_captured_class_factories
already fixes this for same-module callers by cloning a distinct class
per call site, but it only ever sees call sites in the SAME module as
the factory -- a caller in another module reaches the factory through
an ordinary cross-module call that pass never visits, so an exported
factory's own template stayed shared and got silently re-parented on
each call.

Give an exported factory real per-evaluation identity directly: when
its body is nothing but the single-statement
return class extends <expr> {} shape, upgrade the class's own
ClassRef to ClassExprFresh, exactly what the same class expression
would already lower to had it needed per-evaluation statics/captures/a
private brand. This closes the gap for every caller, local or
cross-module, without touching the existing same-module
specialization (a locally-cloned call site never calls the factory at
runtime at all, so it is unaffected).
A subclass constructor assigning this.<name> where <name> is a method
inherited from a parent class allocated an own inline field slot for
it, hiding the inherited method from the moment super() returned.
Track own+inherited instance method names per class (mirroring the
existing accessor-name tracking) and consult the union when deciding
whether a constructor-body this.<name> = ... assignment is a new data
field or a method override.
A hoisted var reaches lower_let as two Stmt::Lets sharing one local
id (a body-entry predefine, then the real declaration); the second
takes the #1803 redeclaration early return before ctx.local_types is
updated. proven_local_types (consulted by is_numeric_expr) IS
refreshed on redeclaration, but local_types (consulted by
expr_may_return_boxed_value_from_raw_f64_fallback) was not, so the
two predicates disagreed about the same local: a strict-equality
compare against an out-of-bounds/hole read of a var-declared number
array took the bare-fcmp numeric fast path, which cannot represent
the NaN-boxed undefined tag such a read can produce. Refresh
local_types on the redeclaration path too.
… parent

A subclass with no explicit constructor, extending a capture-bearing
class expression held in a local (const Base = class {...}; const Sub
= class extends Base {...};), never forwarded Base's captured
enclosing-scope locals to the synthesized subclass constructor: the
lowering deliberately drops the static extends_name for such a
lexically-local heritage identifier (avoiding a same-named-class
collision, #5437), and capture propagation was keyed off that same
name. Resolve the heritage identifier through resolve_class_alias
instead - the same table Expr::New's own capture lookup already uses
for let X = class {...}; new X() - for capture forwarding only,
gated to subclasses with no own constructor (an explicit constructor
already forwards captures correctly via a separate mechanism).
…uiltin namespace

Math[k], JSON[k], Object[k] and other builtin namespace/constructor
member reads with a non-literal computed key collapsed to the bare
GlobalGet(0) intrinsic sentinel, so the read landed on the number 0
instead of the real object (Math[key](x) threw "(number).x is not a
function"). #973's value-form reroute wraps these idents as
PropertyGet{GlobalGet(0), name}; member_tail.rs undoes that reroute in
member-object position so the intrinsic call/constant-fold paths for a
STATICALLY-KNOWN member name (Math.max(...)) keep their pre-#973 bare
receiver. That undo is only safe when the member name is known at
lowering time -- outer_static_member is None for a computed
non-literal key, which zeroed out the outer_is_reified_*/
outer_is_inherited_* guards instead of blocking the undo itself.

Add outer_is_dynamic_computed_key to the existing conjunction so any
dynamic key keeps the reified receiver, letting the runtime property
lookup resolve against the real namespace/constructor object. Verified
this needs no console carve-out (console[m](...) already falls back
correctly to the generic dynamic-dispatch path once its receiver
survives). Literal-key paths are untouched by construction -- the flag
only fires for MemberProp::Computed with a non-string-literal key.
…rted sibling by value

An exported function whose body references another exported function BY
VALUE (`x === f`, not just as a call target) was a candidate for the
cross-module function inliner, which bundled a private clone of the
sibling into the destination module under a fresh symbol. Every function
value materializes into a heap closure keyed by its wrapper symbol, so
the clone's `f` and the canonical `f` every importer resolves through
produced two distinct closures -- an in-module identity check silently
disagreed with every importer's own view of the same function.

gather_cross_module_functions now refuses a candidate whose dependency
graph would need to bundle a separately-exported sibling referenced by
value; it falls back to the ordinary cross-module call instead, which
resolves through the shared canonical wrapper. Self-recursion is
unaffected.

Fixes #10554
Object.prototype.toString.call(x) fell through to the generic
[object Object] for URL, URLSearchParams, Headers, Request, Response,
FormData, Blob, File, AbortController, AbortSignal, TextEncoder,
TextDecoder, EventTarget, Event and CustomEvent, and x[Symbol.toStringTag]
read back undefined -- breaking the standard cross-realm type check
utility/HTTP libraries use (axios decides body serialization this way).

Two representations, two gaps: the Web Fetch family and TextEncoder/
TextDecoder are small-integer registry handles with no brand/property
case; URL/URLSearchParams and AbortController/AbortSignal/EventTarget/
Event/CustomEvent are real objects whose instances are never linked to
their .prototype via object_static_prototype, so a property installed
only there would never be reached from an instance.

A new web_builtin_to_string_tag answers both Object.prototype.toString
and x[Symbol.toStringTag] from one place, and a real, correctly-shaped
descriptor is also installed on each constructor's own .prototype for
reflection.

Fixes #10555
…evaluation

instanceof's class-chain walk resolved a dynamic parent purely by the
shared TEMPLATE class_id, so evaluating a heritage-carrying class
expression more than once shadowed an EARLIER evaluation's parent once
a LATER evaluation of the same factory ran. Each per-evaluation class
object already pins its own heritage (js_class_object_pin_parent,
consulted by super() and capture resolution since #9364); instanceof
never consulted it.

Pin the constructing class object onto each new instance too, and give
instanceof a value-aware chain walk that prefers a pinned VALUE at each
hop (falling back to the plain class_id registry once no further
per-evaluation precision is available). Gated behind a monotone latch
armed only when a class object is ever pinned, so the common
never-evaluated-twice case pays a single idle-load check.
Ralph Küpper added 9 commits September 19, 2026 18:05
The wrapped-`let` fold changes what the detector can count, so the recorded
561 and the measured 581 are two different yardsticks. That is what the
script's audited-migration exemption is for, and it is the same situation
as the 1 -> 2 migration. The ratchet is unchanged: it still fails on
finding 582, verified by planting one.

The exemption becomes an explicit AUDITED_MIGRATIONS list naming each
migration and its reason, instead of a hard-coded (1, BASELINE_SCHEMA)
pair. Every unlisted schema change is still rejected, and --self-test now
asserts that 2 -> 4 is refused and that BASELINE_SCHEMA cannot be bumped
without naming its own migration -- otherwise a renumber would exempt
every PR from the ratchet.

581 = 558 + 34 newly visible - 11 false positives. Two of the 34 were
inspected and are genuine unrooted-across-allocation shapes; the other 32
are unaudited exposure surface, not known bugs.
`raw_handle_debt.py --no-raise-vs` compares ceilings strictly per path, so a
pure file move — which the 2000-line cap forces regularly — reads as new debt:
the bare run demands the emptied source's line be deleted, and the merge-base
run then rejects the destination as "was not listed at the merge base", though
the total never moved and the bodies are byte-identical.

A ledger entry may now carry `# moved-from: <path>`. The destination is credited
with what the source actually surrendered between the base and head ledgers, and
nothing else: the total check is untouched, the credit is bounded by a real
reduction in the same diff, two destinations sharing one source drain one pool,
and the annotation goes inert once the move lands.

A malformed entry comment is now a parse failure rather than an ignored comment,
and `--update` carries surviving annotations through (writer split out as
`render_ledger` so the round trip is assertable).

`--self-test` +12 cases: undeclared move still rejected, declared move and a 1+1
three-way split pass, laundering / over-draw / double-spend / stale annotation /
self-reference each rejected by their own diagnostic. No ceiling changed.
…10581)

The fixture raced a `* * * * * *` CronJob against a fixed `Date.now() + 10_000`.
The deadline is a timeout, not a barrier: when it expired first the loop exited
and the fixture printed `false` on two lines expected to read `true`, dropping
`tick 1`/`tick 2` as well — a four-line divergence the harness reports as a
`parity_fail`, i.e. as a miscompile. It is inside pr-gate's gap shards and
absent from gap_snapshot.json, and it already held #10530 out of a train.

The wait now has no deadline, so the printed text is a function of CronJob's
behaviour alone: the ticks arrive, or PERRY_RUN_TIMEOUT kills the run and the
harness classifies that as a crash/timeout rather than a parity mismatch. No
fallback bound — any bound that prints or throws on expiry is the same defect
with a longer fuse, and the old 10s was unreachable anyway because
PERRY_RUN_TIMEOUT is also 10s.

The never-started job now prints `neverTicks === 0` from a real counter instead
of a hardcoded `true`, checked after the barrier. Output bytes unchanged.

Verified with an identical 11s event-loop stall injected into both the old and
new fixtures: under Node 26.5.1 and Perry v0.5.1598 the old one diverges and the
new one is byte-identical to the unstalled oracle. Harness run exits 0 with
journal status `pass`; 8 Node runs gave one distinct output in 1.86-2.04s.
#10711 reports that a function read from an object property silently drops
its own call to a second function passed to it as a parameter — commander's
`_displayError` shape, where `outputError(str, write)` invokes the `writeErr`
it was handed:

    this._outputConfiguration.outputError(
      message, this._outputConfiguration.writeErr);

It does not reproduce. The reporter's own isolated repro prints the expected
text on all three trees that matter — current main (v0.5.1598), the main
commit their branch forks from (8df83f8), and their actual tree (PR #10712
on top of #10699, head 463c4fa) — and real commander 14.0.3 compiled from
source via `perry.compilePackages` matches Node 26.5.1 byte for byte across
the whole output surface the issue names: `--help`, `--version`, missing
required argument, unknown option, unknown command and `program.error()`,
under both the default output configuration and a `configureOutput()`
override. 32 further shapes of the same indirection agree with Node too.

So this adds the regression lock rather than a fix. The shape is worth gating:
#10689 — an inherited property read folding to the constant `undefined` on a
scalar-replaced object — landed one commit before this issue was filed and is
the same family, silent in the same way. The fixture covers the reported form
verbatim plus the method-shorthand, class-field, `configureOutput`-override,
spread, nested-receiver, cross-object-writer and in-loop spellings.

Two of the cases exist to keep the fixture from passing vacuously. One traces
`before` / `typeof write` / `after` around the inner call, so "the outer body
ran and the inner call evaporated" cannot read as a pass. The other omits the
writer entirely and asserts a TypeError: that a missing callee is LOUD is the
property that keeps this bug class from ever presenting as a plausible wrong
answer.

Every writer sinks to stdout because the parity harness merges stdout and
stderr into one compared stream; the stream is incidental to the indirection.

Refs #10711
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Acceptance complete — this is unblocked and out of draft.

Real npm axios with its full 27-package transitive graph, no perry.compilePackages entry: 130 modules, all native, 33.7 MB binary, correct status codes and correct response bodies for both GET and POST, exit 0 on both sides against Node 26.5.1.

One divergence was found and has been fully attributed rather than waved through: response.headers is an empty object under Perry. It is pre-existing and not caused by this PR, and it is now filed as #10744.

The attribution, because "pre-existing" is a claim that deserves evidence:

  1. A dependency-free node:http control does not divergeres.headers["content-type"] is correct on both engines. So it isn't a generic client-transport gap.
  2. The key is absent, not miscased or mistypedObject.keys() empty, Content-Type also undefined.
  3. Not fix(http): client rawHeaders/httpVersion*/complete, 'upgrade' event, request-level createConnection #10668's documented limitation — that gap is rawHeaders' name casing on the array form, and that PR explicitly states res.headers is correct. Verified by reading its body, not inferred from its title.
  4. On pristine main, with the native binding still serving the import, it is worse — verified via the compile log (serving axios from the bundled native binding perry-ext-axios... ignoring your installed node_modules/axios, Found 1 module(s): 1 native). There, status, data and headers are all undefined for both GET and POST, ending in Cannot read properties of undefined (reading 'content-type').

So real-source axios is strictly better than the binding being removed: status and data correct with headers incomplete, versus nothing correct followed by a crash. The removal improves this path rather than regressing it.

Two notes for whoever queues this:

  • The recorded counts have been corrected. This PR previously carried workspace_members 83→82 / externalize 33→32, which was stale. The head now reads 79 / 30 / 44 against main's 80 / 31 / 44. EXPECTED_ROWS stays at 371, which is correct rather than a missed decrement — there are no axios rows in native_result_ledger.tsv on either side.
  • It will need a forward rebase onto whatever main is when it's queued; train 224 bumped Cargo.toml/Cargo.lock underneath it. That is a version-bump conflict only. Note that refactor(stdlib): remove dotenv native binding #10691 and refactor(stdlib): remove uuid native binding #10701 also compute to 79/30/44, so whichever of the three lands second and third must re-derive — the counts are absolute, not deltas.

Ralph Küpper and others added 7 commits September 19, 2026 18:53
…r gate

`scripts/native_result_ledger.py` is red on pristine `main`, blocking the
path-filtered `Native Result Ledger / check` workflow on every PR that
touches `native_table/**` or the ledger itself.

Two independent defects, one hiding the other.

1. Stale row count (bookkeeping). #10658's `net.Socket` surface cluster
   landed in merge train 221 and grew
   `native_table/net_events.rs` from 53 to 58 typed rows. `EXPECTED_ROWS`
   stayed at 371, so the gate failed with
   `expected 371 classified rows, found 376`.

2. Four unclassified providers (the real defect). Those five new rows
   carry four runtime symbols that were never added to
   `native_result_ledger.tsv`, so the table declared a result class the
   provider inventory had no opinion about. An unclassified `result_kind`
   misrepresents to the GC what a native call returns.

The count check runs FIRST and raises, so the classification-coverage
check never executed: the stale constant was acting as a mask. Bumping
the constant alone would have turned the gate green and shipped (2).

Each of the four providers was read, not name-matched. All four return
their `handle: i64` argument unchanged -- a `next_id_or_throw()` registry
id and key into `statics::sockets()`, not a heap address -- which is
exactly `NativeRetKind::HandleId` ("an integer registry id or provider
sentinel"):

  js_ext_net_socket_on                 perry-ext-net/src/handle_exports.rs:65
  js_net_socket_prepend_listener       perry-ext-net/src/lifecycle.rs:1040
  js_net_socket_prepend_once_listener  perry-ext-net/src/lifecycle.rs:1060
  js_net_socket_unpipe                 perry-ext-net/src/pipe.rs:325

`js_ext_net_socket_on` backs two rows (`on` and `addListener` share the
symbol), hence five rows from four symbols. The sibling
`js_net_socket_pipe` returns `f64`/`NR_F64`, which the scanner does not
classify, so it needs no row.

Constants: EXPECTED_ROWS 371 -> 376, EXPECTED_PROVIDERS 322 -> 326.
These describe `main` as it stands at 023dc0b; in-flight
binding-removal PRs that also move `EXPECTED_ROWS` re-derive their own
number at rebase time.
Removes the native uuid binding so `import { v4, parse, stringify, NIL }
from "uuid"` (no perry.compilePackages entry) resolves to the real npm
package compiled from source, per the owner's decision to stop shipping
hand-written Rust reimplementations of npm packages.

The native binding is missing `parse`/`stringify` entirely (a
`parse`/`stringify` roundtrip throws: `bytes` comes back `undefined`),
and `NIL` reads as `undefined` (js_uuid_nil exists in the deleted source
but was never wired into either NativeModSig dispatch table or the API
manifest, so property access on the uuid module namespace fell through
to undefined). v1/v3/v4/v5/validate/version were correct in both.

Per #10678 (duplicate extern "C" exports across perry-ext-*/perry-stdlib
pairs), this binding existed twice: crates/perry-ext-uuid/ (the
governance-tracked binding crate) and crates/perry-stdlib/src/uuid.rs (a
second, independent implementation behind the now-removed bundled-uuid
feature). Both are deleted, along with the 7-entry NativeModSig dispatch
block in native_table/utils_crypto.rs, the well_known_bindings.toml
entry, the "uuid" NATIVE_MODULES entry and its 7 manifest rows, and the
6 Android stub exports.

crypto/random.rs entanglement: crypto.randomUUID()/randomUUID({v7:true})
and nodemailer's message-id generation both call the `uuid` Cargo crate
(uuid::Uuid::new_v4()/now_v7()) directly and unconditionally — neither
site has a cfg(feature) gate. The `uuid` crate dependency in
perry-stdlib/Cargo.toml was therefore never actually optional in
practice even though it was declared `optional = true` behind the
bundled-uuid npm-binding feature; removing that feature without also
dropping `optional = true` would have broken the build the moment
bundled-uuid stopped being enabled. Made `uuid` a required (non-optional)
dependency and retargeted the `ids` feature umbrella to
`["bundled-nanoid"]`. crypto.randomUUID()/randomUUID({v7:true}) and
node:crypto's randomBytes are unaffected — verified below.

Retargeted the one dts-shape regression test that used uuid.v4() as its
zero-arg-module-function fixture (perry-api-manifest's
dts_uuid_v4_has_no_args) to perry/gc.minor(), which is unrelated to any
binding-removal churn.

Built on perrymaster (--profile perry-dev). A real `npm install uuid`
project (no perry.compilePackages entry) exercising v1/v4/v5/v3,
validate/version, a parse/stringify roundtrip, and NIL, diffed against
`node --experimental-strip-types` (Node 26.5.1): byte-for-byte identical,
including the deterministic v5/v3 (name+namespace) values. Confirmed
against a pristine origin/main (8df83f8) baseline build that the same
program crashes there: `NIL: undefined`, `parse instanceof Uint8Array:
false`, then `TypeError: Cannot read properties of undefined (reading
'length')` on the roundtrip — reproducing the reported bug exactly.
crypto.randomUUID() and node:crypto's randomBytes/randomUUID were
re-checked against the fix and still work (both call the `uuid` crate
directly, unaffected by the binding removal).

- cargo build --profile perry-dev -p perry -p perry-runtime-static -p
  perry-stdlib-static: clean; confirmed .a mtimes moved.
- cargo check --workspace --all-targets (host-compatible exclusion set
  via workspace_architecture.py --print-excluded-scope) under
  -D warnings: clean.
- cargo test -p perry-api-manifest: 39+4 passing (after retargeting the
  uuid-fixture test).
- cargo test -p perry-codegen --test manifest_consistency: 5/5 passing.
- cargo test -p perry --bin perry -- well_known: 27/27 passing.
- cargo test -p perry-hir: full suite passing (test_lower_native_module_
  registration uses "uuid" only as synthetic example data for a generic
  register/lookup mechanism — unaffected by the registry removal).
- python3 scripts/binding_governance.py --check: OK (39 extension
  crates).
- node scripts/binding_pins.mjs --check: OK (37 pinned, lock-step
  holds).
- python3 scripts/workspace_architecture.py --check: OK.
- python3 scripts/native_result_ledger.py: OK, 371 rows/322 providers
  unchanged (none of uuid's dispatch rows used a ledger-tracked NR_*
  kind).
- python3 scripts/string_payload_access_inventory.py --write-baseline:
  perry-stdlib inline-offset 40 -> 38 (uuid.rs's own 2 sites).
- Regenerated docs/api/perry.d.ts + docs/src/api/reference.md
  (perry --print-api-manifest) and docs/src/native-libraries/
  governance.md (binding_governance.py --table) from the fixed manifest.
- cargo fmt --all -- --check: clean.
- scripts/run_lint_gates.sh (SKIP_COMPILE_GATES=1): 76 of 77 passed; the
  one failure (Public benchmark evidence freshness) is the pre-existing,
  known-red-on-every-PR gate per this campaign's contract.

- Compile tier of run_lint_gates.sh (known-red on Linux per this
  campaign's contract).
- Full gap suite (host stalls under auto-optimize per contract).
- test-files/test_parity_uuid.ts is already excluded from the parity
  gate (test-parity/known_failures.json, "ci-env": Node's own oracle run
  fails ERR_MODULE_NOT_FOUND because uuid was never added to the repo's
  root package.json/package-lock.json — the same pre-existing gap
  documented for nanoid, #8271). Its `@covers` comment now points at a
  deleted file (crates/perry-stdlib/src/uuid.rs); leaving it untouched,
  matching how the sibling nanoid PR (#10693) left its own equivalent
  parity fixture alone.
- No version bump / CLAUDE.md edit — per this campaign's convention, the
  maintainer bumps at merge time.
- crates/perry-ui-android/src/stdlib_stubs.rs was edited to remove the
  matching 6 js_uuid_* stub exports (following the established pattern
  from the sibling removals) but not build-verified — this host has no
  Android NDK and the package is excluded from the host-compatible
  check scope.
… source

Deletes the perry-ext-axios crate, perry-stdlib's axios.rs shim, the
js_axios_* FFI surface, and every NATIVE_MODULES/manifest/HIR/codegen
special-case that existed only to route the native binding. A plain
`import axios from "axios"` with no perry.compilePackages entry now
resolves and compiles the real npm package (and its transitive deps)
from source instead.

Must not merge before #10673 (agent-base namespace/export= fallback
fix) -- axios's https-proxy-agent dependency needs it to compile.
Renamed the axios-removal changeset fragment now that the PR number is
known, and pointed PENDINGCS-compile-smoke-known.md's dangling
placeholder reference at it.
@proggeramlug
proggeramlug force-pushed the wip/axios-native-binding-removal branch from a397a18 to 041b7ed Compare September 19, 2026 17:09
proggeramlug pushed a commit that referenced this pull request Sep 19, 2026
Renamed the axios-removal changeset fragment now that the PR number is
known, and pointed PENDINGCS-compile-smoke-known.md's dangling
placeholder reference at it.
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Landed in merge train 226 (#10751), released as v0.5.1605 — main is now 91a566c8af.

Closing rather than merging is how trains work here: the four PRs were cherry-picked onto one tree, validated together, and landed under the train's own commit, so GitHub cannot mark this one merged even though your change is on main.

The workspace count triple was re-derived on the assembled tree rather than taken from any PR's recorded value: 78 members / externalize=29 / keep=44. Both #10679 and #10691 correctly recorded 78/29/44 against 053b9ccac4, and whichever landed second would have been wrong — so the number was recomputed here rather than carried.

Validation: all nine cheap gates, cargo check --workspace --all-targets under -D warnings, the release build of all five pinned artifacts, every unit suite, and an 8-area gap sweep with zero unexplained regressions and each area asserted to have run a non-zero number of tests. lint completed its full 6-of-6 compile tier with nothing outside the known-red public-baseline step. Ledger green at 376/326, unrooted_local_shape at 578.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants