Skip to content

Spawned async tasks starve after recompile with fresh auto-optimize archives (wrapper-bundled perry-runtime copy splits event-pump state) #5920

Description

@proggeramlug

Symptom

A program that spawns background async work (e.g. a fire-and-forget fetch().then(...)) and returns to the event loop hangs forever on the second compile — the spawned task never runs. The first compile of the same source works.

import http from "node:http";           // pulls the perry-ext-http wrapper into the link
console.log("http keys:", typeof http.STATUS_CODES);

let done = false;
fetch("https://example.com/").then((r) => { done = true; console.log("FETCH DONE", r.status); });

let n = 0;
const iv = setInterval(() => {
  n++;
  console.log("tick", n, "done=", done);
  if (done || n >= 12) { clearInterval(iv); console.log(done ? "PASS" : "FAIL: fetch starved"); process.exit(done ? 0 : 1); }
}, 1000);
  • compile Support custom menu bar items #1 (auto-optimize does the cargo rebuild): binary prints FETCH DONEPASS.
  • compile linux compilation and README #2 (auto-optimize "archives fresh" fast path): binary ticks 12× with done= falseFAIL: fetch starved. Timers fire; only spawned tasks starve. sample shows the main thread parked in js_wait_for_event's condvar fallback (the branch taken when no wait-driver is registered).

Mechanism

The archives-fresh fast path returns prefer_well_known_before_stdlib: true (optimized_libs/driver.rs), so the well-known wrapper archives are linked before libperry_stdlib.a. The full-rebuild path returns false. That flips the link between compiles of identical source.

libperry_ext_http.a bundles its whole Rust dep graph — including a complete copy of the perry_runtime codegen unit. In the wrappers-first shape, that copy is the first definition of every extern runtime symbol, so the user object's calls (js_wait_for_event, js_promise_run_microtasks, …) bind to the wrapper's runtime copy. ld64 reports it:

ld: warning: duplicate symbol '_js_atomics_exchange' in:
    .../_libperry_ext_http.a_trimmed.lib[12](perry_runtime-<h>.perry_runtime.<h>-cgu.0.rcgu.o)
    .../libperry_stdlib.a[183](perry_stdlib-<h>.perry_runtime-<h>.perry_runtime.<h>-cgu.0.rcgu.o.rcgu.o)

(~3,900 such warnings in a large app's link.)

Meanwhile perry-stdlib's own code does not go through those extern symbols: its staticlib LTO internalized the runtime crate and promoted the internals it uses to hidden .llvm.-suffixed names, resolvable only by stdlib's own bundled runtime member:

$ nm -A libperry_stdlib.a | grep 'U .*llvm'
...perry_stdlib...cgu.o: U __ZN13perry_runtime10event_pump17WAIT_DRIVER_SLEEP17h...E.0.llvm.12589...
...perry_stdlib...cgu.o: U __ZN13perry_runtime10event_pump8NOTIFIED17h...E.llvm.12589...
...perry_stdlib...cgu.o: U __ZN13perry_runtime7promise10microtasks14run_microtasks17h...E.llvm.12589...
...perry_stdlib...cgu.o: U __ZN13perry_runtime9exception15EXCEPTION_STATE...llvm.12589...

So the process runs two disjoint copies of the runtime's mutable globals: two event-pump wait-driver slots, two NOTIFIED condvar states, two microtask queues, two exception states. The async bridge's spawn()ensure_pump_registered()js_register_wait_driver stores the driver in stdlib's copy; the main loop's js_wait_for_event — resolved from the wrapper's copy — reads a never-written slot, falls back to the condvar park, and never drives the current-thread runtime. Every spawned task starves. (block_on-style awaits still work, which is why simple awaited-fetch programs don't reproduce.)

Related: #5892 / #5911 fixed the cargo-test flavor of this family (test-only perry_ffi_* shims shipping in ext archives). This one is the perry-compile flavor — not shims, the entire bundled runtime cgu — and still fires with #5911's gate in place.

Fix direction

In the wrappers-before-stdlib link shape, drop the wrapper's bundled perry_runtime-* member(s) when it is safe, so stdlib's copy is the single provider (evidence-based per the v0.5.331 dedup standard):

  1. the stdlib archive bundles the same codegen unit (stdlib's packaging renames members to perry_stdlib-<h>.<original-name>.rcgu.o, so match by name containment — same crate+cgu hash ⇒ same rlib input);
  2. every symbol the member defines that a sibling member references is also defined by stdlib (a sibling depending on one of the copy's .llvm. internals keeps the member — fail-safe).

The standalone libperry_runtime.a linked after stdlib keeps filling DCE gaps as today. With this, the repro's second compile logs [strip-dedup] libperry_ext_http.a: dropped 1 bundled perry-runtime member(s), duplicate-symbol warnings drop ~4×, and the program passes in both link shapes.

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