Skip to content

Commit 01c5374

Browse files
committed
fix(ERR-01): preserve raise and await source origins
1 parent 51198ae commit 01c5374

6 files changed

Lines changed: 129 additions & 10 deletions

File tree

‎IMPLEMENTATION_PLAN.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,3 +463,9 @@ ownership, native GET text Fetch, source errors and stop/restart. A runnable
463463
text_metrics example and real-browser tests cover local/offline-after-load
464464
computation and transport/resource failures. WEB-03 owns verified PWA assembly
465465
and offline reload; QA-03 extends cross-target parity. No package was released.
466+
467+
[ERR-01](https://github.com/outside-labs/Python-JavaScript/issues/34) corrects
468+
preconstructed exception origins before QA-03: explicit raises and first source
469+
awaits replace constructor spans, while propagated runtime/callback errors retain
470+
their origin. CPython/native/browser cases cover the correction and mapped
471+
tracebacks. QA-03 now depends on this diagnostic fix as well as BROWSER-01.

‎docs/compiler-emitter.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,3 +89,10 @@ precision/Unicode/tuple/default behavior and an independent Node source-map
8989
consumer. Local modules, async browser capabilities and the final browser parity
9090
gate remain MOD-01, ASYNC-01/BROWSER-01 and QA-03. This compiler executes only
9191
ahead-of-time approved source; it does not parse new source in the browser.
92+
93+
Exception construction does not start a traceback. An explicit raise records
94+
its statement span; rejecting a Promise with an unraised exception records the
95+
first source await. Errors already raised in a function/callback keep that
96+
origin when propagated through awaits. Exception values retain identity. Native
97+
stack capture, where available, maps explicit raises to their statement; full
98+
Python call-frame construction remains separately staged.

‎scripts/compiler_browser_smoke.py‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919

2020

2121
ROOT = Path(__file__).resolve().parents[1]
22-
APP = '''from JavaScript.browser import read_text, write_text, on_click, unsubscribe, fetch_text
22+
APP = '''from JavaScript import Promise
23+
from JavaScript.browser import read_text, write_text, on_click, unsubscribe, fetch_text
2324
from text_metrics import metrics
2425
async def run(text):
2526
current = await read_text("input")
@@ -32,6 +33,13 @@ async def load(text):
3233
async def clear(text):
3334
await write_text("result", "")
3435
async def fail(text):
36+
if text == "raise":
37+
error = TypeError("explicit value")
38+
raise error
39+
if text == "reject":
40+
error = TypeError("rejected value")
41+
pending = Promise.reject(error)
42+
await pending
3543
return 1 // 0
3644
async def busy(text):
3745
await write_text("status", "busy")
@@ -204,8 +212,14 @@ def smoke(channel=None):
204212
page.evaluate('window.start()');page.wait_for_function('window.controller.stats().subscriptions===5')
205213
page.click('#fail');page.wait_for_function('window.failures.length===1')
206214
error = page.evaluate('window.failures[0]')
207-
assert error['type'] == 'ZeroDivisionError' and error['location']['line'] == 14, error
215+
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
208216
checks.append('python-error-source-location')
217+
for value, statement in [('raise','raise error'),('reject','await pending')]:
218+
page.evaluate('window.start()');page.wait_for_function('window.controller.stats().subscriptions===5')
219+
page.fill('#input',value);page.click('#fail');page.wait_for_function('window.failures.length===1')
220+
error=page.evaluate('window.failures[0]')
221+
assert error['type']=='TypeError' and error['location']['line']==next(i for i,line in enumerate(APP.splitlines(),1) if line.strip()==statement),error
222+
checks.append('preconstructed-raise-and-rejection-origins')
209223
sessions = []
210224
for _ in range(3):
211225
page.evaluate('window.start()');page.wait_for_function('window.controller.stats().subscriptions===5')

‎src/JavaScript/_compiler_runtime.py‎

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,18 @@
99
class $PyError extends Error {
1010
constructor(name, message, location) { super(message); this.name = name; this.location = location; }
1111
}
12-
function $fail(name, message, location) { throw new $PyError(name, message, location); }
12+
const $raisedErrors = new WeakSet();
13+
function $markRaised(error, location) {
14+
if (!$raisedErrors.has(error)) { error.location = {...location}; $raisedErrors.add(error); }
15+
return error;
16+
}
17+
function $raise(error, location) {
18+
if (!(error instanceof $PyError)) $fail('TypeError', 'raise requires an adopted exception.', location);
19+
error.location = {...location}; $raisedErrors.add(error);
20+
if (typeof Error.captureStackTrace === 'function') Error.captureStackTrace(error, $raise);
21+
throw error;
22+
}
23+
function $fail(name, message, location) { $raise(new $PyError(name, message, location), location); }
1324
'''),
1425
'binding': (('error',), r'''
1526
const $UNBOUND = Symbol('unbound');
@@ -226,7 +237,7 @@ class $JSArray { constructor(values) { this.values = values; } }
226237
const error = new $PyError('AbortError', 'Compiled operation cancelled.', location);
227238
error.code = 'CANCELLED'; return error;
228239
}
229-
function $checkpoint(location) { if (signal?.aborted) throw $cancelled(location); }
240+
function $checkpoint(location) { if (signal?.aborted) $raise($cancelled(location), location); }
230241
class $Coroutine {
231242
constructor(start) { this.start = start; this.started = false; }
232243
}
@@ -244,15 +255,15 @@ class $PromiseValue {
244255
return new Promise((resolve, reject) => {
245256
let finished = false;
246257
const cleanup = () => signal?.removeEventListener('abort', abort);
247-
const abort = () => { if (!finished) { finished = true; cleanup(); reject($cancelled(location)); } };
258+
const abort = () => { if (!finished) { finished = true; cleanup(); reject($markRaised($cancelled(location), location)); } };
248259
signal?.addEventListener('abort', abort, {once: true});
249260
if (signal?.aborted) { abort(); return; }
250261
promise.then(value => {
251262
if (finished) return; finished = true; cleanup();
252263
try { $checkpoint(location); resolve(value); } catch (error) { reject(error); }
253264
}, error => {
254265
if (finished) return; finished = true; cleanup();
255-
reject(error instanceof $PyError ? error : new $PyError('RuntimeError', 'Unhandled Promise rejection.', location));
266+
reject($markRaised(error instanceof $PyError ? error : new $PyError('RuntimeError', 'Unhandled Promise rejection.', location), location));
256267
});
257268
});
258269
}
@@ -349,7 +360,7 @@ class $HostHandle { constructor(wire) { this.wire=Object.freeze({...wire}); Obje
349360
unsubscribe: $fn('unsubscribe',1,[],(args,where)=>{
350361
const handle=args[0];
351362
if (!(handle instanceof $HostHandle) || !$subscriptions.has(handle)) {
352-
const error=new $PyError('HostError','Subscription handle is stale.',where);error.code='STALE_HANDLE';throw error;
363+
const error=new $PyError('HostError','Subscription handle is stale.',where);error.code='STALE_HANDLE';$raise(error,where);
353364
}
354365
return $request('events.unsubscribe',{subscription:handle.wire},where,value=>{
355366
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
404415

405416

406417
HELPER_BINDINGS = {
407-
'error': ('$PyError', '$fail'), 'binding': ('$UNBOUND', '$get'),
418+
'error': ('$PyError', '$fail', '$raise'), 'binding': ('$UNBOUND', '$get'),
408419
'str': ('$str',), 'tuple': ('$tuple',), 'numeric': ('$number',),
409420
'truth': ('$truth',), 'binary': ('$binary',), 'unary': ('$unary',),
410421
'equal': ('$equal', '$order'), 'compare': ('$compare',), 'index': ('$index',),

‎src/JavaScript/emitter.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -194,8 +194,12 @@ def statement(self, node, indent):
194194
self.add(f'{self.names[target]}={temporary};', indent, node)
195195
elif kind in {'expression', 'return', 'raise'}:
196196
value = self.expression(fields['value']) if fields['value'] else 'null'
197-
prefix = {'expression': '', 'return': 'return ', 'raise': 'throw '}[kind]
198-
self.add(prefix + value + ';', indent, node)
197+
if kind == 'raise':
198+
self.use('error')
199+
self.add(f'$raise({value},{self.location(node)});', indent, node)
200+
else:
201+
prefix = 'return ' if kind == 'return' else ''
202+
self.add(prefix + value + ';', indent, node)
199203
elif kind == 'if':
200204
self.use('truth')
201205
self.add(f'if($truth({self.expression(fields["test"])},{self.location(node)})){{', indent, node)

‎tests/test_compiler_errors.py‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import json
2+
from pathlib import Path
3+
import subprocess
4+
import sys
5+
import tempfile
6+
import unittest
7+
8+
from JavaScript.emitter import compile_source
9+
from JavaScript.modules import SourceModule, compile_modules
10+
from test_compiler_async import run_async
11+
from test_compiler_emitter import run_node
12+
13+
14+
class CompilerErrorOriginTests(unittest.TestCase):
15+
def assert_origin(self, source, *, asynchronous=False):
16+
oracle = '''import contextlib,io,json,traceback
17+
source = ''' + repr(source) + '''
18+
namespace={};output=io.StringIO()
19+
try:
20+
with contextlib.redirect_stdout(output):
21+
exec(compile(source,'origin.py','exec'),namespace)
22+
'''
23+
if asynchronous:
24+
oracle += " import asyncio\n asyncio.run(namespace['main']())\n"
25+
oracle += '''except Exception as error:
26+
frame=[frame for frame in traceback.extract_tb(error.__traceback__) if frame.filename=='origin.py'][-1]
27+
print(json.dumps({'stdout':output.getvalue(),'name':type(error).__name__,'message':str(error),'line':frame.lineno}))
28+
'''
29+
expected = json.loads(subprocess.check_output([sys.executable, '-I', '-X', 'utf8', '-c', oracle], text=True, encoding='utf-8', timeout=10))
30+
artifact = compile_source(source, filename='origin.py', async_enabled=asynchronous)
31+
actual = run_async(artifact) if asynchronous else run_node(artifact)
32+
self.assertEqual(actual['stdout'], expected['stdout'])
33+
self.assertEqual(actual['error']['name'], expected['name'])
34+
self.assertEqual(actual['error']['message'], expected['message'])
35+
self.assertEqual(actual['error']['location']['filename'], 'origin.py')
36+
self.assertEqual(actual['error']['location']['line'], expected['line'])
37+
return actual
38+
39+
def test_preconstructed_exception_reports_raise_instead_of_constructor(self):
40+
actual = self.assert_origin('def selected():\n error=TypeError("bad")\n print(error is error)\n raise error\nselected()')
41+
self.assertEqual(actual['error']['location']['line'], 4)
42+
self.assertEqual(actual['error']['location']['column'], 5)
43+
44+
def test_promise_value_origin_and_prior_callback_runtime_origins(self):
45+
cases = [
46+
'from JavaScript import Promise\nasync def main():\n error=TypeError("rejected")\n pending=Promise.reject(error)\n await pending',
47+
'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)',
48+
'async def inner():\n return 1//0\nasync def main():\n return await inner()',
49+
'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',
50+
]
51+
for source in cases:
52+
with self.subTest(source=source):
53+
self.assert_origin(source, asynchronous=True)
54+
55+
def test_shared_runtime_retains_callback_module_origin(self):
56+
sources = (SourceModule.from_text('app', 'from JavaScript import Promise\nfrom helper import callback\nasync def main():\n await Promise.resolve(1).then(callback)'),
57+
SourceModule.from_text('helper', 'def callback(value):\n error=TypeError("module")\n raise error'))
58+
result = run_async(compile_modules(sources, entrypoint='app', async_enabled=True))
59+
self.assertEqual(result['error']['location']['filename'], 'helper.py')
60+
self.assertEqual(result['error']['location']['line'], 3)
61+
62+
def test_native_traceback_maps_to_the_raise_statement(self):
63+
source = 'def selected():\n error=TypeError("bad")\n raise error\nselected()'
64+
artifact = compile_source(source, filename='origin.py')
65+
runner = '''import {createModule} from './app.mjs';import {SourceMap} from 'node:module';import {readFile} from 'node:fs/promises';
66+
const map=new SourceMap(JSON.parse(await readFile(new URL('./app.mjs.map',import.meta.url))));
67+
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)));}
68+
'''
69+
with tempfile.TemporaryDirectory() as temporary:
70+
root = Path(temporary)
71+
for item in artifact.files:
72+
(root / item.path).write_bytes(item.data)
73+
(root / 'run.mjs').write_text(runner)
74+
result = json.loads(subprocess.check_output(['node', str(root / 'run.mjs')], text=True, timeout=10))
75+
self.assertEqual(result['originalSource'], 'origin.py')
76+
self.assertEqual(result['originalLine'], 2)
77+
self.assertEqual(result['originalColumn'], 4)

0 commit comments

Comments
 (0)