Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions IMPLEMENTATION_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -450,3 +450,9 @@ rejected. Per-module maps, source hashes, static output closure and unchanged
notices are retained. Published 0.1.0 artifacts remain unchanged. GitHub Issues
and Project fields record live delivery status; the forward table records the
original adopted dependency order. ASYNC-01 and BROWSER-01 follow this foundation.

The explicit [async profile](docs/compiler-async.md) implements ASYNC-01
coroutines, Promise conversion/chaining, synchronous callbacks, source-located
rejections and AbortSignal context cancellation. JavaScript microtask timing and
CPython scheduling differences are documented. Fetch/DOM/worker transport remains
BROWSER-01; no new async runtime dependency or published artifact is introduced.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,7 @@ network requests.
The development [compiler emitter](docs/compiler-emitter.md) produces deterministic
ES modules for the approved single-module Python profile. This API is not part
of the published 0.1.0 distribution.

The explicit [async compiler profile](docs/compiler-async.md) adds lazy coroutines,
awaited Promise values, callbacks and context cancellation. Browser host adapters
remain separately tracked.
101 changes: 101 additions & 0 deletions docs/compiler-async.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# Compiled async profile

The development APIs `parse_source`, `compile_source` and `compile_modules`
accept `async_enabled=True`. Without it, async syntax remains rejected. Published
0.1.0 artifacts predate these compiler APIs. Single-module artifacts identify
`python-browser-async/1` and ABI `python-browser-async-esm/1`; local-module artifacts
identify `python-browser-modules-async/1` and ABI
`python-browser-modules-async-esm/1`. These are explicit extensions of the
[source language](compiler-language.md) and [module contract](compiler-modules.md).

```python
from JavaScript import Promise

async def main():
value = await Promise.resolve(9007199254740993)
print(value + 1)
return value
```

Compile this source with the async flag, import its generated `createModule`,
then use `await createModule().main()` in JavaScript. An exported async function
returns a native Promise, including for invalid arguments and cancellation.
Python integers, strings and tuples keep their adopted runtime representation;
no implicit Number conversion is added. Synchronous exports returning an adopted
Promise also expose a native Promise. Other synchronous exports remain synchronous.

## Coroutine and Promise behavior

Top-level plain `async def` functions support the existing positional parameters,
immutable defaults, ordinary control flow and return values. Calling one inside
compiled Python creates a lazy, single-use coroutine. Its body begins when
awaited. Arguments/defaults are bound when called. Awaiting the same coroutine
twice raises RuntimeError; a Promise can be awaited repeatedly. Await in defaults,
module initialization or a synchronous function is rejected. Async generators,
async iteration/context managers, asyncio task APIs, gather and top-level await
are deferred. Unawaited coroutines have no execution side effect; no finalizer
warning is promised.

The approved async JavaScript registry adds `Promise.resolve(value)`,
`Promise.reject(TypeError(message))` and `promise.then(on_fulfilled, on_rejected)`.
Registry imports/aliases resolve by binding identity. A handler is a supported
plain synchronous Python function or None; omitted/None handlers forward outcomes.
Each handler receives one argument and is called once. A returned adopted Promise
is chained; a raised adopted exception rejects the chain without retrying the
handler. Async callbacks and arbitrary thenables are rejected. Promise constructors,
catch/finally, all/race and general native Promise inputs remain deferred.
Coroutines must be awaited before passing them as arguments; async functions
cannot return an unawaited coroutine or Promise as their final value.

Source locations survive async rejection, imported calls and callbacks. Explicit
builtin TypeError and existing runtime errors retain their Python names and
messages. Unexpected host rejections become a bounded RuntimeError rather than
exposing host internals. Synchronous compiled calls retain depth 128; at most 128
async coroutine bodies may be active in one context. This bound also limits
concurrent active bodies and is not CPython's recursion limit.

## Scheduling and cancellation

An exported async invocation starts on a JavaScript microtask. Internal coroutine
creation remains lazy; native async lowering and Promise continuations preserve
source evaluation order, short circuiting, and callback single invocation.
Awaited boolean/chained-comparison expressions may add a continuation even on a
short-circuited path. Awaiting an already fulfilled Promise also crosses a
microtask checkpoint. CPython's companion Promise uses thread-backed callbacks
and its asyncio bridge may resume an already completed awaitable without the
same checkpoint. Timing relative to unrelated tasks or callbacks is therefore
not a cross-target guarantee. No implicit event loop or Python scheduler is
embedded in the generated artifact.

`createModule({write, signal})` accepts an optional AbortSignal for that context.
Generated statements, public calls, callbacks and await boundaries check it.
Cancellation rejects pending exported operations with name AbortError and code
CANCELLED at a recorded source location. Await listeners are removed on success,
failure and cancellation. Completed output/other side effects remain committed.
The signal applies to the whole application context; create a fresh context to
restart. Signal reasons are not copied into application errors.

AbortSignal delivery cannot interrupt busy synchronous execution on its own.
In a worker, the trusted main thread must retain worker termination as the stop
path. The [host protocol](https://github.com/outside-labs/pocket-python/blob/main/docs/browser-host-protocol.md)
uses session/request IDs, serialized tagged values, exactly one terminal result
and CANCELLED/TIMEOUT replies. A future transport adapter must wrap a pending
host result as an adopted Promise, forward cancellation to its request, discard
stale replies and release handles. Raw Promise/Event/DOM objects do not cross
that transport. HOST-01 specifies that adapter; this task does not implement it.

## Fetch boundary

CPython code continues to use the published `fetch_api.fetch` implementation
re-exported by `JavaScript`, including its thread-backed Promise and HTTP types.
Those implementations are not copied into compiled artifacts. `fetch`, Request,
Response, streaming bodies and response JSON methods remain rejected by this
compiler profile. BROWSER-01 adds the separately granted `fetch/1` text operation,
lowering to browser-native Fetch through the trusted host with AbortSignal,
origin/CORS, URL, credential, redirect, size and deadline rules. Its first text
metrics UI requires no network access. Streaming, async iterables and standalone
Promise package extraction remain separate extensions.

Local fixtures compare awaited values, laziness, defaults, order and callback
errors with CPython and execute the generated modules in Node. Abort and listener
cleanup tests use a local AbortController; no external network is needed.
5 changes: 3 additions & 2 deletions docs/compiler-language.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,9 @@ provides initial positive and rejected/deferred cases; parsing known sources
for the contract does not prove a compiler already exists. PARSE-02 implements
the front end and EMIT-01 must execute emitted output against that corpus.

MOD-01 adds explicit local-module identity and initialization; ASYNC-01 adds
supported await/Promise/cancellation behavior. BROWSER-01 integrates main-thread
The local-module profile implements explicit module identity and initialization;
the [async profile](compiler-async.md) adds supported await/Promise/cancellation
behavior when explicitly enabled. BROWSER-01 integrates main-thread
DOM/events and native Fetch through the versioned host protocol; QA-03 verifies
CPython/Node/browser parity and source-map errors. Origin/CORS and worker message
constraints remain explicit.
Expand Down
101 changes: 101 additions & 0 deletions src/JavaScript/_compiler_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,105 @@ class $JSArray { constructor(values) { this.values = values; } }
if (value instanceof $JSArray && name === 'length') return value.values.length;
$fail('TypeError', 'Attribute is outside the approved JavaScript registry.', location);
}
'''),
'async': (('call', 'javascript'), r'''
if (signal !== null && (typeof signal !== 'object' || typeof signal.aborted !== 'boolean' ||
typeof signal.addEventListener !== 'function' || typeof signal.removeEventListener !== 'function')) {
throw new TypeError('signal must be an AbortSignal or null');
}
function $cancelled(location) {
const error = new $PyError('AbortError', 'Compiled operation cancelled.', location);
error.code = 'CANCELLED'; return error;
}
function $checkpoint(location) { if (signal?.aborted) throw $cancelled(location); }
class $Coroutine {
constructor(start) { this.start = start; this.started = false; }
}
class $PromiseValue {
constructor(promise) { this.promise = promise; promise.catch(() => {}); Object.freeze(this); }
}
const $asyncFunctions = new WeakSet();
let $activeAsync = 0;
function $cfn(name, count, defaults, body) {
const fn = $fn(name, count, defaults, (args, location) => new $Coroutine(() => body(args, location)));
$asyncFunctions.add(fn); return fn;
}
function $wait(promise, location) {
$checkpoint(location);
return new Promise((resolve, reject) => {
let finished = false;
const cleanup = () => signal?.removeEventListener('abort', abort);
const abort = () => { if (!finished) { finished = true; cleanup(); reject($cancelled(location)); } };
signal?.addEventListener('abort', abort, {once: true});
if (signal?.aborted) { abort(); return; }
promise.then(value => {
if (finished) return; finished = true; cleanup();
try { $checkpoint(location); resolve(value); } catch (error) { reject(error); }
}, error => {
if (finished) return; finished = true; cleanup();
reject(error instanceof $PyError ? error : new $PyError('RuntimeError', 'Unhandled Promise rejection.', location));
});
});
}
function $await(value, location) {
$checkpoint(location);
if (value instanceof $PromiseValue) return $wait(value.promise, location);
if (!(value instanceof $Coroutine)) $fail('TypeError', 'await requires an adopted coroutine or Promise.', location);
if (value.started) $fail('RuntimeError', 'cannot reuse already awaited coroutine', location);
value.started = true;
if ($activeAsync >= 128) $fail('RecursionError', 'Maximum active async calls (128) exceeded.', location);
$activeAsync++;
let promise;
try { promise = Promise.resolve(value.start()); } catch (error) { promise = Promise.reject(error); }
const pending = promise.finally(() => { $activeAsync--; });
pending.catch(() => {});
return $wait(pending, location);
}
const $PromiseRegistry = Object.freeze({
resolve: $fn('Promise.resolve', 1, [], (args, location) => {
if (args[0] instanceof $Coroutine) $fail('TypeError', 'Await a coroutine before Promise conversion.', location);
return args[0] instanceof $PromiseValue ? args[0] : new $PromiseValue(Promise.resolve(args[0]));
}),
reject: $fn('Promise.reject', 1, [], (args, location) => {
if (!(args[0] instanceof $PyError)) $fail('TypeError', 'Promise.reject requires an adopted exception.', location);
return new $PromiseValue(Promise.reject(args[0]));
})
});
function $asyncAttribute(value, name, location) {
if (value === $JS && name === 'Promise') return $PromiseRegistry;
if (value === $PromiseRegistry && (name === 'resolve' || name === 'reject')) return value[name];
if (value instanceof $PromiseValue && name === 'then') return $fn('Promise.then', null, [], (args, where) => {
if (args.length > 2 || args.some(fn => fn !== null && (!(fn instanceof $Callable) || $asyncFunctions.has(fn)))) {
$fail('TypeError', 'then requires up to two synchronous Python callbacks or None.', where);
}
const callback = fn => fn == null ? undefined : result => {
$checkpoint(where);
const returned = $call(fn, [result], where);
if (returned instanceof $Coroutine) $fail('TypeError', 'Async Promise callbacks are outside this profile.', where);
return returned instanceof $PromiseValue ? returned.promise : returned;
};
return new $PromiseValue(value.promise.then(callback(args[0]), callback(args[1])));
});
$fail('TypeError', 'Attribute is outside the adopted async registry.', location);
}
'''),
'async-expose': (('async', 'input'), r'''
const $asyncExposed = new WeakMap();
function $asyncExpose(value, location) {
if (!(value instanceof $Callable)) return value;
if (!$asyncExposed.has(value)) {
const invoke = args => {
$checkpoint(location);
return $call(value, args.map(argument => $input(argument, location)), location);
};
$asyncExposed.set(value, $asyncFunctions.has(value) ?
(...args) => Promise.resolve().then(() => $await(invoke(args), location)) : (...args) => {
const result = invoke(args);
return result instanceof $PromiseValue ? $await(result, location) : result;
});
}
return $asyncExposed.get(value);
}
'''),
'input': (('call', 'str', 'tuple'), r'''
function $input(value, location, depth = 0, seen = new Set()) {
Expand Down Expand Up @@ -248,6 +347,8 @@ class $JSArray { constructor(values) { this.values = values; } }
'types': ('$types', '$isinstance'), 'exception': ('$TypeError',),
'repr': ('$repr',), 'print': ('$print',),
'javascript': ('$JS', '$JArray', '$attribute'), 'input': ('$input', '$expose'),
'async': ('$checkpoint', '$await', '$cfn', '$PromiseRegistry', '$asyncAttribute'),
'async-expose': ('$asyncExpose',),
}


Expand Down
Loading
Loading