Skip to content

fix(link): refuse a link whose ext wrappers bundle a different tokio (#7629) - #7999

Merged
proggeramlug merged 7 commits into
mainfrom
fix/7629-shared-tokio-unification
Aug 13, 2026
Merged

fix(link): refuse a link whose ext wrappers bundle a different tokio (#7629)#7999
proggeramlug merged 7 commits into
mainfrom
fix/7629-shared-tokio-unification

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Closes #7629. Rewrites the FATAL text tracked by #7990 (does not close it — see below).

#7629 — the six aborting gap tests are one build-graph defect

test_gap_fetch_request_from_node_incoming_message, test_gap_http_client_no_redirect_follow,
test_gap_http_overloads_3226plus, test_gap_http_req_async_iterator,
test_gap_http_res_socket_writable_onfinished and test_gap_net_connect_bound_value
die with SIGABRT (exit 134) — CRASH, not FAIL — after a Rust panic on a worker thread:

thread '<unnamed>' panicked at crates/perry-ext-http/src/server/server.rs:911:13:
there is no reactor running, must be called from the context of a Tokio 1.x runtime

Five panic at perry-ext-http's tokio::spawn; net_connect_bound_value panics one frame
lower, inside tokio's net/tcp/listener.rs (perry-ext-net's TcpListener::bind
PollEvented::newHandle::current()). The listener.rs:304 path does NOT need a
separate fix
— the differing frame is only where each wrapper first touched the reactor.
Both were reproduced here and both are explained by the same cause.

Root cause

perry-ext-* wrappers are staticlibs, so each bundles its own copy of tokio;
libperry_stdlib.a bundles one too, and perry-stdlib owns the process's only runtime.
tokio's runtime::context::CONTEXT is a thread_local!, so its symbol carries the
compiling crate instance's metadata hash — two tokio compilations in one binary are two
independent contexts
. perry-stdlib's runtime enters one, the wrapper reads the other,
finds it empty, and under panic = "abort" the process dies.

This is not a new discovery; it is a documented invariant that nothing checked.
optimized_libs/driver.rs states the failure verbatim (#507) and prevents it on the
auto-optimize path by rebuilding every tokio-using wrapper in the same cargo invocation
as perry-stdlib-static. Every path that bypassed that rebuild produced a binary that linked
cleanly and aborted at its first socket, with the cause three stages upstream of the symptom.

Measured on 55fd197d5, reading the compilation id straight out of the archive member
names (ar t <archive> | grep -o 'tokio-[0-9a-f]*'):

build libperry_stdlib.a libperry_ext_http.a libperry_ext_net.a result
auto-optimize tokio-692c8788… tokio-692c8788… PASS 3/3
PERRY_NO_AUTO_OPTIMIZE=1 tokio-5aeb6213… tokio-01c4c58f… tokio-59c9ffcf… exit 134, 3/3
one invocation, all packages tokio-5aeb6213… tokio-5aeb6213… tokio-5aeb6213… PASS 3/3

Three different tokios in the middle row — one per cargo build -p <one crate>, because
cargo resolves feature unification per invocation.

Which paths violated the invariant

  1. optimized_libs/no_auto.rs::build_missing_prebuilt_ext_lib — literally
    cargo build --release -p perry-ext-http, reached whenever PERRY_NO_AUTO_OPTIMIZE=1
    and the archive is not on disk. This produced both witnesses here.
  2. run_parity_tests.sh's node-suite net step — a second
    cargo build --release -p perry-ext-net -j1 after the main build.
  3. Any hand-run cargo build -p perry-ext-http before a PERRY_SKIP_BUILD=1 gap run —
    which is the fast path agents use, and PERRY_SKIP_BUILD=1 exports
    PERRY_NO_AUTO_OPTIMIZE=1 and then builds nothing.

Why CI stayed green while the gap suite was red

The 8 conformance-smoke gap shards run on ubuntu-latest and build every archive in one
cargo build, so the invariant held there by accident. The failure is reachable only from
a build configuration CI does not use.

The fix

A link-time check that can fail. New crates/perry/src/commands/compile/shared_tokio.rs
reads each archive's tokio-<hash> compilation id out of the ar member names and refuses
a link whose wrappers disagree with the stdlib archive, naming both ids and the single
cargo build that fixes it. The container is parsed in-process rather than through
llvm-ar, so the check cannot silently stop gating when a tool is absent. Only wrappers
where binding_needs_shared_tokio holds are checked — the same predicate the #507 rebuild
uses, so the check and the fix cannot drift apart. SharedTokioReport::compared_anything()
makes "compared nothing" distinguishable from "found no mismatch", and a unit test asserts
an empty report is not read as a pass.

Warn where the mismatch is manufactured. build_missing_prebuilt_ext_lib now says what
it is about to do and why it usually ends badly, then builds anyway and lets the link check
decide. It deliberately does not refuse: refusing there is a prediction, and two cargo
invocations can unify to the same tokio — those links work, and a check that reads the
actual archives should not fail them. It cannot repair the situation either, because
building the wrapper with perry-stdlib-static would overwrite the prebuilt stdlib with
this invocation's feature set and drop the external-*-pump features, trading an abort for
a hang.

Make the harness's own builds coherent. run_parity_tests.sh folds -p perry-ext-net
into BUILD_PACKAGES, and under PERRY_SKIP_BUILD=1 verifies the required ext archives are
present in PERRY_RUNTIME_DIR before running anything, with the exact command.

A second, independent defect this uncovered

With coherent tokio the no-auto gap path still failed — now at link, with five undefined
_js_ext_zlib_*. The external-*-pump features are a property of the ONE prebuilt stdlib
while ext-archive selection is per-import: a stdlib built with external-zlib-pump
references js_ext_zlib_process_pending unconditionally and cannot link a test that does
not import node:zlib; one built without the pumps links, but never drains the wrapper's
queues. No subset of pump features serves a mixed corpus, so the "build the ext packages
too" compensation this script documented had never actually worked.

The first attempt was PERRY_FORCE_WELL_KNOWN=events,http,net,ws,zlib — the in-tree
mechanism for unioning modules into well_known_iteration_set regardless of imports. It
works, and it is 17x too slow to keep. Measured on one trivial gap test, same host, back to
back:

compile
no-auto, no force 2.2 s
no-auto + PERRY_FORCE_WELL_KNOWN 37.7 s

(five extra archives, 193 MB, through strip-dedup on every link — ~30 min of gap suite
becomes ~5 h, which defeats the point of PERRY_SKIP_BUILD=1).

What landed instead: the 23 of 554 gap tests that import an ext-routed module drop
PERRY_NO_AUTO_OPTIMIZE for their own compile; the other 531 keep the 2.2 s prebuilt path.
The detector matches both spellings, both quote styles and require(...), because
test_gap_net_connect_bound_value reaches net only through createRequire. Scoped to the
all suite: node-suite selects one module at a time, so its prebuilt stdlib and its ext
archives already agree.

#7990 — the FATAL that pointed at the wrong crate

#7645's [gc-pin-latch] abort asserted "some site sets GC_FLAG_PINNED without going
through gc::pin_object" and told the reader to run scripts/gc_pin_sites.py. That tool
reports OK, and both of its allowlisted exceptions are test-only — so the message sent
every reader at a hypothesis its own remedy refutes.

The message now decodes the flags byte by name, prints a header-coherence verdict
computed at the instant of the abort, explains that GC_FLAG_TENURED on a nursery-resident
object is ordinary (the non-moving generational path tenures in place), and ranks five
candidates by that evidence with the pin-site scan last.

The verdict is load-bearing, not decoration. GC_FLAG_INTERNED is written in exactly one
file (string/intern.rs) and only on GC_TYPE_STRING, so #7990's reported header
(obj_type=8 = Map, flags=0x37 including INTERNED) is not a coherent Map — it reads
as memory that once held an interned string, which points at the #7154 unrooted-slot class
rather than at pin bookkeeping, and explains the ~1-in-16 rate. The message now says so and
points at PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=800.

#7990 is not closed by this PR. The underlying fault is still open; this makes the abort
point at the right investigation. No CI gate was added for it: at ~6% of runs a gate would
go red on a healthy tree often enough to be ignored — the same reasoning that declined to
gate #7803's 19%.

Validation

Gap-suite result

PERRY_SKIP_BUILD=1 ./run_parity_tests.sh --filter test_gap_ on macOS arm64, archives built
with the harness's own recipe, 58 minutes:

Parity Pass:  538      Parity Fail: 15      Compile Fail: 1      Crashed: 0

Crashed: 0 — all six #7629 witnesses pass, plus test_gap_events_import_4995.

Against the committed Linux snapshot (15 entries): 14 reproduce,
test_gap_iterator_helpers_2874 passes here, and two failures are not in it — neither
caused by this change
:

  • test_gap_specabi_reassign — an output mismatch (plain: 99 101 2 vs 0 0 2). A
    spec-ABI codegen defect; nothing here can alter program output.
  • test_gap_zlib_4917_leveljs_zlib_deflate_raw_sync / js_zlib_inflate_raw_sync exist
    only in perry-stdlib/src/zlib.rs; perry-ext-zlib does not define them, and the
    auto-optimize flip strips compression-gzip from the stdlib when it routes node:zlib to
    the wrapper. Verified both ways on this tree: the no-auto path links it (exit 0), the
    auto-optimize path does not — so it is red under the DEFAULT scripts/run_gap_tests.sh on
    main today, and is likewise not in the snapshot. Deliberately not routed around
    here: dropping zlib from the ext-routed set would hide a real API gap to make a number
    green.

(There is no test-parity/gap_snapshot.macos.json in the tree, so no macOS gap baseline has
ever been recorded; the comparison is against the Linux one.)

Summary by CodeRabbit

  • Bug Fixes

    • Added build-time checks to detect incompatible runtime components before linking, with actionable error messages.
    • Improved warnings for configurations that may cause runtime failures.
    • Enhanced garbage-collection diagnostics for pinned object relocation issues, including flag details and header consistency checks.
  • Tests

    • Improved parity-test setup to build and validate required components consistently.
    • Added coverage for archive compatibility and garbage-collection diagnostic edge cases.
  • Documentation

    • Added troubleshooting guidance for runtime compatibility and GC diagnostic investigations.

…7629)

Six gap tests SIGABRTed with "there is no reactor running" because
`libperry_ext_http.a` / `libperry_ext_net.a` bundled a different tokio
compilation than `libperry_stdlib.a`. tokio's runtime context is a
`thread_local!` mangled with the compiling crate instance's hash, so two
compilations are two contexts: perry-stdlib's runtime enters one, the wrapper
reads the other, and under panic=abort the process dies at its first socket.

#507's auto-optimize rebuild already prevents this by folding every tokio-using
wrapper into the same cargo invocation as perry-stdlib-static, but nothing
checked the invariant, so every path that bypassed that rebuild produced a
binary that linked cleanly and aborted.

- new `compile/shared_tokio.rs` reads each archive's tokio compilation id out
  of its `ar` member names (parsed in-process, no llvm-ar dependency) and
  refuses a mismatched pair, naming both ids and the command that fixes it
- `optimized_libs/no_auto.rs` stops building a tokio-using wrapper alone under
  PERRY_NO_AUTO_OPTIMIZE, which is what manufactured the split
- run_parity_tests.sh folds `-p perry-ext-net` into the main build (it was a
  second invocation), verifies the ext archives under PERRY_SKIP_BUILD=1, and
  forces the well-known set so the all-pumps stdlib actually links

Also rewrites the `[gc-pin-latch]` FATAL, which asserted a cause its own tool
refutes (#7990): it now prints a header-coherence verdict and ranks candidates
by that evidence, with the pin-site scan last.
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: cd8fe0af-977d-46f3-8850-ba547e916a5e

📥 Commits

Reviewing files that changed from the base of the PR and between 667b028 and 8ec3800.

📒 Files selected for processing (9)
  • changelog.d/7629-shared-tokio-unification.md
  • crates/perry-runtime/src/gc/copying.rs
  • crates/perry-runtime/src/gc/pin.rs
  • crates/perry/src/commands/compile.rs
  • crates/perry/src/commands/compile/optimized_libs/no_auto.rs
  • crates/perry/src/commands/compile/run_pipeline.rs
  • crates/perry/src/commands/compile/shared_tokio.rs
  • gc-handoff/REACTOR-NOTES.md
  • run_parity_tests.sh

📝 Walkthrough

Walkthrough

The PR adds archive-level shared-Tokio validation to compiler and parity-test workflows. It also improves pinned-young GC failure diagnostics with header-coherence analysis, decoded flags, ordered investigation candidates, and unit tests.

Changes

Shared Tokio archive coherence

Layer / File(s) Summary
Archive inspection and mismatch reporting
crates/perry/src/commands/compile/shared_tokio.rs, gc-handoff/REACTOR-NOTES.md, changelog.d/7629-shared-tokio-unification.md
The compiler parses Unix and Windows archives, extracts Tokio compilation IDs, identifies relevant wrappers, compares archives, and reports actionable mismatches.
Compiler validation and no-auto warning
crates/perry/src/commands/compile.rs, crates/perry/src/commands/compile/run_pipeline.rs, crates/perry/src/commands/compile/optimized_libs/no_auto.rs
The compile pipeline checks Tokio coherence before linking and warns when no-auto mode builds a wrapper independently.
Parity archive construction and validation
run_parity_tests.sh, gc-handoff/REACTOR-NOTES.md, changelog.d/7629-shared-tokio-unification.md
The parity runner builds required extensions in one Cargo invocation, validates prebuilt archives, adjusts ext-routed tests, and records validation results.

Pinned young-object diagnostics

Layer / File(s) Summary
Header analysis and fatal reporting
crates/perry-runtime/src/gc/pin.rs, crates/perry-runtime/src/gc/copying.rs, gc-handoff/REACTOR-NOTES.md, changelog.d/7629-shared-tokio-unification.md
GC diagnostics validate object headers, decode flags, classify coherent and incoherent states, explain tenured objects, order investigation candidates, and test the resulting reports.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ParityRunner
  participant CargoBuild
  participant CompilePipeline
  participant SharedTokioVerifier
  participant Archives
  ParityRunner->>CargoBuild: build runtime, stdlib, and required extensions
  ParityRunner->>CompilePipeline: compile ext-routed test
  CompilePipeline->>SharedTokioVerifier: verify_shared_tokio(stdlib, wrappers)
  SharedTokioVerifier->>Archives: read Tokio compilation IDs
  Archives-->>SharedTokioVerifier: archive identities
  SharedTokioVerifier-->>CompilePipeline: coherent report or mismatch error
  CompilePipeline-->>ParityRunner: linked test or actionable failure
Loading

Possibly related PRs

  • PerryTS/perry#6629: Both PRs address duplicate static-library runtime contents through compile or link-time handling.
  • PerryTS/perry#6891: Both PRs modify parity external-wrapper builds and PERRY_NO_AUTO_OPTIMIZE behavior.
  • PerryTS/perry#7650: Both PRs modify GC pinning diagnostics in pin.rs and copying.rs.

Suggested labels: bug, tooling, parity

Suggested reviewers: jdalton

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/7629-shared-tokio-unification

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Ralph Küpper added 3 commits August 13, 2026 00:27
One prebuilt stdlib cannot serve the mixed gap corpus: the `external-*-pump`
features are a property of that single archive while ext-archive selection is
per-import, so a stdlib built with `external-zlib-pump` fails to link every
test that does not import node:zlib, and one built without the pumps links but
never drains the wrapper's queues. There is no subset that satisfies both.

Forcing every wrapper archive onto every link (PERRY_FORCE_WELL_KNOWN) does
work but costs 2.2s -> 37.7s per compile, measured — 17x, which is the whole
point of PERRY_SKIP_BUILD. Instead, the 23 of 554 gap tests that import an
ext-routed module drop PERRY_NO_AUTO_OPTIMIZE for their own compile; the other
531 keep the fast prebuilt path.
The no-auto path warned-and-refused when a tokio-using wrapper archive was
missing. Refusing there is a prediction: two cargo invocations CAN unify to the
same tokio, and those links work. Warn and build instead, and let the link-time
check — which compares the tokio compilation ids in the archives it is about to
link — be the thing that fails. Evidence over heuristic, and it cannot fail a
build that would have worked.
@proggeramlug
proggeramlug merged commit df2c428 into main Aug 13, 2026
0 of 18 checks passed
@proggeramlug
proggeramlug deleted the fix/7629-shared-tokio-unification branch August 13, 2026 00:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

test_gap_fetch_request_from_node_incoming_message SIGABRTs deterministically on pristine main, and is in no allowlist

1 participant