From 01c5374ca53f64d4b0e45d8d6e6350439710305a Mon Sep 17 00:00:00 2001 From: ellainng <299958882+ellainng@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:09:53 -0700 Subject: [PATCH] fix(ERR-01): preserve raise and await source origins --- IMPLEMENTATION_PLAN.md | 6 +++ docs/compiler-emitter.md | 7 +++ scripts/compiler_browser_smoke.py | 18 ++++++- src/JavaScript/_compiler_runtime.py | 23 ++++++--- src/JavaScript/emitter.py | 8 ++- tests/test_compiler_errors.py | 77 +++++++++++++++++++++++++++++ 6 files changed, 129 insertions(+), 10 deletions(-) create mode 100644 tests/test_compiler_errors.py diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 9be7779..ab1da57 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -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. diff --git a/docs/compiler-emitter.md b/docs/compiler-emitter.md index a2a995d..c0427b2 100644 --- a/docs/compiler-emitter.md +++ b/docs/compiler-emitter.md @@ -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. diff --git a/scripts/compiler_browser_smoke.py b/scripts/compiler_browser_smoke.py index 2c244fa..a0acb7f 100644 --- a/scripts/compiler_browser_smoke.py +++ b/scripts/compiler_browser_smoke.py @@ -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") @@ -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") @@ -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') diff --git a/src/JavaScript/_compiler_runtime.py b/src/JavaScript/_compiler_runtime.py index 8aff583..9ec12cf 100644 --- a/src/JavaScript/_compiler_runtime.py +++ b/src/JavaScript/_compiler_runtime.py @@ -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'); @@ -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; } } @@ -244,7 +255,7 @@ 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 => { @@ -252,7 +263,7 @@ class $PromiseValue { 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)); }); }); } @@ -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); @@ -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',), diff --git a/src/JavaScript/emitter.py b/src/JavaScript/emitter.py index 7820698..241b2fe 100644 --- a/src/JavaScript/emitter.py +++ b/src/JavaScript/emitter.py @@ -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) diff --git a/tests/test_compiler_errors.py b/tests/test_compiler_errors.py new file mode 100644 index 0000000..425c7bf --- /dev/null +++ b/tests/test_compiler_errors.py @@ -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)