You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
runtime: natively-compiled Claude Code cli.js hangs on -p (streaming API response) — app-specific blocked deadlock; generic fetch/stream/async-gen all work #8681
The natively-compiled Claude Code 2.1.112 bundle (see #8583 — it now builds a working 240 MB cc) runs --version/--help and authenticates against the API, but cc -p "<prompt>"hangs on a successful streaming response: EXIT 124 at 60s, zero response bytes. (With a bad key it returns a fast 401, so request/auth/error handling works — only the successful streaming path hangs.)
It is NOT a generic fetch/stream/async-gen bug
Symbolicated synthetics (PERRY_DEBUG_SYMBOLS=1, --enable-wasm-runtime), run against the real streaming Messages API, all complete cleanly:
raw fetch + getReader().read() loop ✓
for await (chunk of response.body) ✓
async generator that await reader.read() + yields, consumed by for await (the SSE-parser shape) ✓ (n=8, OK)
async-gen over a constructed ReadableStream and over an async-pull stream ✓
pure/setTimeout-delay async-gens ✓
So generic fetch, streaming, and async generators are ruled out. The hang is specific to claude-code's own application code (agent loop / SSE-to-message assembly / config / tool path).
Runtime signature (real, stripped cc)
sample of the hung process: 0% CPU (blocked, not spinning); the event loop is parked in kevent (idle), one worker thread blocks in read, several threads nanosleep-poll — nothing wakes the loop, so whatever the app is awaiting never resumes = deadlock.
Next step
Pinpointing needs a PERRY_DEBUG_SYMBOLSfull-bundlecc rebuild (~1–2 h) so a sample/lldb backtrace names the stuck claude-code function; synthetic narrowing cannot reach it. Found while bringing the cli.js bundle to a working native binary (#8583).
Minor side-finding
perry's fetch to a plain-HTTP localhost server fails with error sending request even though curl to the same server works; HTTPS to the real API works fine. Likely a separate small bug in the HTTP (non-TLS) client path.
js_wait_for_event is emitted by exactly one codegen site — expr/fs_await.rs:247, the blocking busy-waitawait lowering. The async-generator queue and the async-step driver never call it. So __85891 reaching it means it holds a raw Expr::Await that escaped transform_async_to_generator / the generator linearizer. Entered re-entrantly from inside the async-step/microtask cascade, that busy-wait monopolises the single runtime thread (run_one_tick is a current-thread block_on that "must NOT be called from inside a spawned runtime task") and self-deadlocks: __85891 blocks waiting for a value the suspended generator chain above it must produce.
What I verified (fast cargo test -p perry-transform, no LLVM/link)
Added differential tests that sweep every syntactic await position through the real transforms. All pass — the transform layer does not leave a raw await for any shape I can construct:
async_closure_await_in_every_position_is_rewritten — 19 positions (ternary/logical/sequence/switch/try/catch/finally/array/object/call-arg/index/binary/return/throw/if/while/for-of…): every async closure is collected + is_async cleared.
async_generator_closure_expressions_linearize_awaits — same for const g = async function*(){…} closure expressions (the perry_closure path — closest to the crash frame).
One real gap found + fixed (likely latent for -p, correct regardless)
transform_generators records top-level async function* into module.async_generator_funcs (mod.rs:131) but the class-method loops never did — so async *m(){}, static async *m(){}, and async *[Symbol.asyncIterator](){} (the Anthropic SDK Stream shape) were absent. Test async_generator_class_methods_are_recorded_like_top_level reproduces it. Fixed by mirroring the record call in all three method loops.
Honest caveat: the current codegen consumer (artifacts.rs:1659/1667) filters async_generator_funcs by hir.functions + closures only, and transform_generator_function bakes async-gen semantics (promise-wrapped .next(), suspending awaits) into the method body itself — so this record gap is probably not the -p cause. Fixed anyway: it's the correct invariant and future-proofs any method-side consumer.
Where this leaves it
The transform layer is robust for every testable shape, so __85891's raw await comes from a shape these synthetics don't capture (needs the SDK's exact composition + real streaming I/O). Next step requires mapping __85891 back to source via PERRY_SAVE_LL on a matching cc build — currently blocked: the local target/release/perry (0.5.1519) is newer than the checkout (0.5.1512) and emits js_set_process_entry_path, which the current-source runtime lacks, so nothing links; plus heavy build contention. Will resume once a matching toolchain is available.
ROOT CAUSE FOUND + FIXED (verified end-to-end in a clean worktree)
The bug (general, not app-specific): every await inside a catch block of a plain async function/closure compiled to a BLOCKING busy-wait, which self-deadlocks when reached re-entrantly from inside the async-step / async-generator pull cascade (the Anthropic SDK stream error path). Confirmed as the perry_closure __85891 frame.
Mechanism
transform_generators linearizes the catch body into real dispatch states and records CatchRoute.catch_entry_state = Some(..). But the async-step throw handler build_async_catch_route_body_direct (generator/lower/async_step.rs) ignored catch_entry_state and instead inlined a raw clone of the catch body through rewrite_yield_to_await_in_stmts — turning every suspend in the catch back into a raw Expr::Await. Codegen (expr/fs_await.rs) lowers a raw await in a step body as !ctx.is_async_fn: the blocking busy-wait loop (js_wait_for_event + js_unsettled_top_level_await_exit + js_await_loop_tick_timers). __85891's disassembly shows exactly this: 7 such blocking-await sites, alongside js_get_current_step_closure/js_async_step_done (async-step) and js_get_async_iterator (for await).
A rejected awaited promise in a try routes to the catch; if the catch awaits (retry/backoff/cleanup — ubiquitous in stream handling), that inline await blocks the single runtime thread while the async-step driver that would resolve it sits suspended above on the stack → deadlock. Generic streaming works because the happy path never enters such a catch.
build_async_catch_route_body_direct: when route.catch_entry_state.is_some(), route to the linearized catch states (bind the catch param, state = catch_entry_state, fall through to the step's dispatch loop) instead of inlining the blocking copy — exactly what the sync path's build_abrupt_routing does. The linearized catch suspends via AsyncStepChain like any other await. Yield-free catches (nothing to suspend on) keep the legacy inline.
build_async_throw_body_direct: for a linearized route, build the route condition with state_based = true (upper = protected_end_state, which EXCLUDES the catch's own states) so a throw raised inside the catch (catch (e) { await x; throw wrap(e) }) escapes to an enclosing handler instead of re-matching the same route.
Plus a complementary correctness fix in generator/mod.rs: record async-generator class methods (async *[Symbol.asyncIterator]() etc.) into async_generator_funcs like top-level async function*.
Validation
89/89perry-transform unit tests, incl. 5 new differential tests (await-position sweep for closures & generators; residual-await sweep; async-gen-method recording).
13/13 behavioral synthetics compiled with the fixed perry are byte-identical to Node v26 — basic recover, retry-loop with await in catch, multi-await + rethrow, try/catch/finally all awaiting, async-generator await-in-catch consumed by for await, microtask interleaving/ordering, nested try/catch, loop + continue from catch, throw-after-await-in-catch, throw-without-await, generator yields in catch, and .throw() into a running async generator. The ordering case (b1,a1,b2,a2,b3) proves the catch await now cooperatively suspends instead of blocking.
Full cli.js recompile with the fix in progress to confirm __85891 no longer block-waits. Changes isolated to perry-transform (generator/lower/async_step.rs, generator/mod.rs, tests in async_to_generator.rs); built and validated in worktree perry-wt-8681 (perry 0.5.1512, source-matched).
Activity
proggeramlug commentedon Aug 24, 2026
Investigation update: transform layer ruled out; blocking-await mechanism confirmed; one latent gap fixed
Root-cause mechanism is firm from the symbolicated
sample:js_wait_for_eventis emitted by exactly one codegen site —expr/fs_await.rs:247, the blocking busy-waitawaitlowering. The async-generator queue and the async-step driver never call it. So__85891reaching it means it holds a rawExpr::Awaitthat escapedtransform_async_to_generator/ the generator linearizer. Entered re-entrantly from inside the async-step/microtask cascade, that busy-wait monopolises the single runtime thread (run_one_tickis a current-threadblock_onthat "must NOT be called from inside a spawned runtime task") and self-deadlocks:__85891blocks waiting for a value the suspended generator chain above it must produce.What I verified (fast
cargo test -p perry-transform, no LLVM/link)Added differential tests that sweep every syntactic
awaitposition through the real transforms. All pass — the transform layer does not leave a raw await for any shape I can construct:async_closure_await_in_every_position_is_rewritten— 19 positions (ternary/logical/sequence/switch/try/catch/finally/array/object/call-arg/index/binary/return/throw/if/while/for-of…): every async closure is collected +is_asynccleared.async_generator_linearizes_every_await_position— awaits in if/while/for/try/catch/finally, the pi runtime: pi TUI Enter (CR) keypress does not submit under perry — raw-stdin/Kitty keypress decode divergence (real pi interactive blocker) #6728 nested if-in-try-in-loop shape, switch/ternary/logical: no rawExpr::Awaitsurvivestransform_generators.async_generator_closure_expressions_linearize_awaits— same forconst g = async function*(){…}closure expressions (theperry_closurepath — closest to the crash frame).One real gap found + fixed (likely latent for
-p, correct regardless)transform_generatorsrecords top-levelasync function*intomodule.async_generator_funcs(mod.rs:131) but the class-method loops never did — soasync *m(){},static async *m(){}, andasync *[Symbol.asyncIterator](){}(the Anthropic SDKStreamshape) were absent. Testasync_generator_class_methods_are_recorded_like_top_levelreproduces it. Fixed by mirroring the record call in all three method loops.Honest caveat: the current codegen consumer (
artifacts.rs:1659/1667) filtersasync_generator_funcsbyhir.functions+closuresonly, andtransform_generator_functionbakes async-gen semantics (promise-wrapped.next(), suspending awaits) into the method body itself — so this record gap is probably not the-pcause. Fixed anyway: it's the correct invariant and future-proofs any method-side consumer.Where this leaves it
The transform layer is robust for every testable shape, so
__85891's raw await comes from a shape these synthetics don't capture (needs the SDK's exact composition + real streaming I/O). Next step requires mapping__85891back to source viaPERRY_SAVE_LLon a matching cc build — currently blocked: the localtarget/release/perry(0.5.1519) is newer than the checkout (0.5.1512) and emitsjs_set_process_entry_path, which the current-source runtime lacks, so nothing links; plus heavy build contention. Will resume once a matching toolchain is available.proggeramlug commentedon Aug 24, 2026
ROOT CAUSE FOUND + FIXED (verified end-to-end in a clean worktree)
The bug (general, not app-specific): every
awaitinside acatchblock of a plain async function/closure compiled to a BLOCKING busy-wait, which self-deadlocks when reached re-entrantly from inside the async-step / async-generator pull cascade (the Anthropic SDK stream error path). Confirmed as theperry_closure __85891frame.Mechanism
transform_generatorslinearizes the catch body into real dispatch states and recordsCatchRoute.catch_entry_state = Some(..). But the async-step throw handlerbuild_async_catch_route_body_direct(generator/lower/async_step.rs) ignoredcatch_entry_stateand instead inlined a raw clone of the catch body throughrewrite_yield_to_await_in_stmts— turning every suspend in the catch back into a rawExpr::Await. Codegen (expr/fs_await.rs) lowers a raw await in a step body as!ctx.is_async_fn: the blocking busy-wait loop (js_wait_for_event+js_unsettled_top_level_await_exit+js_await_loop_tick_timers).__85891's disassembly shows exactly this: 7 such blocking-await sites, alongsidejs_get_current_step_closure/js_async_step_done(async-step) andjs_get_async_iterator(for await).A rejected awaited promise in a
tryroutes to the catch; if the catch awaits (retry/backoff/cleanup — ubiquitous in stream handling), that inline await blocks the single runtime thread while the async-step driver that would resolve it sits suspended above on the stack → deadlock. Generic streaming works because the happy path never enters such a catch.Fix (
crates/perry-transform/src/generator/lower/async_step.rs)build_async_catch_route_body_direct: whenroute.catch_entry_state.is_some(), route to the linearized catch states (bind the catch param,state = catch_entry_state, fall through to the step's dispatch loop) instead of inlining the blocking copy — exactly what the sync path'sbuild_abrupt_routingdoes. The linearized catch suspends viaAsyncStepChainlike any other await. Yield-free catches (nothing to suspend on) keep the legacy inline.build_async_throw_body_direct: for a linearized route, build the route condition withstate_based = true(upper = protected_end_state, which EXCLUDES the catch's own states) so athrowraised inside the catch (catch (e) { await x; throw wrap(e) }) escapes to an enclosing handler instead of re-matching the same route.Plus a complementary correctness fix in
generator/mod.rs: record async-generator class methods (async *[Symbol.asyncIterator]()etc.) intoasync_generator_funcslike top-levelasync function*.Validation
perry-transformunit tests, incl. 5 new differential tests (await-position sweep for closures & generators; residual-await sweep; async-gen-method recording).awaitin catch, multi-await + rethrow, try/catch/finally all awaiting, async-generator await-in-catch consumed byfor await, microtask interleaving/ordering, nested try/catch, loop +continuefrom catch, throw-after-await-in-catch, throw-without-await, generatoryields in catch, and.throw()into a running async generator. The ordering case (b1,a1,b2,a2,b3) proves the catch await now cooperatively suspends instead of blocking.Full
cli.jsrecompile with the fix in progress to confirm__85891no longer block-waits. Changes isolated toperry-transform(generator/lower/async_step.rs,generator/mod.rs, tests inasync_to_generator.rs); built and validated in worktreeperry-wt-8681(perry 0.5.1512, source-matched).fix(async): linearize `await` inside `catch` for async fns/closures (P…
fix(async): linearize await inside catch; complete the RegExp test262…
fix(async): linearize await inside an async-generator finally; fix al…