Skip to content

Fix concurrent force-close on asyncio socket streams - #1277

Open
hansu650 wants to merge 8 commits into
agronholm:masterfrom
hansu650:fix/concurrent-aclose-forcefully-1273
Open

Fix concurrent force-close on asyncio socket streams#1277
hansu650 wants to merge 8 commits into
agronholm:masterfrom
hansu650:fix/concurrent-aclose-forcefully-1273

Conversation

@hansu650

@hansu650 hansu650 commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

NOTE Erasing or replacing the contents of this template will result in your pull
request being summarily closed without consideration!

Changes

Fixes #1273.

Ensure SocketStream.aclose() always aborts the asyncio transport and takes a
cancellable checkpoint before returning. This keeps concurrent aclose_forcefully()
calls from reporting completion while the raw socket is still open without suppressing
cancellation of normal aclose().

Checklist

If this is a user-facing code change, like a bugfix or a new feature, please ensure that
you've fulfilled the following conditions (where applicable):

  • You've added tests (in tests/) which would fail without your patch
  • You've updated the documentation (in docs/), in case of behavior changes or new
    features
  • You've added a new changelog entry (in docs/versionhistory.rst).

If this is a trivial change, like a typo fix or a code reformatting, then you can ignore
these instructions.

Updating the changelog

If there are no entries after the last release, use **UNRELEASED** as the version.
If, say, your patch fixes issue #123, the entry should look like this:

- Fix big bad boo-boo in task groups
  (`#123 <https://github.com/agronholm/anyio/issues/123>`_; PR by @yourgithubaccount)

If there's no issue linked, just link to your pull request instead by updating the
changelog after you've created the PR.

Testing

  • IPv4 TestTCPStream matrix on Windows/Python 3.13: 110 passed
  • CapacityLimiter regression matrix after merging latest master: 81 passed
  • Ruff check and format pass on changed Python files
  • Windows IPv6 loopback is unavailable on this host (WinError 5/10013); CI remains
    authoritative

Comment thread src/anyio/_backends/_asyncio.py Outdated
@graingert

Copy link
Copy Markdown
Collaborator

you say you reproduced this on windows, does UDPSocket and ConnectedUDPSocket have the same problem there?

@hansu650

Copy link
Copy Markdown
Contributor Author

I also checked the Windows UDP paths: UDPSocket and ConnectedUDPSocket do not have the same race because both aclose() implementations already wait for DatagramProtocol.closed_event, which connection_lost() sets after the transport is closed. Their focused IPv4 FD-release regressions pass across the available backends (10 tests). The Windows IPv6 variants are unavailable on this machine (WinError 5/10013), so CI remains the cross-platform authority.

@graingert

graingert commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

DatagramProtocol.closed_event.wait() should be cancelled by aclose_forcefully before the socket is closed

@hansu650

Copy link
Copy Markdown
Contributor Author

Thanks — agreed. I updated both asyncio UDPSocket.aclose() and ConnectedUDPSocket.aclose() so the transport shared closed_event wait is shielded after transport.close(). This lets aclose_forcefully() cancel the surrounding close operation while still waiting for the OS file descriptor to be released. I also added force-close regression tests for both UDP socket types (asserting fileno() == -1). Validation on the focused UDP close matrix: 24 passed, 16 skipped for the existing Windows asyncio bug; Ruff and formatting checks pass. Commit: daf4d16.

Comment thread src/anyio/_backends/_asyncio.py Outdated
Comment thread src/anyio/_backends/_asyncio.py Outdated
@graingert

graingert commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

we also need to make sure aclose() raises a CancelledError:

stream = await connect_tcp(*server_addr)
exc = None

with CancelScope() as scope:
    scope.cancel()
    try:
        await stream.aclose()
    except anyio.get_cancelled_exc_class() as e:
        exc = e
        raise

assert exc is not None

the checkpoint doesn't need to be shielded to wait for the close event, there just needs to be a checkpoint - the sock.close() gets called from loop.call_soon which is called before the task is woken by cancellation

@hansu650

Copy link
Copy Markdown
Contributor Author

Implemented the cancellation test and shutdown simplification in b84eb22, then merged the latest master in b3e2de6 to resolve the CI merge conflict. The new asyncio-specific regression verifies that SocketStream.aclose() raises the backend cancellation exception from an already-cancelled scope, while the existing concurrent aclose_forcefully() regression still verifies both callers return only after the raw socket is closed. Local validation: 110 IPv4 TestTCPStream cases, 81 CapacityLimiter cases, and Ruff check/format passed. This Windows host cannot open IPv6 loopback sockets (WinError 5/10013), so CI remains authoritative for IPv6.

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.

concurrent anyio.aclose_forcefully fails to block until socket is closed on asyncio

3 participants