Skip to content

[mypyc] Fix AttributeError in async try/finally with mixed return paths #19361

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: master
Choose a base branch
from

Conversation

Chainfire
Copy link

@Chainfire Chainfire commented Jun 30, 2025

Async functions with try/finally blocks were raising AttributeError when:

  • Some paths in the try block return while others don't
  • The non-return path is executed at runtime
  • No further await calls are needed

This occurred because mypyc's IR requires all control flow paths to assign to spill targets (temporary variables stored as generator attributes). The non-return path assigns NULL to maintain this invariant, but reading NULL attributes raises AttributeError in Python.

Created a new IR operation GetAttrNullable that can read NULL attributes without raising AttributeError. This operation is used specifically in try/finally resolution when reading spill targets.

  • Added GetAttrNullable class to mypyc/ir/ops.py with error_kind=ERR_NEVER
  • Added read_nullable_attr method to IRBuilder for creating these operations
  • Modified try_finally_resolve_control in statement.py to use GetAttrNullable only for spill targets (attributes starting with 'mypyc_temp')
  • Implemented C code generation in emitfunc.py that reads attributes without NULL checks and only increments reference count if not NULL
  • Added visitor implementations to all required files:
    • ir/pprint.py (pretty printing)
    • analysis/dataflow.py (dataflow analysis)
    • analysis/ircheck.py (IR validation)
    • analysis/selfleaks.py (self leak analysis)
    • transform/ir_transform.py (IR transformation)
  1. Separate operation vs flag: Created a new operation instead of adding a flag to GetAttr for better performance - avoids runtime flag checks on every attribute access.

  2. Targeted fix: Only applied to spill targets in try/finally resolution, not a general replacement for GetAttr. This minimizes risk and maintains existing behavior for all other attribute access.

  3. No initialization changes: Initially tried initializing spill targets to Py_None instead of NULL, but this would incorrectly make try/finally blocks return None instead of falling through to subsequent code.

Added two test cases to mypyc/test-data/run-async.test:

  1. testAsyncTryFinallyMixedReturn: Tests the basic issue with async try/finally blocks containing mixed return/non-return paths.

  2. testAsyncWithMixedReturn: Tests async with statements (which use try/finally under the hood) to ensure the fix works for this common pattern as well.

Both tests verify that the AttributeError no longer occurs when taking the non-return path through the try block.

See mypyc/mypyc#1115

Async functions with try/finally blocks were raising AttributeError when:
- Some paths in the try block return while others don't
- The non-return path is executed at runtime
- No further await calls are needed

This occurred because mypyc's IR requires all control flow paths to assign
to spill targets (temporary variables stored as generator attributes). The
non-return path assigns NULL to maintain this invariant, but reading NULL
attributes raises AttributeError in Python.

Created a new IR operation `GetAttrNullable` that can read NULL attributes
without raising AttributeError. This operation is used specifically in
try/finally resolution when reading spill targets.

- Added `GetAttrNullable` class to mypyc/ir/ops.py with error_kind=ERR_NEVER
- Added `read_nullable_attr` method to IRBuilder for creating these operations
- Modified `try_finally_resolve_control` in statement.py to use GetAttrNullable
  only for spill targets (attributes starting with '__mypyc_temp__')
- Implemented C code generation in emitfunc.py that reads attributes without
  NULL checks and only increments reference count if not NULL
- Added visitor implementations to all required files:
  - ir/pprint.py (pretty printing)
  - analysis/dataflow.py (dataflow analysis)
  - analysis/ircheck.py (IR validation)
  - analysis/selfleaks.py (self leak analysis)
  - transform/ir_transform.py (IR transformation)

1. **Separate operation vs flag**: Created a new operation instead of adding
   a flag to GetAttr for better performance - avoids runtime flag checks on
   every attribute access.

2. **Targeted fix**: Only applied to spill targets in try/finally resolution,
   not a general replacement for GetAttr. This minimizes risk and maintains
   existing behavior for all other attribute access.

3. **No initialization changes**: Initially tried initializing spill targets
   to Py_None instead of NULL, but this would incorrectly make try/finally
   blocks return None instead of falling through to subsequent code.

Added two test cases to mypyc/test-data/run-async.test:

1. **testAsyncTryFinallyMixedReturn**: Tests the basic issue with async
   try/finally blocks containing mixed return/non-return paths.

2. **testAsyncWithMixedReturn**: Tests async with statements (which use
   try/finally under the hood) to ensure the fix works for this common
   pattern as well.

Both tests verify that the AttributeError no longer occurs when taking
the non-return path through the try block.

See mypyc/mypyc#1115
Copy link
Collaborator

@JukkaL JukkaL left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR! Left a comment about a style issue, overall looks good.

@@ -799,6 +799,24 @@ def accept(self, visitor: OpVisitor[T]) -> T:
return visitor.visit_get_attr(self)


class GetAttrNullable(GetAttr):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be more consistent with other ops to add a new flag to GetAttr instead of adding a subclass. For example, add allow_null: bool = False as a keyword-only argument to GetAttr.__init__, and set the error kind accordingly in __init__ when the flag is true, and a boolean attribute to GetAttr. I expect that this change would also reduce the the size of the PR. Can you make this change?

(Your approach clearly has some benefits over adding a flag, as it's arguably easy to forget to handle the flag in a visitor, but since we already have a bunch of flags that impact the semantics in Op subclasses, this is something that already needs to be remembered when writing a visitor.)

Copy link
Author

@Chainfire Chainfire Jul 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I used the flag approach somewhere in my journey, my concern was that this might reduce performance as every GetAttr would be a tiny bit slower. That being said, if you prefer to have it that way, I guess I can make the change.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I expect the performance impact to be quite small, since checking a boolean flag is quick (here branch prediction success rate should be high). I try to avoid subclasses of concrete classes such as GetAttr as a style convention, especially in new code, so I'd prefer to have this as a flag.

@@ -426,6 +427,24 @@ def visit_get_attr(self, op: GetAttr) -> None:
elif not always_defined:
self.emitter.emit_line("}")

def visit_get_attr_nullable(self, op: GetAttrNullable) -> None:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you implement the suggestion to not use a subclass, it still makes sense to keep this code in a separate method that is called from the visit_get_attr handler if the flag is set.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I understand what you mean here

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even if the functionality is merged from GetAttrNullable into the GetAttr class, and there is no longer a visit_get_attr_nullable method in OpVisitor, the code here doesn't need to be merged into the visit_get_attr method, but this method can be renamed and called from visit_get_attr method to handle the nullable case. I.e. visit_get_attr could look like this (some details omitted):

    def visit_get_attr(self, op: GetAttr) -> None:
        if op.allow_null:
            self.get_attr_with_allow_null(op)
            return
        ... <existing code of visit_get_attr>

    def get_attr_with_allow_null(self, op: GetAttr) -> None:
        ... <similar to visit_get_attr_nullable in original version of this PR>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants