Skip to content

Support async futures in JS bindings#491

Open
tekumara wants to merge 2 commits into
pydantic:mainfrom
tekumara:js-async-futures
Open

Support async futures in JS bindings#491
tekumara wants to merge 2 commits into
pydantic:mainfrom
tekumara:js-async-futures

Conversation

@tekumara

@tekumara tekumara commented Jun 5, 2026

Copy link
Copy Markdown

The change turns JS async external calls from “awaited by the wrapper before Monty sees them” into proper Monty-managed external futures.

Previously, runMontyAsync() would call a JavaScript external function, and if the result was a Promise, it would simply await that promise in JavaScript before resuming Monty with a
concrete value. That worked for simple linear code, but it meant Monty never saw a pending future. As soon as Python code did something like:

  import asyncio
  results = await asyncio.gather(fetch_a(), fetch_b())

Monty could suspend with RunProgress::ResolveFutures, but the JS binding had no representation for that suspension point. It hit the old fallback:

  Some("Async futures (ResolveFutures) are not yet supported in the JS bindings".to_owned())

So the binding could advertise async support, but it was really only “JS awaits outside Monty”, not “Monty can drive async Python semantics”.

The new design exposes that missing VM state to JavaScript.

When a JS external function returns a promise, runMontyAsync() now does not await it immediately. Instead, it calls the new native method:

  snapshot.resumePending()

That resumes Monty with ExtFunctionResult::Future(callId). Monty then stores an internal ExternalFuture and continues running Python code. If the Python code later awaits that
future, or if asyncio.gather() blocks on several such futures, Monty yields a ResolveFutures state.

The JS binding now represents that state as a new class:

  MontyResolveFutures

It exposes:

  pendingCallIds
  resume({ results: [...] })
  dump()
  load()
  repr()

The wrapper keeps a map of JS promises keyed by Monty callId. When Monty yields MontyResolveFutures, the wrapper waits for one of the requested JS promises to settle, converts that
fulfillment or rejection into a Monty future result, and resumes Monty. That loop repeats until Monty completes or raises.

The key decision was to keep Monty as the scheduler for Python async semantics, rather than trying to emulate scheduling entirely in TypeScript. The Rust VM already knows which
futures are pending, which tasks are blocked, and how asyncio.gather() should resume. The binding’s responsibility is now just to correlate Monty callIds with host promises and feed
results back when requested.

I also added callId to MontySnapshot because the JS layer needs a stable identifier to associate a pending JS promise with the Monty external future that represents it. Without
exposing callId, the wrapper could call resumePending(), but it would not know how to match a later pendingCallIds request back to the original promise.

Another choice was to preserve the existing synchronous API shape as much as possible. Regular resume({ returnValue }) still works. Synchronous external functions still resume
immediately. Only promise-like results take the new resumePending() path. The public start/resume union is extended from:

  MontySnapshot | MontyNameLookup | MontyComplete

to:

  MontySnapshot | MontyNameLookup | MontyResolveFutures | MontyComplete

That reflects the actual VM state now instead of hiding it behind a NotImplemented error.

Tests were updated to use Python await for async JS external functions, because that is now the intended model: if a JS function returns a promise, Monty sees an awaitable external
future. There is a small backwards-compatibility fallback for the simple old pattern where an un-awaited external future is the final top-level result, but the documented behavior
is now to write:

  await fetch_data()

or:

  await asyncio.gather(fetch_a(), fetch_b())

The regression test specifically verifies that two gathered JS promises are active at the same time, proving that Monty is no longer serializing async external calls through the
wrapper.

Finally, the limitations docs were updated to make the binding behavior explicit: JavaScript promises are Monty external futures, Python should await them, and non-awaited promise usage is only supported in the simple top-level compatibility case.

@codspeed-hq

codspeed-hq Bot commented Jun 5, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 17 untouched benchmarks
⏩ 15 skipped benchmarks1


Comparing tekumara:js-async-futures (8e04a27) with main (90ead8d)

Open in CodSpeed

Footnotes

  1. 15 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@codecov

codecov Bot commented Jun 5, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@cubic-dev-ai cubic-dev-ai 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.

No issues found across 6 files

Re-trigger cubic

The async wrapper previously unwrapped a pending JS promise when the final Monty output string matched the external-future repr. That made ordinary Python strings control execution and could replace a legitimate result, or hang if the unrelated promise never settled.\n\nRemove the compatibility path instead of adding another string-based heuristic: callers that want a promise result must await the external function in Python, while un-awaited calls remain ordinary Monty output. Add a regression test for the ambiguous string and update the async limitations docs to make the contract explicit.
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