Skip to content

Compile a standard Express app: full blocker inventory (codegen emits undefined @perry_closure_* globals → linked-but-crashing binary) #3527

Description

@proggeramlug

Summary

Tracking issue: what it takes to compile a standard Express app with Perry.

I took a textbook Express 5 app, installed real Express (express@5.2.1, 65 packages in the tree, all plain CommonJS/JS), and drove it through perry compile, removing one blocker at a time to surface the entire chain rather than stopping at the first error.

Headline finding (the good news): Express's whole dependency tree is essentially TypeScript-subset-compatible — all 65 packages route to native compilation; none require the (removed) V8 runtime. Modern JS like Express really does compile the same as TS here. With a handful of escape hatches + two tiny dependency shims, Perry emits an 11.6 MB native binary (COMPILE_EXIT=0).

The catch: that binary is a false positive. The compiler tolerates 49 per-module codegen failures and links the broken objects in anyway, so the binary Bus error: 10 on launch with zero output. The real central blocker is a codegen bug, not the JS/TS divide.

Repro

// app.ts
import express from 'express';
const app = express();
app.get('/', (req, res) => res.send('Hello World!'));
app.get('/json', (req, res) => res.json({ message: 'hello', value: 42 }));
app.listen(3000, () => console.log('listening on port 3000'));
npm install express          # express@5.2.1, 65 packages
perry compile app.ts -o app_bin

Tested on main @ bef6c3530, macOS arm64, Apple clang 21.0.0.

The blocker chain (in the order you hit them)

1. Bare import of a JS package → V8-removed gate

With no config, express/index.js (CJS) routes to the removed JS runtime:

Error: JavaScript runtime (V8) support has been removed... pulled in a JS runtime via: express/index.js [express]

2. #497 trust gate

Adding perry.compilePackages: ["express"] then trips:

package 'express' is in compilePackages but not in perry.allow.compilePackages

Fix: also set perry.allow.compilePackages, or PERRY_ALLOW_PERRY_FEATURES=1.

3. Opt-in is not transitive — must enumerate all 65 packages

Even with both flags set for express, all 28+ transitive deps individually route to V8:

still routed to runtime JavaScript because they are not in perry.compilePackages: accepts, body-parser, content-disposition, ... vary

You must list every package in the tree (direct + transitive) in both compilePackages and allow.compilePackages.

4. 🐛 The "*" wildcard is broken for compile-routing

compilePackages: ["*"] + allow.compilePackages: ["*"] passes the trust gate (no #497 error) but the wildcard is not honored by native-compile routingexpress is treated as un-opted-in and immediately routes to V8:

Compile package: *
JS module: express -> .../express/index.js
Error: JavaScript runtime (V8) support has been removed... express/index.js [express]

So "*" is strictly worse than naming "express". This is a standalone bug worth fixing first — it's the natural "compile everything" ergonomic and it silently does nothing for routing.

5. new Function(...) / Function(...) refused (#1677) — eval-family

With the full tree enumerated, the first hard language error:

  • depd index.js:436new Function(...) (builds deprecation wrappers from a code string to preserve arity).
  • function-bind implementation.js:85Function(...). This one is deep and ubiquitous (pulled in by get-intrinsic, which nearly everything depends on).

Both are trivially replaceable with ordinary closures. PERRY_ALLOW_EVAL=1 bypasses but yields non-functional behavior.

6. Unimplemented stdlib surface (#463)

After shimming depd: buffer.hasOwnProperty is not implemented (and likely more behind it). PERRY_ALLOW_UNIMPLEMENTED=1 skips the whole category to surface deeper errors.

7. 🐛 CENTRAL BLOCKER — codegen emits references to undefined @perry_closure_* globals

With eval + unimplemented + features all bypassed and depd/function-bind shimmed, the frontend fully succeeds (Found 129 module(s): 129 native, 0 JavaScript), then codegen produces invalid LLVM IR for 49 modules. Every failure is the same shape:

clang -c failed (status=exit status: 1)
.../perry_llvm_*.ll:9153:44: error: use of undefined value '@perry_closure_node_modules_body_parser_lib_read_js__7'
 9153 |   call void @js_register_function_name(ptr @perry_closure_..._read_js__7, ptr @.str.1, i32 10)

js_register_function_name is handed a @perry_closure_<module>__N global that the module's IR never defines. 49 modules fail this; the build links the surviving objects anyway and reports success → the resulting binary Bus error: 10 immediately.

This is the bug to fix to make Express actually work. Two parts:

  • (a) Codegen must emit (or not reference) the @perry_closure_* symbol consumed by js_register_function_name. Likely a named-function-expression / closure-naming path that registers a name for a closure global that wasn't materialized in this module.
  • (b) A per-module clang -c failure must fail the whole compile, not silently drop the object and link a broken binary. Right now COMPILE_EXIT=0 with 49 codegen failures is actively misleading.

49 failing modules across 30 packages:
body-parser (6), express (lib/application, request, response, utils, view — 5), negotiator (4), qs (3), router (3), body-parser/lib/types (4), mime-types (2), math-intrinsics (2), has-symbols (2), plus call-bind-apply-helpers, call-bound, dunder-proto, ee-first, function-bind, get-intrinsic, get-proto, http-errors, iconv-lite, inherits, object-inspect, on-finished, path-to-regexp, proxy-addr, raw-body, send, serve-static, side-channel, side-channel-list, side-channel-map, side-channel-weakmap, statuses (1 each).

The breadth (core utility packages like get-intrinsic/has-symbols/side-channel) suggests this is one codegen bug hit by a common JS pattern, not 30 separate problems.

8. Non-fatal noise (won't block, but worth tracking)

  • Warning: Could not resolve import 'ejs' — Express's optional view-engine require; harmless for a JSON/text API.
  • Could not resolve import 'supports-color' — debug's optional color dep.
  • Many unknown identifier 'window'/'navigator'/'document'/'localStorage'/'global'/'HTMLElement'/'Atomics'/'Float16Array' warnings — universal-module browser branches that don't execute server-side.
  • Assignment to undeclared variable '$gOPD', creating implicit local (gopd) — likely benign but worth confirming it doesn't change semantics.

What "yes, Express compiles" actually requires

In rough priority:

  1. Fix About README #7(a) — the @perry_closure_* undefined-symbol codegen bug. This is the one that matters. Without it, nothing downstream runs.
  2. Fix About README #7(b) — make per-module clang -c failure abort the build. The silent-link-broken-binary behavior masks About README #7(a) as success.
  3. Fix useEffect + setState panics with RefCell already borrowed on macOS ARM64 #4 — honor "*" in compile-routing (or reject it loudly so it's not a silent no-op).
  4. Tracker: AOT-first eval/new Function strategy — evaporate runtime dynamism at build time #1677 — recognize/auto-rewrite the trivial new Function/Function arity wrappers in depd and function-bind (both are 1-liners replaceable with closures), or document them as known-good shims.
  5. Compile-time error for unimplemented Node / Web APIs #463 — fill the stdlib surface gaps (buffer.hasOwnProperty and whatever's behind it once About README #7 is fixed and real modules execute).
  6. Ergonomics — transitive opt-in: enumerating 65 packages by hand is impractical. Consider auto-including the transitive closure of a named package, once trusted.
  7. Then re-test runtime: app.listen + routing + res.send/res.json. Raw node:http is already well-supported, so the foundation is there — but it's untested for Express until About README #7 lands.

Notes / methodology

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugConfirmed defect or regressiontriagedMaintainer reviewed; type, scope, and next step are clear

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions