Skip to content
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

better async effects #1169

Open
wants to merge 6 commits into
base: main
Choose a base branch
from

Conversation

rmorshea
Copy link
Collaborator

@rmorshea rmorshea commented Dec 9, 2023

By submitting this pull request you agree that all contributions to this project are made under the MIT license.

Issues

Closes: #956

Solution

Effects can now be defines as sync or async generators (more info in the docs):

@use_effect
def my_effect():
    conn = open_connection()
    try:
        # do something with the connection
        yield
    finally:
        conn.close()

@use_effect
async def my_effect():
    conn = await open_connection()
    try:
        # do something with the connection
        yield
    finally:
        await conn.close()

Note: this contains a subset of the changes originally created in #1093

Checklist

  • Tests have been included for all bug fixes or added functionality.
  • The changelog.rst has been updated with any significant changes.

@rmorshea rmorshea added this to the 1.1.0 milestone Dec 9, 2023
@Archmonger
Copy link
Contributor

Will review on Monday. Not home at the moment.

Copy link
Contributor

@Archmonger Archmonger left a comment

Choose a reason for hiding this comment

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

I like the flexibility of generator approach a lot more than our previously proposed stop_event interface.

But I do still think safeguards (timeout) need to be put in place for programmer error. We can't trust everyone to right perfect code, so we need to detect, warn, then mitigate programming flaws.

Also, now that our async function interface has deviated wildly from the sync equivalent, it's starting to become clear to me we shouldn't overload use_effect for async support any longer. We need to migrate towards a use_async_effect hook.

Comment on lines +134 to +136
If you never ``yield`` control back to ReactPy, then the component will never
re-render and the effect will never be cleaned up. This is a common mistake when
using ``use_effect`` for the first time.
Copy link
Contributor

@Archmonger Archmonger Dec 11, 2023

Choose a reason for hiding this comment

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

I don't like the idea of having no safeguard against this. An interface that doesn't protect against critical breaking behavior is usually the wrong interface.

My intuition has been telling me we probably want a new use_async_effect hook that incorporates a timeout parameter. Since ReactJS useEffect only supports sync effects, it's a (minor) mismatch that we're overloading our equivalent with async support.

Here, a new connection will be established whenever a new ``url`` is set.

.. warning::

A component will be unable to render until all its outstanding effects have been
Copy link
Contributor

Choose a reason for hiding this comment

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

render -> re-render

Comment on lines +188 to +189
or an async generator. If your effect doesn't need to do any cleanup, then you can
simply write an async function.
Copy link
Contributor

Choose a reason for hiding this comment

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

then you can simply write -> then you should use

self._effect_tasks.extend(create_task(e(stop)) for e in self._effect_funcs)
for effect_func in self._effect_funcs:
effect_task = create_task(effect_func(stop))
# potential memory leak if the task doesn't remove itself when complete
Copy link
Contributor

Choose a reason for hiding this comment

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

This comment needs a bit more description.

Is the memory leak due to our internal design, or due to user failure? Is the next LOC designed to prevent this issue, or is it the cause of this issue?

# potential memory leak if the task doesn't remove itself when complete
effect_task.add_done_callback(lambda t: self._effect_tasks.remove(t))
self._effect_tasks.add(effect_task)
self._effect_tasks.update()
Copy link
Contributor

Choose a reason for hiding this comment

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

Is there any reason to call update here?

def add_effect(function: _Effect) -> None:
effect_func = _cast_async_effect(function)

async def run_effect(stop: Event) -> None:
Copy link
Contributor

@Archmonger Archmonger Dec 11, 2023

Choose a reason for hiding this comment

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

Does this function really need to be dynamically generated within add_effect? It would be more memory efficient to move run_effect outside of add_effect, and pass in all needed values via args.

Dynamically defined functions are generally a bad idea for any long-running Python code due to memory usage.

Comment on lines +209 to +212
f"Async effect {async_function_effect} returned a cleanup "
f"function - use an async generator instead by yielding inside "
"a try/finally block. This will be an error in a future "
"version of ReactPy.",
Copy link
Contributor

Choose a reason for hiding this comment

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

This needs to be mentioned in the changelog.

Comment on lines +143 to +146
Alternatively to the generator style of cleanup, you can return a cleanup function from
your effect function. As with the generator style, the cleanup function will be run
before the component is unmounted or before the next effect is triggered when the
component re-renders:
Copy link
Contributor

@Archmonger Archmonger Dec 11, 2023

Choose a reason for hiding this comment

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

The fact that returning cleanup functions will continue being allowed on sync effects (but not async effects) really makes me think we should commit to a use_async_effect hook.

It feels confusing to mandate two completely separate user interfaces within the same hook.

@rmorshea
Copy link
Collaborator Author

I think we can have use_async_effect with a timeout parameter, but something I realized while working on this is that I don't think we can have a default. Having a default timeout trades concerns about responsiveness and hangs for memory leaks and unexpected behavior due to premature timeouts.

@Archmonger
Copy link
Contributor

Django has a similar timeout design for views. They have an arbitrary default of 20 seconds.
ASGI webservers also have similar timeouts, specifically related to keep-alive requests. Usually default of 5 seconds.

We just have to pick a "good enough" default, and trust the user to change it when needed.

@Archmonger
Copy link
Contributor

On that note, maybe teardown_timeout is a more appropriate name.

@rmorshea
Copy link
Collaborator Author

rmorshea commented Dec 12, 2023

We just have to... trust the user to change it when needed.

Ultimately we have to trust the user to know what they're doing in some regard. Either we trust them to understand that if their effects don't end in a timely manner it will impact the responsiveness of components, or we trust that they understand how premature timeouts may cause memory leaks and unexpected behavior when resources aren't cleaned up. My intuition is that debugging issues with responsiveness will be easier than trying to find the source of memory leaks and orphaned resources.

@Archmonger
Copy link
Contributor

Archmonger commented Dec 12, 2023

In the scenario where we auto-teardown, we've caught the error and can log the LOC. But in the scenario of user failure, it's a complete freeze with no user information.

Maybe we can default it to something large like 20 seconds? At that point we'd be 98% confident it's a programming failure and worth an ERROR log.

@Archmonger
Copy link
Contributor

Archmonger commented Dec 14, 2023

I'm realizing sync effects can be generators too.

Maybe we can stick with one use_effect but remove the ability to return a cancel function?

@Archmonger
Copy link
Contributor

Archmonger commented Dec 17, 2023

In terms of whether we diverge to creating use_async_effect, this issue is also relevant: #1136

In my mind, threading should be unique to sync calls.

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.

Better Async Effect Cleanup
2 participants