Skip to content

[CORO_UCONTEXT] Node 24 / V8 13 compatibility (ucontext coroutines; closed, superseded by #5) - #2

Closed
jbaczuk-qualia wants to merge 4 commits into
async-resourcefrom
node24-compat
Closed

jbaczuk-qualia wants to merge 4 commits into
async-resourcefrom
node24-compat

Conversation

@jbaczuk-qualia

@jbaczuk-qualia jbaczuk-qualia commented Sep 15, 2026 •

Copy link
Copy Markdown

Summary

Makes fibers build and run on qualialabs/node#custom-v24 (Node 24.21.0, V8 13.6). Four commits:

  • e0fbe77 compile against V8 13 and return the fiber's result from run(). SetAccessor -> SetNativeDataProperty, Holder() -> This(), kFinalizer weak callbacks -> kParameter. PropertyCallbackInfo is now stored inline, so the by-value uni::Return() helper silently dropped every getter result; it takes a const reference now. fibers_async.js returns the wrapped function's value so run() resolves to it (README semantics).
  • a7f5750 ucontext coroutines on Linux and arm64 instead of pthreads. Same binding.gyp change as build: use ucontext coroutines instead of pthreads on Linux and arm64 #3. CORO_PTHREAD builds and passes the fibers suite (16/19) and GC stress on the patched node 24, so V8 13 itself does not rule it out (an earlier version of this description said it did; that was wrong). What does rule it out is node 24: GDC with pthread fibers dies on its first HTTPS request because crypto_context.cc keeps the root cert store in thread_local statics and a fiber thread re-registers its cleanup hook (CleanupQueue::Add CHECK). ucontext avoids the whole class, costs ~0.7 µs per switch instead of ~13 µs plus an OS thread per fiber, and passes more of the suite (18/19 vs 16/19).
  • a692f79 find V8's ThreadId TLS key on V8 >= 12 and report the running stack to V8. The legacy TLS-key scan finds nothing on V8 >= 12, so Fiber silently ran without swapping the thread id. New scan: snapshot pthread TLS from a helper thread that has entered the isolate and pick the key whose value is a small monotonically assigned id. FIBERS_DEBUG_TLS=1 prints what it found. On every switch fibers now calls v8_qualia_set_thread_stack_start() (resolved with dlsym, no-op when absent) so cppgc's conservative stack scan walks the fiber stack instead of the OS thread's.
  • 2fb6179 make the key discovery tolerate concurrent thread creation and fail loudly. The two-snapshot diff flaked about 1 run in 80 when another thread was assigned a ThreadId between the snapshots (vb == va + 1 no longer held) and died on an assert. It now retries up to 8 times, accepts vb - va <= 64, requires exactly one candidate key, and aborts with a message naming the problem instead of an assert. 150/150 process starts clean afterwards.

Verification

Not published yet: the monorepo experiment vendors npm pack of this branch as fibers-5.0.5-node24.tgz. 5.0.5 is already published, so this needs a new version (5.0.6 is taken by #3's bump; use 5.1.0 or rebase onto #3). Related PRs: qualialabs/node#4, qualialabs/node-builder#16, qualialabs/meteor-lite#41, qualialabs/qualia#56265 and #56266.

🤖 Generated with Claude Code

jbaczuk-qualia and others added 3 commits September 15, 2026 14:47
…om run()

- SetAccessor -> SetNativeDataProperty, Holder() -> This() (removed in V8 13)
- uni::Return takes PropertyCallbackInfo by const reference: since V8 13 the
  argument slots live inline in the struct, so a by-value copy lost every
  GetReturnValue().Set() and all accessors read as undefined
- fibers_async.js: return fn(...args) from runInAsyncScope so fiber.run()
  returns the function's result when the fiber finishes

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
V8 13 keeps GC state such as the marking write barrier in compiler
thread_local storage that is not copied to coroutine threads, so the
CORO_PTHREAD build corrupts the heap under node 24. Same-thread coroutines
(upstream's default, and the async-resource-threadless branch) see all of it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ack to V8

find_thread_id_key located V8's isolate, thread-data and thread-id pthread
keys by value. V8 >= 12 keeps the first two in thread_local storage, so the
scan silently found nothing (its asserts are compiled out) and coroutines
stopped getting their own V8 ThreadId, which the Locker/Unlocker archiving of
JS stacks relies on. Fall back to comparing two helper-thread snapshots: the
ThreadId slot is the small int that increments between them. The isolate and
thread-data slots are maintained by Locker/Unlocker themselves and are only
swapped when found. FIBERS_DEBUG_TLS=1 prints what was detected.

If the node binary exports v8_qualia_set_thread_stack_start (Qualia's node
build), call it on every coroutine switch with the target stack's start so
cppgc's conservative stack scan, stack limits and IsOnStack() see the
coroutine stack instead of the OS thread's.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…on and fail loudly

The V8 >= 13 fallback compared two helper-thread TLS snapshots and required the ThreadId
slot to read exactly n and n + 1. V8 creates platform worker threads lazily, so a thread
created between the two snapshots consumes an id and the match fails; observed about once
per 80 loads on node 24. The comparison now accepts a gap of up to 64, requires the
matching key to be unique, and retries with a fresh snapshot pair up to eight times
(150/150 loads afterwards).

A failed discovery is now a fatal error with a message instead of an assert: the assert is
compiled out of Release builds, and without the key every coroutine shares the OS thread's
V8 ThreadId, so Locker/Unlocker archiving corrupts JS stacks intermittently later.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@jbaczuk-qualia

Copy link
Copy Markdown
Author

Closed 2026-09-21, kept for posterity. This is the CORO_UCONTEXT port. We decided to upgrade to node 24 on the CORO_PTHREAD backend that production runs today, with the minimum number of changes: #5 carries only the backend-independent commits from here (e0fbe77 V8 13 compile fixes, the #4 orphan-fiber fix) plus two pthread fixes, and needs no V8 patches. The parts unique to this branch are the reasons we are not shipping it: the V8 ThreadId TLS key is found by snapshotting pthread TLS from helper threads and diffing values (a692f79, hardened in 2fb6179 after it flaked 1 in 80 starts), and a wrong guess swaps the wrong TLS slot on every coroutine switch, which corrupts V8 state silently; v8_qualia_set_thread_stack_start has to be called on every switch to keep cppgc from scanning the wrong stack; and both depend on three V8 GC patches in qualialabs/node#4 that have to be re-audited on every node release. Measurements and the full comparison stay in the write-up (gist da31bb93ceb6f57a66f1b47c1415dee7).

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.

1 participant