diff --git a/docs/interpreter-generators.md b/docs/interpreter-generators.md index 9a3f1a4..9823d36 100644 --- a/docs/interpreter-generators.md +++ b/docs/interpreter-generators.md @@ -37,8 +37,8 @@ See the [yield expression and generator method reference](https://docs.python.or ## Acceptance -The corpus contains 28 adopted class comparisons and three explicit refusals; -the shared core selection has 25 comparisons and three refusals. CI runs all +The corpus contains 33 adopted class comparisons and three explicit refusals; +the shared core selection has 30 comparisons and three refusals. CI runs all eight target/profile/driver combinations and requires zero logical teardown. Dedicated tests collect between every instruction, retain suspended aliases and cycles, and interrupt execution in generator bodies and comprehension callbacks. @@ -49,3 +49,7 @@ Run `python -m examples.interpreter_generators` to build a composed console; new source after reload, method/body errors, hard Stop/Restart and waiting class-to-core updates. Assignment expressions in comprehensions, multi-argument throw, generator frame introspection and async retain their future boundaries. + +Injected generator exceptions retain the fresh definition or suspended yield +frame, delegated yield-from frames and active exception context. The corpus +checks these frame names and source lines against the pinned executable oracle. diff --git a/runtime/check_classes.py b/runtime/check_classes.py index 0a5b94f..ceb8f0d 100644 --- a/runtime/check_classes.py +++ b/runtime/check_classes.py @@ -43,6 +43,10 @@ def references(corpus_path=CORPUS): passed = passed and actual['stdout'] == case.get('stdout', '') if 'error_type' in case: passed = passed and actual.get('error', {}).get('type') == case['error_type'] + if 'error_frames' in case: + passed = passed and actual.get('error', {}).get('frames') == case['error_frames'] + if 'error_context_type' in case: + passed = passed and (actual.get('error', {}).get('context') or {}).get('type') == case['error_context_type'] records.append({'id': case['id'], 'stage': case['stage'], 'reference_status': 'pass' if passed else 'fail', 'reference': actual}) return {'schema_version': 1, 'suite': corpus.get('suite', 'interpreter-classes/1'), 'profile': corpus['profile'], @@ -89,6 +93,12 @@ def check(target, channel=None, corpus_path=CORPUS, stages=None, stepped=False, passed = actual['ok'] == expected['ok'] and actual['stdout'] == expected['stdout'] if not expected['ok']: passed = passed and actual.get('error', {}).get('type') == expected['error']['type'] + if 'error_frames' in case: + frames = [{'function': frame['function'], 'line': frame['location']['span'][0] if frame.get('location') else None} + for frame in actual.get('error', {}).get('traceback', [])] + passed = passed and frames == case['error_frames'] + if 'error_context_type' in case: + passed = passed and (actual.get('error', {}).get('context') or {}).get('type') == case['error_context_type'] classification = 'reference' else: passed = not actual['ok'] and actual.get('error', {}).get('code') == 'UNSUPPORTED' diff --git a/runtime/functions.mjs b/runtime/functions.mjs index bebac1f..66a26a2 100644 --- a/runtime/functions.mjs +++ b/runtime/functions.mjs @@ -190,7 +190,7 @@ function activation(program, {maxSteps = 10_000, maxWork = 1_000_000, maxDepth = const {dispatch, call, deliver, notifyNext, recover, argumentOwner, appendArgument, appendKeyword, expandArguments, keywordArgument, expandMapping, finishArguments, expandDisplay, expandUnpack} = operationRuntime({state, memory, values, classes, moduleContext, maxDepth, allocate, mutate, python, hard, charge, enter, bind, block, store, onOutput, resumeGenerator: (...args) => resumeGenerator(...args), yieldGenerator: value => yieldGenerator(value), - delegateResult: (...args) => delegateResult(...args)}); + delegateResult: (...args) => delegateResult(...args), injectGenerator: value => injectGenerator(value)}); const leaveGenerator = (frame, status) => { const generator = frame.generator, completion = frame.completion; state.temporaries.push(generator, ...completionRoots(completion)); @@ -198,13 +198,15 @@ function activation(program, {maxSteps = 10_000, maxWork = 1_000_000, maxDepth = if (!frame.ownsException) frame.activeException = null; for (const task of frame.tasks) if (task.type === "try" && !task.previousOwned) task.previous = null; generator.status = status; generator.frame = frame; - frame.leave(); state.frames.pop(); + frame.leave(); frame.leave = null; state.frames.pop(); return completion; }; const yieldGenerator = value => { const frame = state.frames.at(-1); if (!frame.generator) hard("BYTECODE", "BytecodeError", "Yield requires a generator frame."); state.temporaries.push(value); + const task = frame.tasks.at(-1); + frame.suspensionSite = task.type === "delegate" ? task.site : {function: frame.fn.name, location: state.current.location}; const mode = frame.generatorMode, site = frame.callsite; const completion = leaveGenerator(frame, "suspended"); if (mode === "close") python("RuntimeError", "generator ignored GeneratorExit"); @@ -215,6 +217,27 @@ function activation(program, {maxSteps = 10_000, maxWork = 1_000_000, maxDepth = if (iterator instanceof InstanceValue) return classes.read(iterator, name); python("AttributeError", "Delegate has no attribute '" + name + "'"); }; + const injectGenerator = value => { + const frame = state.frames.at(-1); + addTrace(value, frame.suspensionSite); + if (frame.ownsException && frame.activeException && frame.activeException !== value) mutate(value, () => { + const before = value.context; value.context = frame.activeException; + return () => { value.context = before; }; + }); + handle(new Raised(value)); + }; + const definitionSite = callable => { + for (const fn of callable.pool.code.functions) for (const block of fn.blocks) { + for (let index = 0; index < block.instructions.length; index++) { + charge(1); + const [op, target] = block.instructions[index]; + if (["function", "function-defaults"].includes(op) && target === callable.index) + return {function: callable.pool.code.functions[callable.index].name, + location: block.source ? {filename: block.source.filename, span: block.source.spans[index]} : null}; + } + } + return {function: callable.pool.code.functions[callable.index].name, location: null}; + }; const delegateAdvance = (mode, value) => { const frame = state.frames.at(-1), task = frame.tasks.at(-1), site = task.site; if (mode === "throw" && value.name === "GeneratorExit") mode = "close"; @@ -225,8 +248,8 @@ function activation(program, {maxSteps = 10_000, maxWork = 1_000_000, maxDepth = try { plan = delegateMethod(task.iterator, mode); } catch (error) { if (!(error instanceof Raised) || error.value.name !== "AttributeError" || mode === "send") throw error; - if (mode === "close") { handle(new Raised(task.closing)); return; } - handle(new Raised(value)); return; + if (mode === "close") { injectGenerator(task.closing); return; } + injectGenerator(value); return; } const args = mode === "close" ? [] : [value]; if (plan.kind === "value") call(plan.value, args, [], [], site, finish); @@ -238,7 +261,7 @@ function activation(program, {maxSteps = 10_000, maxWork = 1_000_000, maxDepth = const frame = state.frames.at(-1), task = frame.tasks.at(-1); if (kind === "delegate-iter") { task.iterator = value; delegateAdvance("send", null); } else if (kind === "delegate-next") yieldGenerator(value); - else if (kind === "delegate-close") handle(new Raised(task.closing)); + else if (kind === "delegate-close") injectGenerator(task.closing); else { frame.tasks.pop(); frame.tasks.at(-1).stack.push(value); } return null; }; @@ -251,9 +274,10 @@ function activation(program, {maxSteps = 10_000, maxWork = 1_000_000, maxDepth = const fresh = generator.status === "created"; if (fresh && mode === "send" && value !== null) python("TypeError", "can't send non-None value to a just-started generator"); if (generator.status === "closed" || fresh && mode !== "send") { + if (fresh && mode === "throw") addTrace(value, definitionSite(generator.callable)); generator.status = "closed"; generator.callable = null; generator.bindings.clear(); if (mode === "close") { dispatch(deliver(null, completion, site)); return; } - if (mode === "throw") throw new Raised(value); + if (mode === "throw") { addTrace(value, site); throw new Raised(value); } python("StopIteration", ""); } state.temporaries.push(generator, value, ...completionRoots(completion)); @@ -275,8 +299,8 @@ function activation(program, {maxSteps = 10_000, maxWork = 1_000_000, maxDepth = generator.status = "running"; const frame = generator.frame; frame.generatorMode = mode; if (frame.tasks.at(-1).type === "delegate") delegateAdvance(mode, value); - else if (mode === "close") handle(new Raised(allocate(ExceptionValue, "GeneratorExit", [], ""))); - else if (mode === "throw") handle(new Raised(value)); + else if (mode === "close") injectGenerator(allocate(ExceptionValue, "GeneratorExit", [], "")); + else if (mode === "throw") injectGenerator(value); else if (!fresh) frame.tasks.at(-1).stack.push(value); }; const diagnostic = value => { diff --git a/runtime/operation-runtime.mjs b/runtime/operation-runtime.mjs index f73a934..020ed8a 100644 --- a/runtime/operation-runtime.mjs +++ b/runtime/operation-runtime.mjs @@ -7,7 +7,7 @@ export class Raised { constructor(value) { this.value = value; } } // Fixed phases of the existing machine. Python callbacks enter its ordinary // frames; destinations use its block stack and single unwinder. export function operationRuntime({state, memory, values, classes, moduleContext, maxDepth, - allocate, mutate, python, hard, charge, enter, bind, block, store, onOutput, resumeGenerator, yieldGenerator, delegateResult}) { + allocate, mutate, python, hard, charge, enter, bind, block, store, onOutput, resumeGenerator, yieldGenerator, delegateResult, injectGenerator}) { const count = tag => { state.transitions[tag] = (state.transitions[tag] || 0) + 1; }; const display = value => { if (value instanceof NotImplementedValue) return "NotImplemented"; @@ -668,7 +668,7 @@ export function operationRuntime({state, memory, values, classes, moduleContext, count("dispatch:" + task.kind); let {callable, args, keywords, keywordValues, site, completion, depth} = task; try { - if (task.kind === "unary" && task.operation === "delegate-raise") throw new Raised(task.value); + if (task.kind === "unary" && task.operation === "delegate-raise") { injectGenerator(task.value); return; } if (task.kind === "unary" && task.operation === "unpack-short") { state.temporaries = operationRoots(task); python("ValueError", "Not enough values to unpack"); } diff --git a/src/pocket_python/_interpreter_lock.py b/src/pocket_python/_interpreter_lock.py index a148b40..4f9d14e 100644 --- a/src/pocket_python/_interpreter_lock.py +++ b/src/pocket_python/_interpreter_lock.py @@ -1,6 +1,6 @@ """Known source-console assets, canonical LF bytes; refresh with scripts/lock_interpreter.py.""" -ENGINE_FINGERPRINT = '838cf574d88d26ca28e945fd3fa077e7c98bf4e19a4bb202429d54beecbef697' +ENGINE_FINGERPRINT = '0482efe29e46d5f1228ee6d7ce214f1f4168729a55241e4995502e9587cb3ec2' ENGINE_FILES = ( ('browser-protocol.mjs', 10589, '920e8d07c7070995636dff40fdf4333afc9bd4143483db5d8dff82ec1f28ff76'), ('bytecode.mjs', 6836, 'e76fabeea879bc1da8016283f7e74df5974479666b100199c2d3c31a53b8ecf4'), @@ -8,7 +8,7 @@ ('class-values.mjs', 15372, '67fc606a6c91f7aeb1d4309cc4a0be33778181cdccb51190e5f76d4969d9b43d'), ('function-bytecode.mjs', 20438, 'ca6b3abd73d2be35422eea244b876a851bf98e38836e31a33fa6d18d4252ae68'), ('function-objects.mjs', 4277, '06af0ca936de1dd336cb7a2ac13f24efae960ea3fe448c93fb95a2e92213c4e2'), - ('functions.mjs', 56333, '4450009703952a1dab3c62a94dd84aadeee9c0f2fee0a31db44495eddcc20e96'), + ('functions.mjs', 57726, '7d6e4322c31187b12387695dd094a0fd4dab2220cf28c1df2bb4856ccf7ae179'), ('interpreter-host.mjs', 7019, '0bb0a64faffcf77e0064258e4cfcaa7a937c4372129849f76901bee91b5a5ae2'), ('interpreter-worker.mjs', 8282, '937a97cc49d987492de0bc16021a4a804ef51e4ea5b398f79c92e5d30ac82558'), ('library-catalog.mjs', 907, '0adcaae0f6f07b4e1acb22e46493ec99f7247a5828b426c703530d8b2736032c'), @@ -20,7 +20,7 @@ ('module-registry.mjs', 10085, 'f23f8313c0804a052cba155e193df920b0221ef978a06f16c1a22f208dfbef91'), ('module-session.mjs', 1470, '08e4ccf16361299c15b99621fdfcef0d9ef93de50d28d738158c7bc85a7c23a3'), ('numeric.mjs', 13954, '2431f099a7d40aa38d6b3c68b556207752e4191211e01005acde94325bd91bad'), - ('operation-runtime.mjs', 93126, '18aff2a37a30c3fe0deebb51dae85f8200f9674588308941f5147ae10c809c9f'), + ('operation-runtime.mjs', 93154, '488791c8e00d8e510feaf9bc654f06a6e0bcaed7f8ed6a2024a1a23140713315'), ('operations.mjs', 23452, 'bd4c064811e6400576cae60bfcdbd35a76c829a25085794f95520d6307fad78e'), ('source-compiler.mjs', 35239, '4201a25a0c45b8fba4777c8e98b75e37f3277888708093a77e2827dec5ff7063'), ('source-identifiers.mjs', 25949, '1e4e6a90728a2d9393eb4b2055d4bd759542f4768d1870b508d629c6888e8914'), diff --git a/tests/fixtures/interpreter/generators.json b/tests/fixtures/interpreter/generators.json index 03f17eb..0a48922 100644 --- a/tests/fixtures/interpreter/generators.json +++ b/tests/fixtures/interpreter/generators.json @@ -307,6 +307,112 @@ "source": "class D:\n def __iter__(self): return self\n def __next__(self): return 1\n @property\n def throw(self): raise AttributeError(\"missing\")\ndef g(): yield from D()\na=g()\nprint(next(a))\ntry: a.throw(ValueError(\"original\"))\nexcept ValueError as e: print(str(e))\n", "stdout": "1\noriginal\n", "stage": "G1" + }, + { + "id": "injected-yield-trace", + "stage": "G1", + "source": "def g():\n yield 1\na=g()\nnext(a)\na.throw(ValueError(\"injected\"))\n", + "error_type": "ValueError", + "error_frames": [ + { + "function": "", + "line": 5 + }, + { + "function": "g", + "line": 2 + } + ], + "profiles": [ + "classes-source/1", + "core-source/1" + ] + }, + { + "id": "injected-fresh-trace", + "stage": "G1", + "source": "def g():\n yield 1\ng().throw(ValueError(\"fresh\"))\n", + "error_type": "ValueError", + "error_frames": [ + { + "function": "", + "line": 3 + }, + { + "function": "g", + "line": 1 + } + ], + "profiles": [ + "classes-source/1", + "core-source/1" + ] + }, + { + "id": "injected-delegated-trace", + "stage": "G1", + "source": "def inner():\n yield 1\ndef outer():\n yield from inner()\na=outer()\nnext(a)\na.throw(ValueError(\"delegated\"))\n", + "error_type": "ValueError", + "error_frames": [ + { + "function": "", + "line": 7 + }, + { + "function": "outer", + "line": 4 + }, + { + "function": "inner", + "line": 2 + } + ], + "profiles": [ + "classes-source/1", + "core-source/1" + ] + }, + { + "id": "injected-active-context", + "stage": "G1", + "source": "def g():\n try:\n raise KeyError(\"saved\")\n except KeyError:\n yield 1\na=g()\nnext(a)\na.throw(ValueError(\"injected\"))\n", + "error_type": "ValueError", + "error_frames": [ + { + "function": "", + "line": 8 + }, + { + "function": "g", + "line": 5 + } + ], + "error_context_type": "KeyError", + "profiles": [ + "classes-source/1", + "core-source/1" + ] + }, + { + "id": "injected-caller-context-isolation", + "stage": "G1", + "source": "def g():\n yield 1\na=g()\nnext(a)\ntry:\n raise KeyError(\"caller\")\nexcept KeyError:\n a.throw(ValueError(\"injected\"))\n", + "error_type": "ValueError", + "error_context_type": null, + "error_frames": [ + { + "function": "", + "line": 8 + }, + { + "function": "g", + "line": 2 + } + ], + "profiles": [ + "classes-source/1", + "core-source/1" + ] } ] }