Skip to content

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

Closed

Description

@proggeramlug

Summary

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_SYMBOLS full-bundle cc 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.

Activity

  1. proggeramlug commented on Aug 24, 2026

    @proggeramlug
    ContributorAuthor

    Investigation update: transform layer ruled out; blocking-await mechanism confirmed; one latent gap fixed

    Root-cause mechanism is firm from the symbolicated sample:

    run_microtasks → js_callback_timer_tick
      → [~9× call_async_step_body / async_generator_request]   (the SSE async-gen pull chain)
        → perry_closure __85891
          → js_wait_for_event → async_bridge::run_one_tick → tokio block_on → kevent (parked, 0% CPU)
    

    js_wait_for_event is emitted by exactly one codegen site — expr/fs_await.rs:247, the blocking busy-wait await 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_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 raw Expr::Await survives transform_generators.
    • 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.

  2. proggeramlug commented on Aug 24, 2026

    @proggeramlug
    ContributorAuthor

    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.

    Fix (crates/perry-transform/src/generator/lower/async_step.rs)

    1. 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.
    2. 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/89 perry-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).

  3. added 2 commits that reference this issue on Aug 24, 2026
    850e6f1
    32f0eac
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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