Skip to content

link: perry-ext-* staticlibs bundle a feature-stripped perry-runtime that wins the link — regex/Temporal silently degrade (blocks Express boot) #6303

Closed

Description

@proggeramlug

Summary

String.prototype.replace(re, fn) invokes the replacer callback zero times for a regex on the fancy-regex fallback path (backreference + lookahead) — but only inside a large module graph. The same code in isolation works. The result is a silent wrong answer ([] instead of the parsed path), not a crash.

This is what currently stops a standard Express app from booting.

Symptom

$ ./app_bin
SyntaxError: intrinsic %% does not exist!
$ curl localhost:3000/     -> status=000  (never listens)

Localization

get-intrinsic's stringToPath uses rePropName, which carries a backreference (\2) and a lookahead — i.e. per CLAUDE.md, perry-runtime/src/regex.rs falls back from the regex crate to fancy-regex for it.

Probe inserted into node_modules/get-intrinsic/index.js:

[P] in="%String.prototype.indexOf%"
[P]   uncurried $replace cb=0 result=[]        <- callback never fires
[P]   direct .replace   cb=0 direct=[]         <- not the uncurry-bind either
[P]   rePropName.exec   ="String" source.len=97 flags=g   <- regex object is INTACT
[P]   FRESH simple regex=["String","prototype","indexOf"] <- replace+callback works generally

So stringToPath returns [] -> intrinsicBaseName = '' -> '%' + '' + '%' -> %% -> throw (get-intrinsic/index.js:294, via :309-311).

The discriminator is sharp:

  • exec on that same regex object works
  • a fresh simple regex with a callback works
  • the identical code in a small graph (35 modules) works
  • in the full 139-module Express graph it returns []

So the suspect is replace-with-callback on the fancy-regex path, going silently empty at graph scale.

Repro

npm i express@5.2.1
# package.json: { "perry": { "compilePackages": ["*"] } }
perry app.ts -o app_bin && ./app_bin

Compile is clean (139 modules: 139 native, 0 JavaScript, zero codegen failures, no escape-hatch env vars).

Split out of #3527 — every bug in that issue's original inventory is now fixed; this is a new, scale-emergent blocker that replaced them.

Activity

  1. proggeramlug commented on Jul 12, 2026

    @proggeramlug
    ContributorAuthor

    Root cause found and fixed in #6313. Two corrections to the framing above, because both mattered for finding it:

    It is not fancy-regex-specific, and it is not scale-emergent.

    A plain /[^.]+/g fails identically, and a 3-module program reproduces it:

    import * as http from 'http';          // <- the actual trigger
    const re = /[^.]+/g;                   // plain regex, no backref, no lookahead
    const holder = { re };                 // read via property => codegen can't type it as RegExp
    console.log('a.b.c'.replace(holder.re, (m) => m.toUpperCase()));
    // node:  A.B.C      perry: a.b.c   (callback fired 0 times)

    The real discriminator is "does the program link a perry-ext-* archive" — Express does (http.createServer), the 35-module graph didn't. rePropName being fancy is a coincidence of it being a module-level var, which is what routes it through the dynamic dispatcher.

    What actually happens

    perry-ext-* crates are staticlibs, so each one bundles the perry-runtime rlib objects into libperry_ext_*.a. The workspace dep is default-features = false, so a per-crate cargo build -p perry-ext-http (what release-packages.yml does in its ext loop) bundles a perry-runtime with regex-engine / temporal compiled out:

    libperry_ext_http.a : perry_runtime-9cc0c5804e3dc717.….rcgu.o   (regex-engine OFF)
    libperry_stdlib.a   : perry_runtime-f959e593f6355055.….rcgu.o   (regex-engine ON)
    

    strip_bundled_runtime_from_well_known_lib should drop those bundled CGUs, but it matches members by name containment against stdlib — a different feature set means a different CGU hash, so the strip silently no-ops. And ext archives are linked before stdlib/runtime (prefer_well_known_before_stdlib), so the stripped copy wins the link.

    The kicker: the dispatchers those features gate are exported unconditionally, with the #[cfg] inside the function body:

    pub extern "C" fn js_string_replace_search_dyn(s, needle, replacement) -> *mut StringHeader {
        #[cfg(feature = "regex-engine")]                    // <- gone in the stripped copy
        if let Some(re) = needle_regex_ptr(needle) { return js_string_replace_regex_dyn(...); }
        js_string_replace_string_dyn(s, js_string_coerce(needle), replacement)   // <- always taken
    }

    So the RegExp got ToString-coerced and searched for literally. No match => callback never fires => replace returns the subject unchanged => stringToPath returns [] => '%' + '' + '%' => %%.

    That also explains the probe results exactly: js_regexp_exec and js_string_replace_regex_fn are #[cfg]-gated, so they don't exist in the stripped copy and always resolve to the real runtime — which is why .exec() and a regex literal at the callsite kept working, and only the runtime-dispatched searchValue shape broke.

    Fix (#6313): every perry-ext-* crate that links perry-runtime now requests its default feature set, so the bundled copy can't be a silently-degraded subset. (regex-engine alone isn't enough — native_call_method.rs has the same in-body gate for temporal.) Express 5.2.1 now boots and curl localhost:3000/ returns 200.

  2. proggeramlug commented on Jul 12, 2026

    @proggeramlug
    ContributorAuthor

    Correction — this issue's framing was wrong on both counts

    Recording this because both wrong premises were load-bearing and would send the next person down a dead end.

    It is NOT fancy-regex-specific, and NOT scale-emergent. Minimal repro is three modules with a plain regex:

    import * as http from 'http';   // <- the actual trigger
    const re = /[^.]+/g;            // plain: no backreference, no lookahead
    const holder = { re };          // via a property, so codegen can't statically type it as RegExp
    'a.b.c'.replace(holder.re, (m) => m.toUpperCase());
    // node:  A.B.C
    // perry: a.b.c   (callback fired 0 times)

    The real discriminator is "does the program link a perry-ext-* archive". Express does (http.createServer). The 35-module control graph didn't. Module count was a coincidence, and the backreference/lookahead was a red herring.

    Actual root cause — a feature-stripped duplicate runtime wins the link

    perry-ext-* crates are staticlibs, so each bundles the perry-runtime rlib objects into libperry_ext_*.a. The workspace dependency is declared default-features = false, so a per-crate cargo build -p perry-ext-http — exactly what release-packages.yml does — bundles a perry-runtime with regex-engine and temporal compiled out.

    strip_bundled_runtime_from_well_known_lib is supposed to remove those CGUs, but it matches members by name containment. A different feature set produces a different CGU hash, so the strip silently no-ops. Ext archives are linked before stdlib/runtime, so the crippled copy wins the link.

    The kicker, and why this is silent rather than a link error: the dispatchers those features gate are exported unconditionally, with the #[cfg] inside the function body. js_string_replace_search_dyn still exists — just with RegExp detection compiled out — so the RegExp got ToString-coerced and searched for literally.

    That also explains every probe in the original report: js_regexp_exec and js_string_replace_regex_fn are #[cfg]-gated, so they don't exist in the stripped copy and always resolve to the real runtime. Which is exactly why .exec() and a regex literal kept working while only the runtime-dispatched shape broke.

    Scope

    The bug class was latent for Temporal too (native_call_method.rs has the same in-body gate). Fix in #6313 makes all ten ext crates that link perry-runtime request its default feature set.

    A second, independent defect of the same duplicate-runtime shape — the ext archive's no-op js_stdlib_init_dispatch stub shadowing perry-stdlib's real one, so node:http servers get no tokio reactor — is filed separately as #6314. #6303 + #6314 together are what a release build needs.

  3. changed the title [-]runtime: String.prototype.replace(re, fn) drops all callback invocations on the fancy-regex path at graph scale (blocks Express boot)[/-] [+]link: perry-ext-* staticlibs bundle a feature-stripped perry-runtime that wins the link — regex/Temporal silently degrade (blocks Express boot)[/+] on Jul 12, 2026
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

    bugConfirmed defect or regression

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions