Skip to content

feat(ts): translate kio's poll API across crates: Poll<Poll<T>>, ? on a Poll, and closure copies - #183

Merged
kixelated merged 10 commits into
mainfrom
quest/m2/ts-async
Oct 28, 2026
Merged

kixelated merged 10 commits into
mainfrom
quest/m2/ts-async

Conversation

@kixelated

@kixelated kixelated commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator

Works on quest/m2/ts-async.md: the piece "kio's API, reached across crates", which #141 unblocked. This PR rewrites the quest to what remains.

Problem

Since #141, kio translates from source as moq-net's dependency. Its poll API still failed to translate, or translated wrong, wherever moq-net uses it:

  • Poll<Poll<T>> was refused. kio's Consumer::poll(waiter, f) and ConsumerWeak::poll take a closure that returns a Poll<R>. moq's track.rs poll_group passes a closure whose R is itself a Poll. Ready-of-pending and pending were the same rt.Pending, so the translator refused the type.
  • ? on a Poll<Result<T, E>> was mistranslated silently. The translator branched on .ok of the value ? was applied to. On rt.Pending that read undefined, so the code took the Err path. moq-net does this about 10 times (poll_flush(cx)?, track.poll_recv_group(waiter)?), and every kio Consumer::poll result can hit it.
  • Copies of closures were refused that are safe to copy.
    • In a dependency's optimized MIR, a move shows up as a copy of a non-Copy type. Dependencies build without unwind cleanups, so copy propagation rewrites the move. One hit was wasm-bindgen.
    • rustc gives a closure the FnMut kind its bound asks for, even when it changes nothing. kio's wait(|waiter| self.poll_x(waiter)) only borrows &self shared. Consumer::wait owns a closure with no captures. Both were refused in Pending::new.

Consumer: moq-net, through kio's Consumer::poll, ConsumerWeak::poll, Pending, wait, and Consumer::wait.

Approach

  • Poll<T>, where T can already be pending, is T | rt.OuterPending, as Option<T> is T | rt.None where T can be undefined. The type alone decides it, so every function agrees.
    • Erased gains outer_pending.
    • The constructor, the variant test, and the copy shim all go through Translator::pending(inner).
    • Still refused, each at its span:
      • a third level of nesting, as Option refuses one;
      • a future whose output can be pending (rt.Future's poll returns rt.Pending as its own);
      • an instance of a generic type whose Poll<T> field this instance makes pending.
  • ? on a Poll<Result<T, E>> branches through rt.pollBranch(p, pending). Pending continues as a shared frozen Ok of the Poll<T>'s own pending, so it doesn't allocate. ? on a Poll<Option<Result<T, E>>> branches through rt.pollOptionBranch, which also continues on Ready(None). from_residual into a Poll<Option<Result<..>>> now translates too, since its Err is the same value. Before, it was refused.
  • Closure copies:
    • A MIR copy of a type that isn't Copy lowers as the move it is.
    • A copy of an FnMut closure is allowed when its optimized MIR writes no capture, or writes only captures with no state (a fn item, a closure with no captures, ()). A closure whose MIR isn't available stays refused.
  • Test fixture: the task fixture's in-crate kio module is now its own crate, fixtures/task/kio (lib kio), reached across crates as moq-net reaches kio. It gains kio's real shapes: Task with its blanket impl for FnMut, Pending<P> with Deref/DerefMut/into_inner, wait() -> impl Future, Waiter::poll_future of ?Sized futures, Consumer::poll, poll_closed, closed, and wait.

Census at the pinned moq revision

Spans this PR removes, from just ts census before and after (moq-net, model, kio):

Config Before (59ae88e5) After, without #147 After, at the head (9d0ca3f2, merged with #147)
net.toml 3,804 3,798 3,391
model.toml 1,164 1,159 1,159
kio.toml 397 397 397
  • Removed in both moq-net and the model:
    • four Poll<Poll<Result<Option<group::Consumer>, Error>>> refusals (kio/src/weak.rs:284, :291; moq-net/src/model/track.rs:4429 ×2);
    • kio's pending.rs:25 closure copy. In the model it came from bandwidth.rs:120, :136, group.rs:1691, track.rs:3352, and Consumer::closed; in moq-net, also from session.rs's callers of Consumer::wait.
  • Removed from moq-net only: wasm-bindgen's rt/mod.rs:1096 closure copy.
  • Removed in both by tying closure copies to written captures: hashbrown's scopeguard.rs:21, whose guard closures own an allocator and a layout and never write them. No closure-copy refusal remains in either.
  • feat(ts): map bytes, rand, and tracing without config #147 accounts for the rest of the last column. The ? fix removes no refusal: that code was mistranslated, not refused.
  • kio.toml has no roots, so it lowers kio's generics over their own parameters, and none of these hinge on an instance.

What kio still refuses in moq-net is outside this piece:

Refusals File Cause Owner
159 lock.rs PoisonError::into_inner #178
82 log.rs, sync.rs MaybeUninit, UnsafeCell, raw pointers unsafe or bindings
49 task.rs Tasks, its RawWaker vtable and Box<dyn FnMut> tasks bindings; moq/model.toml already binds kio::Tasks
10 waiter.rs Cell, thread_local!, and Arc::weak_count (ts-shared); [AtomicU64; 8]::default, array::from_fn, and a static atomic see Follow-ups
1 each lib.rs, shared.rs <()>::default; a HashMap of Option see Follow-ups

Tests

  • task (differential, 20,000 random scripts plus named cases): a new watch step drives kio's API across crates.
    • Waiter::poll_future of a pinned recv as a dyn Future, under kio::wait.
    • A Pending of a crate Task, reached through DerefMut between awaits, then into_inner, Consumer::closed, and Consumer::wait.
    • The Task reads through ? on Consumer::poll, so a Poll<Poll<u64>> continues through rt.OuterPending. An even value waits for the next one or for the close that drops it. It also goes through ? on a Poll<Option<Result<..>>>.
    • Three named cases: values released by a later send, a held value dropped by the close, and a cancel mid-wait.
    • Mutation check: with pollBranch reverted to pass-through, the differential reports 80 differences.
  • reject:
    • nested_poll is now three levels deep.
    • pending_output keeps its refusal under the new message.
    • generic_pending refuses a generic Poll<T> field made pending.
    • rebound_reference refuses copies of a closure that rebinds a captured &u32.
  • just check and just ts test pass on the merged head. quest check passes.

Impact

  • New runtime exports: rt.OuterPending (and its type), rt.pollBranch, and rt.pollOptionBranch.
  • Output: Poll<T> where T can be pending is typed T | rt.OuterPending. Before, it was refused, so no breaking change and no MIGRATE.md entry.
  • Output: ? on a Poll<Result<..>> or Poll<Option<Result<..>>> now calls the runtime instead of passing the value through. This is a fix.
  • README.md: the Poll and ControlFlow rows, the "Not yet" list, and the task fixture's description.

Alternatives

  • Wrap the ready value, { ready: T }, when T can be pending: rejected. It allocates per poll and breaks Poll<T>'s erasure for callers. The Option precedent is a second sentinel chosen by type.
  • Swap the sentinel on the inner Poll instead of the outer one: rejected. A &Poll<T> into the payload would then read a different value than the same Poll<T> elsewhere.
  • Let the outer Pending of a future's own poll be rt.OuterPending: rejected. rt.spawn, rt.DynFuture, and callers' executors read rt.Pending, so such a future is refused instead.

Choices made without asking

  • The name rt.OuterPending, after rt.None's role. Alternatives: rt.ReadyPending, rt.Pending2.
  • The ?-on-Poll fix is in this PR, though no census refusal counted it. It is a silent mistranslation of kio's Consumer::poll results, which moq-net reaches.
  • kio's fixture is now a dependency. It builds with cargo's panic strategy, so its translated frames have no unwind cleanups (feat(ts): translate every non-std dependency the roots reach with the package #141's decision, and where the abort-on-panic quest is heading). No new unwinding tests or semantics are added.

Follow-ups

  • Waiter::poll_future::<()> "does not normalize" (kio/src/waiter.rs:160, moq-net census only). It comes from util.rs's future_task closure, which is already refused as a Box<dyn FnMut>. The same call translates in the fixture, so it looks like an artifact of lowering the refused closure. Suggest a quest to find where the dyn Future's type argument turns into ().
  • std gaps in kio that no quest owns: array::from_fn, Default of [T; N] and (), a static atomic (waiter.rs's NEXT), and a HashMap of an Option (shared.rs). Suggest folding them into std-rest or a new quest.

🤖 Generated with Claude Code

(written by Claude Opus 5.5)

kixelated and others added 9 commits October 28, 2026 10:08
Quest-Claim: b03e7e55f46ed499
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated kixelated changed the title Async in the TypeScript translator feat(ts): translate kio's poll API across crates: Poll<Poll<T>>, ? on a Poll, and closure copies Oct 28, 2026
@kixelated
kixelated marked this pull request as ready for review October 28, 2026 18:03
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-28T18:15:49.958437Z 9d0ca3f Manual request
🔒 Security Review ✅ Completed 2026-10-28T18:17:13.577611Z 9d0ca3f Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@kixelated kixelated left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review by review (OpenAI)

Reviewed commit: 09c41ff

One actionable correctness issue: the closure-copy relaxation also permits copying a closure that rebinds a captured shared reference (inline).

Overall direction: type-selected pending sentinels and dedicated Poll ? branching fit the existing erasure model without per-poll ready wrappers. The cross-crate fixture exercises the intended kio boundary; keep the closure exception tied to actual capture mutation rather than reference type alone.

Verification: static review of translator/runtime changes, generated snapshots, and fixture coverage; no tests or translation reproducer executed.

Comment thread ts/src/lower/mir/place.rs Resolved
A copied FnMut closure was allowed whenever its captures were shared
references, but rebinding such a reference changes the copy's state.
Read the closure's optimized MIR for written captures instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kixelated

Copy link
Copy Markdown
Collaborator Author

@codex review

(written by Claude Opus 5.5)

@kixelated kixelated left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review by review (OpenAI)

Reviewed commit: 9d0ca3f

Compared with my review of 09c41ff. The prior shared-reference closure-copy finding is fixed: capture-write analysis now detects rebinding, subfield mutation and mutable borrows, with conservative rejection when MIR is unavailable. The new rebound_reference regression expects refusals at both copies.

No new actionable bugs found in this update. Overall direction is sound: checking actual capture mutation preserves the intended kio exception without confusing an immutable referent with an immutable binding; the small analysis is justified over the former type-only rule.

Verification: focused static review of the fix, surrounding copy/closure lowering, and regression snapshots; tests were not run.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 👍

Reviewed commit: 9d0ca3f21c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review · Automatically triggered

Security review completed. No security issues were found in this pull request.

Reviewed commit: 9d0ca3f21c

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

@kixelated
kixelated merged commit 56a9484 into main Oct 28, 2026
1 check passed
@kixelated
kixelated deleted the quest/m2/ts-async branch October 28, 2026 18:35
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