Skip to content

[Next.js/dylib] Forced-evacuation App Route arm: stale closure from a holder outside the GC heap #8163

Description

@proggeramlug

Splitting the one unresolved item out of #8082 so the production App Route fixture can land on its own merits. The fixture's normal arms pass end-to-end; its forced-evacuation arms do not, and this is that bug.

Symptom

With the Next 16.3.0 production App Route fixture running under forced evacuation and a seeded GC schedule:

PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 \
PERRY_CONSERVATIVE_STACK_SCAN=off PERRY_GC_SCHEDULE_RATE=1 PERRY_GC_SCHEDULE_SEED=8036

the server serves requests and then fails with TypeError: value is not a function, at roughly copying minor #238.

What it is not (each ruled out by measurement)

  • Not the statepoint lowering. The same application compiled with PERRY_RS4GC=0 (shadow-stack roots) fails identically — 80 TypeErrors in each arm, same seed, providers and workload. So it is neither fix(gc): index stack maps from loaded provider apps #8081's loaded-image stack maps nor compiled-frame root coverage.
  • Not the remembered set. skip_remembering never fires (336/336 cycles report remembering_skipped=false, and in-place promotion never happens), and remembered-set coverage is stable — zero cycles lose dirty pages between collections.
  • Not a holder in the GC heap. PERRY_GC_PROTECT_FROMSPACE_HOLDERS=1 (added in fix(next): pass production App Route dylib gate #8082) sweeps the whole live heap at fault time for any word still decoding to the faulting address and finds none.
  • Not a slot the rewrite pass enumerates. PERRY_GC_VERIFY_EVACUATION=1 never panics, so every registered scanner rewrites correctly.

Taken together: the stale copy is held by something unregistered and outside the GC heap — a runtime side table, an FFI structure, or a frame slot.

Where the fault lands

Under PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=800, the stale value is a 48-byte closure consumed by js_value_typeof inside Next's compiled adapter:

static get(e, t, r) { let n = Reflect.get(e, t, r); return "function" == typeof n ? n.bind(e) : n }

reached through js_proxy_get (the adapter is a Proxy get trap over the wrapped route-module object). js_reflect_get itself is fully rooted, so the pre-move copy lives in whatever supplies that property read.

Suggested starting point

scripts/gc_runtime_root_holders.py audits crates/perry-runtime/src and crates/perry-stdlib/src only — pointed at the ext crates it matches 0 declarations, because its patterns do not parse them. Yet every ext crate that invokes closures (perry-ext-http, -net, -ws, -zlib, -fastify) keeps user closures in handle side tables and registers its scanners by hand, with nothing checking that a scanner reaches every table in its own crate. Extending that audit is the obvious next move.

Reproducing in seconds

Build the providers per tests/release/packages/next-app-route/fixture.sh steps 3–5 into a kept build dir (PERRY_NEXT_ROUTE_KEEP_BUILD=1), then run the provider host directly with the env above plus one node verify.mjs. PERRY_GC_FROMSPACE_SCAN=1 mines holders at the retiring minor — but read its counts carefully: it reports dead old-gen residue and raw payload bytes as offenders (impossible obj_types like 47/95/144), which cost this hunt a retracted diagnosis. The protect fault is the trustworthy signal.

The fixture ships this arm behind PERRY_NEXT_ROUTE_FORCED_GC=1, off by default, so it fails loudly for whoever picks this up rather than sitting green.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions