Skip to content

fix(codemode): run each job to completion - #54276

Merged
rekram1-node merged 2 commits into
anomalyco:devfrom
RhysSullivan:codemode-run-to-completion
Oct 20, 2026
Merged

rekram1-node merged 2 commits into
anomalyco:devfrom
RhysSullivan:codemode-run-to-completion

Conversation

@RhysSullivan

Copy link
Copy Markdown
Contributor

Issue for this PR

Closes #54274

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

Each async call, reaction and generator body runs on its own fiber, and Effect yields a fiber after 2,048 operations, so ready program fibers interleaved mid-statement and lost each other's writes (repro in the issue).

This adds a per-execution Turn (promises.ts). Only the fiber holding it runs program code. The main job holds it. An async call or generator step lends the caller's turn to the callee until the callee suspends. await, reactions and combinator settlement pass it to the next waiting job and queue to take it back. Work that runs no program code runs without it: the host side of tool calls, extension promises, and fibers that only settle a promise. So tool calls stay concurrent and settlement timing is unchanged. Preemption stays on, so CPU loops still hit timeoutMs.

Synchronous starts and handbacks nest fibers on the host stack, so past 100 levels they go through the scheduler. Without this, the existing "recurses before its first await" test hung: the host stack overflowed inside a fiber start and left a fiber holding the turn. As a side effect, that recursion now reaches the interpreter's 10,000-call limit; on Node the base overflows the host stack at about 1,340 calls.

Cost: once a job is preempted, waiting jobs resume through one more scheduler step. Medians of 7 alternating runs against base v2 55dde88, on one 16-core Linux host:

Case Bun 1.4.2 (2 passes) Node 24.21.0
10,000 sequential awaits +7–8% +19%
3,000 async calls +6–12% +9%
10,000 generator yields +11–14% +16%
3,000 .then reactions +49–52% +33%
1,000 / 4,000 / 8,000 tasks, two awaits each +76–82% / +97–107% / +97–99% +53% / +75% / +85%
3,000 race / nested all / allSettled +13–20% / +57–59% / +51–53% +21% / +35% / +42%
Peak RSS, 8,000 tasks (3 fresh runs) 132–135 → 231–241 MiB 240–249 → 331–337 MiB

How did you verify your code works?

  • bun test in packages/codemode: 1,402 pass, including 4 new tests in promise.test.ts. The two lost-update tests fail on base (1853 vs 2000, 145 vs 1000). bun typecheck and oxlint are clean.
  • 219 programs run natively in Node and in the interpreter: lost updates, promise and generator ordering, and each combinator beside a preempted job. Base differs from Node on 82, this branch on 9. All 9 also differ on base and give base's exact values: promise adoption settles one job early, empty all/allSettled/any settle one job late, break in for await resumes late, and copy-out drops value: undefined.
  • 20 CPU-bound programs still time out at 100 ms, in 101–111 ms. These include a job that spins while others wait for the turn, plus reactions, generators and combinator members. Interrupting each one takes 39–45 ms.

Screenshots / recordings

Not a UI change.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

Program fibers took turns at Effect's scheduler yields, so a long job could be
preempted mid-statement and another job's writes to shared bindings were lost.
Only the fiber holding the execution's turn now runs program code; awaits and
reactions queue for it, and tool calls and host work run without it.
@github-actions

Copy link
Copy Markdown
Contributor

Thanks for your contribution!

This PR doesn't have a linked issue. All PRs must reference an existing issue.

Please:

  1. Open an issue describing the bug/feature (if one doesn't exist)
  2. Add Fixes #<number> or Closes #<number> to this PR description

See CONTRIBUTING.md for details.

@github-actions

Copy link
Copy Markdown
Contributor

The following comment was made by an LLM, it may be inaccurate:

@rekram1-node

Copy link
Copy Markdown
Contributor

Pushed one small commit on top (6bd8a8a): handBack now always resumes the parked caller through the scheduler. Resumed inline, a caller whose callee was preempted before its first await ran on to its own next await and queued ahead of the callee:

const log = []
Promise.resolve().then(() => log.push("t"))
const heavy = async () => { for (let i = 0; i < 1500; i++) {} await null; log.push("h") }
heavy()
await null
log.push("main")
await null
return log
// native ["t", "h", "main"]; before this commit ["t", "main", "h"]

It also removes the inline nesting and try/finally path there. Merged onto current dev: codemode 14,720 pass / 0 fail, test262 unchanged, and CPU loops in async bodies still hit timeoutMs.

@rekram1-node
rekram1-node marked this pull request as ready for review October 20, 2026 07:02

@opencode-agent opencode-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This fixes the problem, and I found nothing that should block a merge.

I ran the #54274 repro from source in this branch and in the base. On the base it still gives 1853 / 145; with this PR it gives 2000 / 1000, the same as plain JavaScript. I also compared 13 more programs with plain JavaScript: generators, async generators, .then ordering, tool calls inside Promise.all, failing tools, allSettled, race, promise executors and async recursion 9,000 calls deep. The base got 4 of them wrong (a generator delegating to another one failed with "Generator is already running", .then callbacks were dropped, the race case differed, and the deep recursion overflowed the stack). This branch gets all of them right except the race case, which differs only because a tool's real sleep races the interpreter's own run time. Programs that loop forever still stop with TimeoutExceeded: next to a slow tool call, inside a generator, inside a .then, inside an async generator, and with a paused generator. bun test (1,402 pass) and typecheck are clean.

Public API: unchanged. Turn and Pending.createHost are internal to the interpreter.

The cost you measured (about 2× time for programs with many concurrent tasks, about +100 MiB peak RSS at 8,000 tasks) is for maintainers to weigh. It stays bounded by the 10,000 pending-promise limit.

@rekram1-node
rekram1-node merged commit 3b593dd into anomalyco:dev Oct 20, 2026
12 checks passed
vleonbonnet added a commit to vleonbonnet/opencode that referenced this pull request Oct 20, 2026
Upstream's Code Mode now runs each job to completion (anomalyco#54276): only the fiber holding the program's turn runs program code, and a promise a global returns is host work that runs without the turn.

- A global's returned Effect is host work too, so it now runs without the turn. Started on the turn, it held the program until it finished: the program stopped at the call, concurrent fetches ran one at a time, a fetch waiting on its permission request stalled the program, and an un-awaited Effect that never ends hung the execution. A new interpreter test covers the program running on while two such calls are pending.
- No other upstream change touches the fork's files; the auto-merged client types and OpenAPI documents match their generators.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

codemode: concurrent async functions lose updates to shared variables

2 participants