Support async futures in JS bindings#491
Open
tekumara wants to merge 2 commits into
Open
Conversation
Merging this PR will not alter performance
Comparing Footnotes
|
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
Monty could suspend with RunProgress::ResolveFutures, but the JS binding had no representation for that suspension point. It hit the old fallback:
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:
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:
MontyResolveFuturesIt exposes:
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:
to:
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:
or:
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.