Skip to content

Commit c79b172

Browse files
committed
feat(ASYNC-01): lower coroutines and Promise awaits
1 parent 6863850 commit c79b172

9 files changed

Lines changed: 478 additions & 40 deletions

File tree

‎IMPLEMENTATION_PLAN.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,3 +450,9 @@ rejected. Per-module maps, source hashes, static output closure and unchanged
450450
notices are retained. Published 0.1.0 artifacts remain unchanged. GitHub Issues
451451
and Project fields record live delivery status; the forward table records the
452452
original adopted dependency order. ASYNC-01 and BROWSER-01 follow this foundation.
453+
454+
The explicit [async profile](docs/compiler-async.md) implements ASYNC-01
455+
coroutines, Promise conversion/chaining, synchronous callbacks, source-located
456+
rejections and AbortSignal context cancellation. JavaScript microtask timing and
457+
CPython scheduling differences are documented. Fetch/DOM/worker transport remains
458+
BROWSER-01; no new async runtime dependency or published artifact is introduced.

‎README.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,3 +108,7 @@ network requests.
108108
The development [compiler emitter](docs/compiler-emitter.md) produces deterministic
109109
ES modules for the approved single-module Python profile. This API is not part
110110
of the published 0.1.0 distribution.
111+
112+
The explicit [async compiler profile](docs/compiler-async.md) adds lazy coroutines,
113+
awaited Promise values, callbacks and context cancellation. Browser host adapters
114+
remain separately tracked.

‎docs/compiler-async.md‎

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
# Compiled async profile
2+
3+
The development APIs `parse_source`, `compile_source` and `compile_modules`
4+
accept `async_enabled=True`. Without it, async syntax remains rejected. Published
5+
0.1.0 artifacts predate these compiler APIs. Single-module artifacts identify
6+
`python-browser-async/1` and ABI `python-browser-async-esm/1`; local-module artifacts
7+
identify `python-browser-modules-async/1` and ABI
8+
`python-browser-modules-async-esm/1`. These are explicit extensions of the
9+
[source language](compiler-language.md) and [module contract](compiler-modules.md).
10+
11+
```python
12+
from JavaScript import Promise
13+
14+
async def main():
15+
value = await Promise.resolve(9007199254740993)
16+
print(value + 1)
17+
return value
18+
```
19+
20+
Compile this source with the async flag, import its generated `createModule`,
21+
then use `await createModule().main()` in JavaScript. An exported async function
22+
returns a native Promise, including for invalid arguments and cancellation.
23+
Python integers, strings and tuples keep their adopted runtime representation;
24+
no implicit Number conversion is added. Synchronous exports returning an adopted
25+
Promise also expose a native Promise. Other synchronous exports remain synchronous.
26+
27+
## Coroutine and Promise behavior
28+
29+
Top-level plain `async def` functions support the existing positional parameters,
30+
immutable defaults, ordinary control flow and return values. Calling one inside
31+
compiled Python creates a lazy, single-use coroutine. Its body begins when
32+
awaited. Arguments/defaults are bound when called. Awaiting the same coroutine
33+
twice raises RuntimeError; a Promise can be awaited repeatedly. Await in defaults,
34+
module initialization or a synchronous function is rejected. Async generators,
35+
async iteration/context managers, asyncio task APIs, gather and top-level await
36+
are deferred. Unawaited coroutines have no execution side effect; no finalizer
37+
warning is promised.
38+
39+
The approved async JavaScript registry adds `Promise.resolve(value)`,
40+
`Promise.reject(TypeError(message))` and `promise.then(on_fulfilled, on_rejected)`.
41+
Registry imports/aliases resolve by binding identity. A handler is a supported
42+
plain synchronous Python function or None; omitted/None handlers forward outcomes.
43+
Each handler receives one argument and is called once. A returned adopted Promise
44+
is chained; a raised adopted exception rejects the chain without retrying the
45+
handler. Async callbacks and arbitrary thenables are rejected. Promise constructors,
46+
catch/finally, all/race and general native Promise inputs remain deferred.
47+
Coroutines must be awaited before passing them as arguments; async functions
48+
cannot return an unawaited coroutine or Promise as their final value.
49+
50+
Source locations survive async rejection, imported calls and callbacks. Explicit
51+
builtin TypeError and existing runtime errors retain their Python names and
52+
messages. Unexpected host rejections become a bounded RuntimeError rather than
53+
exposing host internals. Synchronous compiled calls retain depth 128; at most 128
54+
async coroutine bodies may be active in one context. This bound also limits
55+
concurrent active bodies and is not CPython's recursion limit.
56+
57+
## Scheduling and cancellation
58+
59+
An exported async invocation starts on a JavaScript microtask. Internal coroutine
60+
creation remains lazy; native async lowering and Promise continuations preserve
61+
source evaluation order, short circuiting, and callback single invocation.
62+
Awaited boolean/chained-comparison expressions may add a continuation even on a
63+
short-circuited path. Awaiting an already fulfilled Promise also crosses a
64+
microtask checkpoint. CPython's companion Promise uses thread-backed callbacks
65+
and its asyncio bridge may resume an already completed awaitable without the
66+
same checkpoint. Timing relative to unrelated tasks or callbacks is therefore
67+
not a cross-target guarantee. No implicit event loop or Python scheduler is
68+
embedded in the generated artifact.
69+
70+
`createModule({write, signal})` accepts an optional AbortSignal for that context.
71+
Generated statements, public calls, callbacks and await boundaries check it.
72+
Cancellation rejects pending exported operations with name AbortError and code
73+
CANCELLED at a recorded source location. Await listeners are removed on success,
74+
failure and cancellation. Completed output/other side effects remain committed.
75+
The signal applies to the whole application context; create a fresh context to
76+
restart. Signal reasons are not copied into application errors.
77+
78+
AbortSignal delivery cannot interrupt busy synchronous execution on its own.
79+
In a worker, the trusted main thread must retain worker termination as the stop
80+
path. The [host protocol](https://github.com/outside-labs/pocket-python/blob/main/docs/browser-host-protocol.md)
81+
uses session/request IDs, serialized tagged values, exactly one terminal result
82+
and CANCELLED/TIMEOUT replies. A future transport adapter must wrap a pending
83+
host result as an adopted Promise, forward cancellation to its request, discard
84+
stale replies and release handles. Raw Promise/Event/DOM objects do not cross
85+
that transport. HOST-01 specifies that adapter; this task does not implement it.
86+
87+
## Fetch boundary
88+
89+
CPython code continues to use the published `fetch_api.fetch` implementation
90+
re-exported by `JavaScript`, including its thread-backed Promise and HTTP types.
91+
Those implementations are not copied into compiled artifacts. `fetch`, Request,
92+
Response, streaming bodies and response JSON methods remain rejected by this
93+
compiler profile. BROWSER-01 adds the separately granted `fetch/1` text operation,
94+
lowering to browser-native Fetch through the trusted host with AbortSignal,
95+
origin/CORS, URL, credential, redirect, size and deadline rules. Its first text
96+
metrics UI requires no network access. Streaming, async iterables and standalone
97+
Promise package extraction remain separate extensions.
98+
99+
Local fixtures compare awaited values, laziness, defaults, order and callback
100+
errors with CPython and execute the generated modules in Node. Abort and listener
101+
cleanup tests use a local AbortController; no external network is needed.

‎docs/compiler-language.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,8 +93,9 @@ provides initial positive and rejected/deferred cases; parsing known sources
9393
for the contract does not prove a compiler already exists. PARSE-02 implements
9494
the front end and EMIT-01 must execute emitted output against that corpus.
9595

96-
MOD-01 adds explicit local-module identity and initialization; ASYNC-01 adds
97-
supported await/Promise/cancellation behavior. BROWSER-01 integrates main-thread
96+
The local-module profile implements explicit module identity and initialization;
97+
the [async profile](compiler-async.md) adds supported await/Promise/cancellation
98+
behavior when explicitly enabled. BROWSER-01 integrates main-thread
9899
DOM/events and native Fetch through the versioned host protocol; QA-03 verifies
99100
CPython/Node/browser parity and source-map errors. Origin/CORS and worker message
100101
constraints remain explicit.

‎src/JavaScript/_compiler_runtime.py‎

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,105 @@ class $JSArray { constructor(values) { this.values = values; } }
216216
if (value instanceof $JSArray && name === 'length') return value.values.length;
217217
$fail('TypeError', 'Attribute is outside the approved JavaScript registry.', location);
218218
}
219+
'''),
220+
'async': (('call', 'javascript'), r'''
221+
if (signal !== null && (typeof signal !== 'object' || typeof signal.aborted !== 'boolean' ||
222+
typeof signal.addEventListener !== 'function' || typeof signal.removeEventListener !== 'function')) {
223+
throw new TypeError('signal must be an AbortSignal or null');
224+
}
225+
function $cancelled(location) {
226+
const error = new $PyError('AbortError', 'Compiled operation cancelled.', location);
227+
error.code = 'CANCELLED'; return error;
228+
}
229+
function $checkpoint(location) { if (signal?.aborted) throw $cancelled(location); }
230+
class $Coroutine {
231+
constructor(start) { this.start = start; this.started = false; }
232+
}
233+
class $PromiseValue {
234+
constructor(promise) { this.promise = promise; promise.catch(() => {}); Object.freeze(this); }
235+
}
236+
const $asyncFunctions = new WeakSet();
237+
let $activeAsync = 0;
238+
function $cfn(name, count, defaults, body) {
239+
const fn = $fn(name, count, defaults, (args, location) => new $Coroutine(() => body(args, location)));
240+
$asyncFunctions.add(fn); return fn;
241+
}
242+
function $wait(promise, location) {
243+
$checkpoint(location);
244+
return new Promise((resolve, reject) => {
245+
let finished = false;
246+
const cleanup = () => signal?.removeEventListener('abort', abort);
247+
const abort = () => { if (!finished) { finished = true; cleanup(); reject($cancelled(location)); } };
248+
signal?.addEventListener('abort', abort, {once: true});
249+
if (signal?.aborted) { abort(); return; }
250+
promise.then(value => {
251+
if (finished) return; finished = true; cleanup();
252+
try { $checkpoint(location); resolve(value); } catch (error) { reject(error); }
253+
}, error => {
254+
if (finished) return; finished = true; cleanup();
255+
reject(error instanceof $PyError ? error : new $PyError('RuntimeError', 'Unhandled Promise rejection.', location));
256+
});
257+
});
258+
}
259+
function $await(value, location) {
260+
$checkpoint(location);
261+
if (value instanceof $PromiseValue) return $wait(value.promise, location);
262+
if (!(value instanceof $Coroutine)) $fail('TypeError', 'await requires an adopted coroutine or Promise.', location);
263+
if (value.started) $fail('RuntimeError', 'cannot reuse already awaited coroutine', location);
264+
value.started = true;
265+
if ($activeAsync >= 128) $fail('RecursionError', 'Maximum active async calls (128) exceeded.', location);
266+
$activeAsync++;
267+
let promise;
268+
try { promise = Promise.resolve(value.start()); } catch (error) { promise = Promise.reject(error); }
269+
const pending = promise.finally(() => { $activeAsync--; });
270+
pending.catch(() => {});
271+
return $wait(pending, location);
272+
}
273+
const $PromiseRegistry = Object.freeze({
274+
resolve: $fn('Promise.resolve', 1, [], (args, location) => {
275+
if (args[0] instanceof $Coroutine) $fail('TypeError', 'Await a coroutine before Promise conversion.', location);
276+
return args[0] instanceof $PromiseValue ? args[0] : new $PromiseValue(Promise.resolve(args[0]));
277+
}),
278+
reject: $fn('Promise.reject', 1, [], (args, location) => {
279+
if (!(args[0] instanceof $PyError)) $fail('TypeError', 'Promise.reject requires an adopted exception.', location);
280+
return new $PromiseValue(Promise.reject(args[0]));
281+
})
282+
});
283+
function $asyncAttribute(value, name, location) {
284+
if (value === $JS && name === 'Promise') return $PromiseRegistry;
285+
if (value === $PromiseRegistry && (name === 'resolve' || name === 'reject')) return value[name];
286+
if (value instanceof $PromiseValue && name === 'then') return $fn('Promise.then', null, [], (args, where) => {
287+
if (args.length > 2 || args.some(fn => fn !== null && (!(fn instanceof $Callable) || $asyncFunctions.has(fn)))) {
288+
$fail('TypeError', 'then requires up to two synchronous Python callbacks or None.', where);
289+
}
290+
const callback = fn => fn == null ? undefined : result => {
291+
$checkpoint(where);
292+
const returned = $call(fn, [result], where);
293+
if (returned instanceof $Coroutine) $fail('TypeError', 'Async Promise callbacks are outside this profile.', where);
294+
return returned instanceof $PromiseValue ? returned.promise : returned;
295+
};
296+
return new $PromiseValue(value.promise.then(callback(args[0]), callback(args[1])));
297+
});
298+
$fail('TypeError', 'Attribute is outside the adopted async registry.', location);
299+
}
300+
'''),
301+
'async-expose': (('async', 'input'), r'''
302+
const $asyncExposed = new WeakMap();
303+
function $asyncExpose(value, location) {
304+
if (!(value instanceof $Callable)) return value;
305+
if (!$asyncExposed.has(value)) {
306+
const invoke = args => {
307+
$checkpoint(location);
308+
return $call(value, args.map(argument => $input(argument, location)), location);
309+
};
310+
$asyncExposed.set(value, $asyncFunctions.has(value) ?
311+
(...args) => Promise.resolve().then(() => $await(invoke(args), location)) : (...args) => {
312+
const result = invoke(args);
313+
return result instanceof $PromiseValue ? $await(result, location) : result;
314+
});
315+
}
316+
return $asyncExposed.get(value);
317+
}
219318
'''),
220319
'input': (('call', 'str', 'tuple'), r'''
221320
function $input(value, location, depth = 0, seen = new Set()) {
@@ -248,6 +347,8 @@ class $JSArray { constructor(values) { this.values = values; } }
248347
'types': ('$types', '$isinstance'), 'exception': ('$TypeError',),
249348
'repr': ('$repr',), 'print': ('$print',),
250349
'javascript': ('$JS', '$JArray', '$attribute'), 'input': ('$input', '$expose'),
350+
'async': ('$checkpoint', '$await', '$cfn', '$PromiseRegistry', '$asyncAttribute'),
351+
'async-expose': ('$asyncExpose',),
251352
}
252353

253354

0 commit comments

Comments
 (0)