Skip to content

fix(#6728): pi interactive mode renders nothing after Enter — 3 async/codegen fixes#6740

Merged
proggeramlug merged 5 commits into
PerryTS:mainfrom
proggeramlug:fix/6728-pi-interactive
Jul 21, 2026
Merged

fix(#6728): pi interactive mode renders nothing after Enter — 3 async/codegen fixes#6740
proggeramlug merged 5 commits into
PerryTS:mainfrom
proggeramlug:fix/6728-pi-interactive

Conversation

@proggeramlug

Copy link
Copy Markdown
Contributor

Fixes #6728.

The blocker

pi's interactive TUI mode renders nothing after the user types a prompt and
presses Enter — no "Working…" spinner, no assistant text, not even the 401
error that one-shot -p mode renders correctly. The process does not crash.
Node runs the identical bundle and shows "Working…" then the error. This PR
fixes it: after these changes the perry-compiled pi renders end-to-end
(bogus key → "Working…" → Error: 401 authentication_error, identical to Node;
a real key streams a real API response).

pi's turn UI is an EventStream (async *[Symbol.asyncIterator]() push/pull
iterator) whose events are dispatched through a class-field async arrow
(_handleAgentEvent). Reproducing what pi actually does turned up three
independent, pre-existing bugs
— all on the async / async-generator lowering
path that #6709 (#6727, now merged) opened up. Each is fixed and covered by a
regression test below.

1. Async-generator await inside a control-flow construct never suspends (completes #6709)

crates/perry-transform/src/generator/break_continue.rs

#6709 made statement-level awaits in an async function* suspend on the
microtask queue. But the linearizer only splits a control-flow construct
(if / loop / try) into suspend states when body_contains_yield sees a
yield inside it — it did not count a nested await. So an await inside an
if (with no yield) in an async generator fell back to a busy-wait and
deadlocked — exactly pi's EventStream, which awaits inside conditionals.

Fix: in an async generator (linearize_async_generator()), body_contains_yield
also treats a statement-level await (Expr / Let init / Return / Throw)
as a suspend point, so the enclosing construct is split into states.

2. A throw after ≥2 awaits leaves an orphaned rejected promise

crates/perry-runtime/src/promise/async_step.rs

An async function that throws after two or more awaits (even when the
throw is caught by the caller) also emitted a spurious
Uncaught (in promise) and exited 1. The subsequent-await backpatch path minted
an intermediate then-result promise via js_promise_then; that promise adopted
the throw's rejection with no handler attached → phantom unhandled rejection.

Fix: subsequent awaits use js_promise_attach_handlers (no intermediate
then-result promise) and return the existing trap_next, matching the
first-await path. A genuinely-uncaught rejection still reports and exits 1.

3. Class-field async-arrow async-step state locals are not boxed

crates/perry-codegen/src/codegen/boxed_locals.rs

This is the one that made agent events reach pi's UI. _handleAgentEvent is a
class-field async arrow. async_to_generator lowers a field-init async
closure with await into an async-step state machine whose state locals
(__state / __done / __sent / …) are mutated across resumes and captured by
the step closure — so they must be boxed (shared heap cells), exactly like
the same closure written as const f = … (module init) or this.f = … (ctor
body).

But collect_module_boxed_vars / collect_module_local_types walked every
closure-bearing member (functions, methods, getters/setters, static methods,
computed members, constructors, module init) except class field
initializers
. So a field-init async closure's step locals were emitted as raw
(unboxed) captures: the wrapper boxed them, but the step closure overwrote the
box slots with raw values (js_closure_set_capture_bits), desyncing the state
machine — calling the closure ran no body, and the await on it resolved
immediately with no effect. Agent-event dispatch silently died there.

Pinpointed by an LLVM-IR diff (PERRY_SAVE_LL): the field-init step closure used
raw capture writes where the equivalent ctor-body step closure used
js_box_set_bits.

Fix: walk class.fields / class.static_fields initializers (and computed
keys) in both collectors, so a field-init closure's boxed locals are seen at
every capture site.

Tests

  • tests/test_issue_6728_async_gen_await_in_conditional.sh — await inside if
    in an async function* resumes (fix 1).
  • tests/test_issue_6728_multi_await_throw_no_orphan_rejection.sh — caught throw
    after 3 awaits exits 0, no phantom rejection; genuine uncaught still exits 1
    (fix 2).
  • tests/test_issue_6728_class_field_async_arrow.sh — class-field async arrow's
    body runs when called directly, via a reference, and via a Set (fix 3).

Validation

  • Unit suites green: perry-transform, perry-runtime promise/async/closure/box +
    class/field/generator.
  • End-to-end: the perry-compiled pi now renders after Enter — bogus key →
    "Working…" → Error: 401 authentication_error (final screen identical to
    Node); a real key produces a real streamed API response.

Each fix is independent and separately testable. Built on current main
(which includes #6709 / #6727).

Ralph Küpper added 5 commits July 21, 2026 17:35
…rryTS#6728)

An async function that reaches a `throw` after two-or-more `await`s rejected
its result promise correctly (a surrounding try/catch caught it) but ALSO
surfaced a spurious "Uncaught (in promise)", exiting the process non-zero.

Root cause: `then_backpatch_result` attaches the async-step resume thunks to
the awaited promise. The first `await` (activation result promise not yet
created, `trap_next` null) uses `js_promise_attach_handlers` (no chained
then-result). Every SUBSEQUENT await (`trap_next` non-null) used
`js_promise_then`, which mints an intermediate then-result promise. When the
resumed step later throws, its fulfill thunk returns the step body's fresh
`Promise.reject(e)` (forwarded into `trap_next` by
`forward_swallowed_rejection`), and that then-result promise ADOPTS the
rejection — becoming a rejected promise with no handler attached. The orphan is
reported as an unhandled rejection.

Fix: use `js_promise_attach_handlers` (next = null) for subsequent awaits too
and return `trap_next` (matching the reuse fast paths), so a multi-await async
function that throws rejects exactly one promise — the activation result.

This is the pi interactive-TUI blocker: the Anthropic SDK's makeRequest throws
the 401 APIError after many awaits, so the phantom rejection fired and the
turn's error never rendered.
…erryTS#6709 completion, PerryTS#6728)

PerryTS#6709 made async-generator `await`s suspend via StateExit::Await, but only for
awaits at a statement-level position the linearizer's main loop visits directly.
An `await` nested inside an `if` / loop / `try` whose branch has NO `yield` was
missed: the split gates (`Stmt::If`, `Stmt::While`, `Stmt::For`, `Stmt::Try`, …
in linearize.rs) are all guarded by `body_contains_yield(...)`, which detected
`yield` but not `await`. So the construct was emitted inline (unsplit), the
`await` inside it never became a StateExit::Await, and it fell through to the
busy-wait poll loop — deadlocking whenever the awaited Promise is settled by an
external producer.

This is exactly pi's EventStream async iterator:
    while (true) {
      if (this.queue.length === 0) { if (this.done) return; await new Promise(...); }
      while (this.queue.length > 0) yield this.queue.shift();
    }
The inner `while` has the yield, so the outer `while(true)` splits — but the
`if (queue.length === 0) { await ... }` has only an await, so it stayed inline
and the await busy-waited forever. pi's turn UI never advanced (no spinner, no
error render). Minimal repro: `await` inside an `if` in an async generator.

Fix: `body_contains_yield` also counts a statement-level `await` (bare / let= /
return / throw) as a suspend point, gated on `linearize_async_generator()` and on
`Expr::Await` (which only survives to the linearizer for async generators), so
plain async functions and sync generators are unaffected. Mirrors the four
`Expr::Await` statement arms PerryTS#6709 added to linearize.rs.
…async-gen await-in-conditional

- test_issue_6728_multi_await_throw_no_orphan_rejection.sh: an async fn that
  throws after >=2 awaits, caught by try/catch, must exit 0 with no spurious
  'Uncaught (in promise)'.
- test_issue_6728_async_gen_await_in_conditional.sh: pi's EventStream push/pull
  shape (async gen with an await inside an 'if' in a loop, settled by an
  external producer) must not deadlock.
…PerryTS#6728)

A class-field-initializer async arrow whose body contains `await` did not run
its body when called: calling it returned immediately and the `await` on it
resolved with no effect. The same closure written as `const f = async …`, an
object-literal property, a function return, or a constructor-body `this.f = …`
assignment all worked.

Root cause: the async-to-generator transform lowers such a closure into an
async-step state machine whose state locals (__state/__done/__sent/…) are
mutated across resume calls AND captured by the step closure, so they must be
boxed (shared heap cells) — the wrapper allocates the boxes and the step closure
reads/writes them. But `collect_module_boxed_vars` / `collect_module_local_types`
(boxed_locals.rs), which build the module-wide boxed-var and local-type sets,
walked every closure-bearing member kind (functions, methods, getters, setters,
static methods, computed members, constructors, module init) EXCEPT class field
initializers. So a field-init closure's step locals were absent from the boxed
set: the wrapper boxed them, but the step closure emitted raw
`js_closure_set_capture_bits` writes that overwrote the box slots with raw
values, desyncing the state machine — state 0 never ran the user body.

Fix: walk `class.fields` / `class.static_fields` initializers (and computed
keys) in both collectors, mirroring the closure/global collectors that already
do. Verified via LLVM IR: the field-init step closure now boxes its state locals
identically to the constructor-body case.

This is the last blocker for pi's interactive TUI: `_handleAgentEvent` is a
class-field async arrow, so agent events never reached the UI.
@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown

Caution

Review failed

An error occurred during the review process. Please try again later.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@proggeramlug
proggeramlug merged commit 574612d into PerryTS:main Jul 21, 2026
27 checks passed
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.

runtime: pi TUI Enter (CR) keypress does not submit under perry — raw-stdin/Kitty keypress decode divergence (real pi interactive blocker)

1 participant