Skip to content

fix!: ensure that generator clean up is correctly propagated#4394

Closed
winstxnhdw wants to merge 10 commits intolitestar-org:mainfrom
winstxnhdw:main
Closed

fix!: ensure that generator clean up is correctly propagated#4394
winstxnhdw wants to merge 10 commits intolitestar-org:mainfrom
winstxnhdw:main

Conversation

@winstxnhdw
Copy link
Copy Markdown
Contributor

@winstxnhdw winstxnhdw commented Oct 3, 2025

Description

As discussed on Discord, we should ensure that synchronous generators are properly cleaned up. Async generators will be cleaned up, so we should do the same for synchronous generators as well.

The following snippet will now properly print "cleaned up inner" on premature disconnects.

from litestar import get
from litestar.response.sse import ServerSentEvent
from litestar.background_tasks import BackgroundTask
import time

@get('/test', sync_to_thread=True)
def test() -> ServerSentEvent:
    def inner_gen():
        try:
            for i in range(10):
                yield str(i)
                time.sleep(1)
        finally:
            print('cleaned up inner')

    def gen():
        igen = inner_gen()
        try:
            yield from igen
        except ClientDisconnectException:
            return 
        finally:
            igen.close()

    g = gen()
    return ServerSentEvent(g)

Closes #3772

Comment thread litestar/utils/sync.py
@codecov
Copy link
Copy Markdown

codecov bot commented Oct 3, 2025

Codecov Report

❌ Patch coverage is 79.59184% with 10 lines in your changes missing coverage. Please review.
✅ Project coverage is 97.78%. Comparing base (d478219) to head (12ef3f3).

Files with missing lines Patch % Lines
litestar/response/streaming.py 73.91% 5 Missing and 1 partial ⚠️
litestar/utils/sync.py 84.00% 3 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4394      +/-   ##
==========================================
- Coverage   97.83%   97.78%   -0.05%     
==========================================
  Files         296      296              
  Lines       15286    15315      +29     
  Branches     1711     1716       +5     
==========================================
+ Hits        14955    14976      +21     
- Misses        189      197       +8     
  Partials      142      142              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment thread litestar/utils/sync.py Outdated
@github-actions github-actions bot added area/private-api This PR involves changes to the privatized API area/response labels Oct 3, 2025
@winstxnhdw
Copy link
Copy Markdown
Contributor Author

winstxnhdw commented Oct 3, 2025

I believe this is closer to what you want? We make AsyncIteratorWrapper into an actual async generator. Then handle the clean up within the SSE wrapper. This way the wrapper remains agnostic. We also proactively handle the clean up for both async and sync generators.

In the end, this should probably be considered a fix since this fixes a discrepancy in clean up between async and sync generators.

@winstxnhdw winstxnhdw force-pushed the main branch 3 times, most recently from 446cb97 to 8a47ccd Compare October 3, 2025 20:33
@winstxnhdw winstxnhdw changed the title feat!: ensure that synchronous generators are cleaned up fix/feat!: ensure that synchronous generators are cleaned up Oct 3, 2025
@winstxnhdw winstxnhdw changed the title fix/feat!: ensure that synchronous generators are cleaned up fix!: ensure that synchronous generators are cleaned up Oct 3, 2025
@winstxnhdw winstxnhdw changed the title fix!: ensure that synchronous generators are cleaned up fix!: ensure that generator clean up is correctly propagated Oct 3, 2025
@winstxnhdw winstxnhdw force-pushed the main branch 2 times, most recently from 8cbaf83 to 8db6397 Compare October 3, 2025 20:50
Comment thread litestar/response/sse.py Outdated
Comment thread litestar/utils/sync.py Outdated
Comment thread litestar/utils/sync.py Outdated
Comment thread litestar/utils/sync.py Outdated
@winstxnhdw winstxnhdw force-pushed the main branch 4 times, most recently from 03b4344 to 326e55a Compare October 8, 2025 04:01
Comment on lines 22 to 25
try:
yield data.file_content
while True:
yield data.file_content
await sleep(0.1)
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Finally found the issue with the test. The SSE needs to continuously yield something otherwise the generator cannot handle the athrow. Is this something we should be worried about?

@winstxnhdw winstxnhdw changed the title fix: ensure that generator clean up is correctly propagated fix!: ensure that generator clean up is correctly propagated Oct 9, 2025
@winstxnhdw
Copy link
Copy Markdown
Contributor Author

winstxnhdw commented Oct 9, 2025

Marking this as breaking again because async generators will no longer clean up by default as we no longer call CancelScope.

Comment thread litestar/utils/sync.py Outdated
elif isinstance(iterator, AsyncIteratorWrapper):
self._original_generator = iterator._original_generator
else:
self._original_generator = iterable_to_generator(iterator)
Copy link
Copy Markdown
Contributor Author

@winstxnhdw winstxnhdw Oct 11, 2025

Choose a reason for hiding this comment

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

This line is still necessary for asend because asend needs a return value.

@winstxnhdw winstxnhdw force-pushed the main branch 2 times, most recently from 9ed342a to 38221a4 Compare October 11, 2025 14:50
@github-actions
Copy link
Copy Markdown

Documentation preview will be available shortly at https://litestar-org.github.io/litestar-docs-preview/4394

Comment thread litestar/utils/sync.py
Comment on lines +84 to +85
elif isinstance(iterator, AsyncIteratorWrapper):
self._original_generator = iterator._original_generator
Copy link
Copy Markdown
Member

@provinzkraut provinzkraut Oct 11, 2025

Choose a reason for hiding this comment

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

This fundamentally changes the functionality of AsyncIteratorWrapper. It's supposed to wrap a synchronous iterable / iterator, and turn it into an async iterable. Now with the added functionality of also wrapping instances of itself (?), it gets really confusing.

Comment on lines +110 to +111
if isinstance(self.iterator.content_async_iterator, AsyncGenerator): # type: ignore[attr-defined]
await self.iterator.content_async_iterator.athrow(ClientDisconnectError) # type: ignore[attr-defined]
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Again, I do not understand why you have to be so over specific here. It should be good enough to simply check:

  • Is it an async-generator? athrow
  • Is it an async-iterable? Just break

Copy link
Copy Markdown
Contributor Author

@winstxnhdw winstxnhdw Oct 11, 2025

Choose a reason for hiding this comment

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

The issue here is how _ServerSentEventIterator is designed. The content is kept in self.content_async_iterator, which means to close the original generator from the handler, we need to specifically throw self.iterator.content_async_iterator because self.iterator will only contain the header chunks like event_id, event_type, etc.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

_ServerSentEventIterator should handle that though, not the code that's calling it. It knows about its internal state, and is best suited to decide where to throw what exception. From the outside, it should be treated as any other async iterator / generator.

@provinzkraut
Copy link
Copy Markdown
Member

Hey @winstxnhdw, thanks for bringing up this issue and attempting to fix it!

I know you've invested some time into this, so I'm sorry to say it, but I'll be closing this PR now though, since it doesn't seem to be getting into a state where I'd consider it mergeable, despite me sinking a lot of time into reviews, suggestions and conversations - time that could also have been spent fixing other (or this) bugs.

Should you want to contribute again in the future, I suggest you try to familiarise yourself with the material a bit more, and approach changes with a more holistic view, i.e. think deeper about the side effects your changes might have, and the intention behind them. To that end, I can also recommend simply reaching out on our discord server, where we'll be happy to answer all questions :)

@euri10
Copy link
Copy Markdown
Contributor

euri10 commented Oct 11, 2025

you can also just put your PR as a draft and ask for anothers their pov if needed, I do that often when I try things and dont want to "pollute" other's workflow, which happens often for me, some PRs lead nowhere, as a draft it doesnt impact anyone else other than me.

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.

Bug: No cleanup for SSE-endpoints if client tears connection down

4 participants