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 @@ -463,3 +463,9 @@ ownership, native GET text Fetch, source errors and stop/restart. A runnable
text_metrics example and real-browser tests cover local/offline-after-load
computation and transport/resource failures. WEB-03 owns verified PWA assembly
and offline reload; QA-03 extends cross-target parity. No package was released.

[ERR-01](https://github.com/outside-labs/Python-JavaScript/issues/34) corrects
preconstructed exception origins before QA-03: explicit raises and first source
awaits replace constructor spans, while propagated runtime/callback errors retain
their origin. CPython/native/browser cases cover the correction and mapped
tracebacks. QA-03 now depends on this diagnostic fix as well as BROWSER-01.
7 changes: 7 additions & 0 deletions docs/compiler-emitter.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,10 @@ precision/Unicode/tuple/default behavior and an independent Node source-map
consumer. Local modules, async browser capabilities and the final browser parity
gate remain MOD-01, ASYNC-01/BROWSER-01 and QA-03. This compiler executes only
ahead-of-time approved source; it does not parse new source in the browser.

Exception construction does not start a traceback. An explicit raise records
its statement span; rejecting a Promise with an unraised exception records the
first source await. Errors already raised in a function/callback keep that
origin when propagated through awaits. Exception values retain identity. Native
stack capture, where available, maps explicit raises to their statement; full
Python call-frame construction remains separately staged.
18 changes: 16 additions & 2 deletions scripts/compiler_browser_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@


ROOT = Path(__file__).resolve().parents[1]
APP = '''from JavaScript.browser import read_text, write_text, on_click, unsubscribe, fetch_text
APP = '''from JavaScript import Promise
from JavaScript.browser import read_text, write_text, on_click, unsubscribe, fetch_text
from text_metrics import metrics
async def run(text):
current = await read_text("input")
Expand All @@ -32,6 +33,13 @@ async def load(text):
async def clear(text):
await write_text("result", "")
async def fail(text):
if text == "raise":
error = TypeError("explicit value")
raise error
if text == "reject":
error = TypeError("rejected value")
pending = Promise.reject(error)
await pending
return 1 // 0
async def busy(text):
await write_text("status", "busy")
Expand Down Expand Up @@ -204,8 +212,14 @@ def smoke(channel=None):
page.evaluate('window.start()');page.wait_for_function('window.controller.stats().subscriptions===5')
page.click('#fail');page.wait_for_function('window.failures.length===1')
error = page.evaluate('window.failures[0]')
assert error['type'] == 'ZeroDivisionError' and error['location']['line'] == 14, error
assert error['type'] == 'ZeroDivisionError' and error['location']['line'] == next(i for i,line in enumerate(APP.splitlines(),1) if line.strip()=='return 1 // 0'), error
checks.append('python-error-source-location')
for value, statement in [('raise','raise error'),('reject','await pending')]:
page.evaluate('window.start()');page.wait_for_function('window.controller.stats().subscriptions===5')
page.fill('#input',value);page.click('#fail');page.wait_for_function('window.failures.length===1')
error=page.evaluate('window.failures[0]')
assert error['type']=='TypeError' and error['location']['line']==next(i for i,line in enumerate(APP.splitlines(),1) if line.strip()==statement),error
checks.append('preconstructed-raise-and-rejection-origins')
sessions = []
for _ in range(3):
page.evaluate('window.start()');page.wait_for_function('window.controller.stats().subscriptions===5')
Expand Down
23 changes: 17 additions & 6 deletions src/JavaScript/_compiler_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,18 @@
class $PyError extends Error {
constructor(name, message, location) { super(message); this.name = name; this.location = location; }
}
function $fail(name, message, location) { throw new $PyError(name, message, location); }
const $raisedErrors = new WeakSet();
function $markRaised(error, location) {
if (!$raisedErrors.has(error)) { error.location = {...location}; $raisedErrors.add(error); }
return error;
}
function $raise(error, location) {
if (!(error instanceof $PyError)) $fail('TypeError', 'raise requires an adopted exception.', location);
error.location = {...location}; $raisedErrors.add(error);
if (typeof Error.captureStackTrace === 'function') Error.captureStackTrace(error, $raise);
throw error;
}
function $fail(name, message, location) { $raise(new $PyError(name, message, location), location); }
'''),
'binding': (('error',), r'''
const $UNBOUND = Symbol('unbound');
Expand Down Expand Up @@ -226,7 +237,7 @@ class $JSArray { constructor(values) { this.values = values; } }
const error = new $PyError('AbortError', 'Compiled operation cancelled.', location);
error.code = 'CANCELLED'; return error;
}
function $checkpoint(location) { if (signal?.aborted) throw $cancelled(location); }
function $checkpoint(location) { if (signal?.aborted) $raise($cancelled(location), location); }
class $Coroutine {
constructor(start) { this.start = start; this.started = false; }
}
Expand All @@ -244,15 +255,15 @@ class $PromiseValue {
return new Promise((resolve, reject) => {
let finished = false;
const cleanup = () => signal?.removeEventListener('abort', abort);
const abort = () => { if (!finished) { finished = true; cleanup(); reject($cancelled(location)); } };
const abort = () => { if (!finished) { finished = true; cleanup(); reject($markRaised($cancelled(location), 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));
reject($markRaised(error instanceof $PyError ? error : new $PyError('RuntimeError', 'Unhandled Promise rejection.', location), location));
});
});
}
Expand Down Expand Up @@ -349,7 +360,7 @@ class $HostHandle { constructor(wire) { this.wire=Object.freeze({...wire}); Obje
unsubscribe: $fn('unsubscribe',1,[],(args,where)=>{
const handle=args[0];
if (!(handle instanceof $HostHandle) || !$subscriptions.has(handle)) {
const error=new $PyError('HostError','Subscription handle is stale.',where);error.code='STALE_HANDLE';throw error;
const error=new $PyError('HostError','Subscription handle is stale.',where);error.code='STALE_HANDLE';$raise(error,where);
}
return $request('events.unsubscribe',{subscription:handle.wire},where,value=>{
host.releaseCallback($subscriptions.get(handle));$subscriptions.delete(handle);return $hostDecode(value,where);
Expand Down Expand Up @@ -404,7 +415,7 @@ class $HostHandle { constructor(wire) { this.wire=Object.freeze({...wire}); Obje


HELPER_BINDINGS = {
'error': ('$PyError', '$fail'), 'binding': ('$UNBOUND', '$get'),
'error': ('$PyError', '$fail', '$raise'), 'binding': ('$UNBOUND', '$get'),
'str': ('$str',), 'tuple': ('$tuple',), 'numeric': ('$number',),
'truth': ('$truth',), 'binary': ('$binary',), 'unary': ('$unary',),
'equal': ('$equal', '$order'), 'compare': ('$compare',), 'index': ('$index',),
Expand Down
8 changes: 6 additions & 2 deletions src/JavaScript/emitter.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,8 +194,12 @@ def statement(self, node, indent):
self.add(f'{self.names[target]}={temporary};', indent, node)
elif kind in {'expression', 'return', 'raise'}:
value = self.expression(fields['value']) if fields['value'] else 'null'
prefix = {'expression': '', 'return': 'return ', 'raise': 'throw '}[kind]
self.add(prefix + value + ';', indent, node)
if kind == 'raise':
self.use('error')
self.add(f'$raise({value},{self.location(node)});', indent, node)
else:
prefix = 'return ' if kind == 'return' else ''
self.add(prefix + value + ';', indent, node)
elif kind == 'if':
self.use('truth')
self.add(f'if($truth({self.expression(fields["test"])},{self.location(node)})){{', indent, node)
Expand Down
77 changes: 77 additions & 0 deletions tests/test_compiler_errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import json
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest

from JavaScript.emitter import compile_source
from JavaScript.modules import SourceModule, compile_modules
from test_compiler_async import run_async
from test_compiler_emitter import run_node


class CompilerErrorOriginTests(unittest.TestCase):
def assert_origin(self, source, *, asynchronous=False):
oracle = '''import contextlib,io,json,traceback
source = ''' + repr(source) + '''
namespace={};output=io.StringIO()
try:
with contextlib.redirect_stdout(output):
exec(compile(source,'origin.py','exec'),namespace)
'''
if asynchronous:
oracle += " import asyncio\n asyncio.run(namespace['main']())\n"
oracle += '''except Exception as error:
frame=[frame for frame in traceback.extract_tb(error.__traceback__) if frame.filename=='origin.py'][-1]
print(json.dumps({'stdout':output.getvalue(),'name':type(error).__name__,'message':str(error),'line':frame.lineno}))
'''
expected = json.loads(subprocess.check_output([sys.executable, '-I', '-X', 'utf8', '-c', oracle], text=True, encoding='utf-8', timeout=10))
artifact = compile_source(source, filename='origin.py', async_enabled=asynchronous)
actual = run_async(artifact) if asynchronous else run_node(artifact)
self.assertEqual(actual['stdout'], expected['stdout'])
self.assertEqual(actual['error']['name'], expected['name'])
self.assertEqual(actual['error']['message'], expected['message'])
self.assertEqual(actual['error']['location']['filename'], 'origin.py')
self.assertEqual(actual['error']['location']['line'], expected['line'])
return actual

def test_preconstructed_exception_reports_raise_instead_of_constructor(self):
actual = self.assert_origin('def selected():\n error=TypeError("bad")\n print(error is error)\n raise error\nselected()')
self.assertEqual(actual['error']['location']['line'], 4)
self.assertEqual(actual['error']['location']['column'], 5)

def test_promise_value_origin_and_prior_callback_runtime_origins(self):
cases = [
'from JavaScript import Promise\nasync def main():\n error=TypeError("rejected")\n pending=Promise.reject(error)\n await pending',
'from JavaScript import Promise\ndef callback(value):\n print("once")\n error=TypeError("callback")\n raise error\nasync def main():\n await Promise.resolve(1).then(callback)',
'async def inner():\n return 1//0\nasync def main():\n return await inner()',
'from JavaScript import Promise\ndef identity(value):\n return value\nasync def main():\n error=TypeError("same object")\n observed=await Promise.reject(error).then(None,identity)\n print(error is observed)\n raise error',
]
for source in cases:
with self.subTest(source=source):
self.assert_origin(source, asynchronous=True)

def test_shared_runtime_retains_callback_module_origin(self):
sources = (SourceModule.from_text('app', 'from JavaScript import Promise\nfrom helper import callback\nasync def main():\n await Promise.resolve(1).then(callback)'),
SourceModule.from_text('helper', 'def callback(value):\n error=TypeError("module")\n raise error'))
result = run_async(compile_modules(sources, entrypoint='app', async_enabled=True))
self.assertEqual(result['error']['location']['filename'], 'helper.py')
self.assertEqual(result['error']['location']['line'], 3)

def test_native_traceback_maps_to_the_raise_statement(self):
source = 'def selected():\n error=TypeError("bad")\n raise error\nselected()'
artifact = compile_source(source, filename='origin.py')
runner = '''import {createModule} from './app.mjs';import {SourceMap} from 'node:module';import {readFile} from 'node:fs/promises';
const map=new SourceMap(JSON.parse(await readFile(new URL('./app.mjs.map',import.meta.url))));
try{createModule()}catch(error){const match=error.stack.match(/app\\.mjs:(\\d+):(\\d+)/);console.log(JSON.stringify(map.findEntry(Number(match[1])-1,Number(match[2])-1)));}
'''
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
for item in artifact.files:
(root / item.path).write_bytes(item.data)
(root / 'run.mjs').write_text(runner)
result = json.loads(subprocess.check_output(['node', str(root / 'run.mjs')], text=True, timeout=10))
self.assertEqual(result['originalSource'], 'origin.py')
self.assertEqual(result['originalLine'], 2)
self.assertEqual(result['originalColumn'], 4)
Loading