Skip to content

fix(PythonToLaurel): Translate raise as exception instead of hole#876

Draft
olivier-aws wants to merge 6 commits intomainfrom
fix/raise-in-except-block
Draft

fix(PythonToLaurel): Translate raise as exception instead of hole#876
olivier-aws wants to merge 6 commits intomainfrom
fix/raise-in-except-block

Conversation

@olivier-aws
Copy link
Copy Markdown
Contributor

@olivier-aws olivier-aws commented Apr 14, 2026

Fixes #874

Problem

raise statements in Python were translated as a deterministic Hole (<?>), which has Unknown type. The Laurel-to-Core translator rejects Unknown types with "could not infer type", causing an internal error when analyzing any function containing raise inside an except block.

Fix

Replace the Hole translation with three statements that model exception semantics:

  1. maybe_except := <??> — havoc (non-deterministic Error value)
  2. assume isError(maybe_except) — constrain to be a real error
  3. exit <label> — exit to the catchers label (inside a try body) or exit the function (at top level)

A raiseExitLabel field is added to TranslationContext to track where raise should transfer control:

  • Inside a try body → exit to the catchers/handlers label
  • Inside a handler or at top level → exit the function ($body)

Each except handler is guarded by isError(maybe_except) & handler_matches_N where handler_matches_N is a non-deterministic boolean. This models that we don't know which handler matches the exception type (sound over-approximation). The handler clears the error after its body and exits the try block, so subsequent handlers are skipped on that path.

Translation example

Given this Python code:

class ErrorA(Exception):
    pass
class ErrorB(Exception):
    pass

def only_first_handler_matches() -> str:
    result: str = ""
    try:
        raise ErrorA("a")
        result = "unreachable"
    except ErrorA:
        result = "caught_a"
    except ErrorB:
        result = "caught_b"
    assert result == "caught_a"
    return result

The try/raise/except block is translated to the following Laurel IR (formatted for readability):

result := from_str("");
{
  // === Try body (catchers block) ===
  {
    maybe_except := <??>;                          // raise: havoc error
    if isError(maybe_except) then
      exit exception_handlers;                     // raise: jump to handlers
    assume isError(maybe_except);                  // raise: error is active
    if isError(maybe_except) then
      exit exception_handlers;                     // exception check
    exit exception_handlers;                       // raise always exits
    // ... unreachable code ...
    exit try_end                                   // normal completion (skipped)
  } exception_handlers;

  // === Handler 0: except ErrorA ===
  var handler_matches_0: bool := <??>;             // non-deterministic choice
  if isError(maybe_except) & handler_matches_0 then {
    result := from_str("caught_a");                // handler body
    maybe_except := NoError();                     // clear error (caught)
    exit try_end                                   // skip remaining handlers
  };

  // === Handler 1: except ErrorB ===
  var handler_matches_1: bool := <??>;             // non-deterministic choice
  if isError(maybe_except) & handler_matches_1 then {
    result := from_str("caught_b");                // handler body
    maybe_except := NoError();                     // clear error (caught)
    exit try_end                                   // skip remaining handlers
  }

  // Fall-through: no handler caught, error propagates
} try_end;

assert result == "caught_a";

The analysis explores all paths non-deterministically:

  • Path where handler 0 fires → result = "caught_a" → assert passes
  • Path where handler 1 fires → result = "caught_b" → assert unknown
  • This is a sound over-approximation: we don't model exception types, so both handlers are considered possible matches.

Changes

  • Strata/Languages/Python/PythonToLaurel.lean — core fix (raise translation + handler dispatch)
  • StrataTest/Languages/Python/tests/test_raise_in_except.py — exact reproducer from Internal error when analyzing a Python function with raise #874
  • StrataTest/Languages/Python/tests/test_raise_variants.py — edge cases (raise in try, bare re-raise, raise new in except, top-level raise)
  • StrataTest/Languages/Python/tests/test_except_dispatch.py — soundness test for multiple handler dispatch
  • Updated expected outputs for test_try_except, test_multiple_except, and others

Testing

All 156 pyAnalyzeLaurel tests pass. The exact reproducer from #874 now produces "Analysis success".

…f hole

The raise statement was translated as a deterministic Hole (<?>), which
carries Unknown type. The Laurel-to-Core translator rejects Unknown
types with "could not infer type", causing an internal error.

Replace the Hole with the correct exception semantics:
1. Havoc maybe_except (non-deterministic Error value)
2. Assume isError(maybe_except) (constrain to actual error)
3. Exit $body (terminate the function)

This mirrors how the pipeline models exceptions: maybe_except tracks
exception state, and isError checks are inserted after each statement
in try blocks to dispatch to exception handlers.

Existing tests test_try_except and test_multiple_except improved from
inconclusive to passing since raise now properly triggers handlers.

Bug: raise statement inside except block translated as hole, causing
could not infer type
…de except block

Make raise translation context-aware by adding raiseExitLabel to
TranslationContext. Inside a try body, raise exits to the catchers
label (handlers). Inside a handler, raise uses the enclosing scope
exit label, correctly propagating to outer try/except blocks instead
of unconditionally exiting the function.

Changes:
- Add raiseExitLabel field to TranslationContext
- Set raiseExitLabel to catchersLabel when entering try body
- Restore enclosing raiseExitLabel when entering except handler
- Use raiseExitLabel in raise translation (default "$body")
- Document intentional omission of exception expression/cause in
  Raise pattern (abstract error model uses error/no-error only)
- Add comment in test_raise_variants.py explaining why always-raising
  functions produce unknown results (no feasible return path)
- Update expected files to reflect correct raise propagation semantics
@olivier-aws olivier-aws requested a review from a team April 14, 2026 12:21
@olivier-aws olivier-aws marked this pull request as draft April 14, 2026 12:22
…ice for sound dispatch

Each handler is guarded by isError(maybe_except) AND a non-deterministic
boolean (handler_matches_*). This models that we don't know which handler
matches the exception type. The handler clears the error after its body
and exits the try block, so subsequent handlers are skipped on that path.
On the fall-through path the next handler gets a chance.

Previously, all handlers shared the same isError guard, which caused
either vacuous truth (path infeasibility) or all handlers running
unconditionally. The non-deterministic choice variable ensures each
handler is independently reachable without creating contradictions.
@olivier-aws olivier-aws force-pushed the fix/raise-in-except-block branch from cc32403 to 6629a36 Compare April 14, 2026 13:15
Verifies that the analysis does not assume a specific handler catches
when multiple except handlers are present. The assert is correctly
reported as unknown on paths where the non-matching handler fires.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Internal error when analyzing a Python function with raise

1 participant