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

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions