Skip to content

l2cap: Resolve teardown hang on disconnect collision or abort#937

Open
uier wants to merge 3 commits into
mainfrom
l2cap-disconnect-abort-hang
Open

l2cap: Resolve teardown hang on disconnect collision or abort#937
uier wants to merge 3 commits into
mainfrom
l2cap-disconnect-abort-hang

Conversation

@uier

@uier uier commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator

Resolve a teardown hang in LeCreditBasedChannel. When a disconnection collision occurs (both DUT and peer call disconnect simultaneously) or the channel is aborted during disconnection, the connection state transitions to DISCONNECTED before the peer's response arrives (or is ignored). In these cases, the disconnection_result future remained unresolved, causing any awaiting teardown task to hang.

This patch ensures that calling abort() or receiving a disconnection request while in the DISCONNECTING state correctly resolves disconnection_result and cleans up the channel.

Verification:
Verified with a new unit test test_abort_while_disconnecting added to tests/l2cap_test.py that stubs a non-responsive peer and calls abort() during the DISCONNECTING state transition, confirming it completes immediately.

@uier uier force-pushed the l2cap-disconnect-abort-hang branch from da093ad to ab024b9 Compare June 16, 2026 12:14
@uier uier force-pushed the l2cap-disconnect-abort-hang branch from ab024b9 to 5464d43 Compare June 16, 2026 12:20
@uier uier requested a review from zxzxwu June 16, 2026 16:15
@uier uier marked this pull request as ready for review June 16, 2026 16:15
Comment thread bumble/l2cap.py Outdated

def abort(self) -> None:
if self.state == self.State.CONNECTED:
if self.state == self.State.CONNECTED or self.state == self.State.DISCONNECTING:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
if self.state == self.State.CONNECTED or self.state == self.State.DISCONNECTING:
if self.state in (self.State.CONNECTED, self.State.DISCONNECTING):

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done

Comment thread bumble/l2cap.py Outdated
Comment on lines +2899 to +2902
le_connection_channels = self.le_coc_channels.get(channel.connection.handle)
if le_connection_channels:
if le_connection_channels.get(channel.destination_cid) is channel:
del le_connection_channels[channel.destination_cid]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
le_connection_channels = self.le_coc_channels.get(channel.connection.handle)
if le_connection_channels:
if le_connection_channels.get(channel.destination_cid) is channel:
del le_connection_channels[channel.destination_cid]
self.le_coc_channels.pop(channel.connection.handle, None)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

did you mean:

            le_connection_channels = self.le_coc_channels.get(channel.connection.handle)
            if le_connection_channels:
                self.le_coc_channels.pop(channel.connection.handle, None)

or you intend to remove the whole channel associated with that connection handle

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

No, we just need to remove a single channel. However, considering that a handle is impossible to be a key of channels and le_coc_channels at the same time, we can just

    def on_channel_closed(self, channel: ClassicChannel | LeCreditBasedChannel) -> None:
        if classic_connection_channels := self.channels.get(channel.connection.handle):
            classic_connection_channels.pop(channel.source_cid, None)
        elif le_connection_channels := self.le_coc_channels.get(channel.connection.handle):
            le_connection_channels.pop(channel.destination_cid, None)

Comment thread bumble/l2cap.py Outdated
connection_channels = self.channels.get(channel.connection.handle)
if connection_channels:
if channel.source_cid in connection_channels:
if connection_channels.get(channel.source_cid) is channel:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
if connection_channels.get(channel.source_cid) is channel:
connection_channels.pop(channel.source_cid, None)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done

Comment thread bumble/l2cap.py Outdated
self._change_state(self.State.DISCONNECTED)
self.manager.on_channel_closed(self)
if was_disconnecting and self.disconnection_result:
self.disconnection_result.set_result(None)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think we can just set connection_result and disconnection_result under any state?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done

Comment thread bumble/l2cap.py Outdated
was_disconnecting = self.state == self.State.DISCONNECTING
self._change_state(self.State.DISCONNECTED)
self.manager.on_channel_closed(self)
if was_disconnecting and self.disconnection_result:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

ditto

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done

uier added 2 commits June 16, 2026 18:03
Resolve a teardown hang in LeCreditBasedChannel. When a disconnection
collision occurs (both DUT and peer call disconnect simultaneously) or the
channel is aborted during disconnection, the connection state transitions
to DISCONNECTED before the peer's response arrives (or is ignored).
In these cases, the `disconnection_result` future remained unresolved,
causing any awaiting teardown task to hang.

This patch ensures that calling abort() or receiving a disconnection request
while in the DISCONNECTING state correctly resolves `disconnection_result` and
cleans up the channel.

Verification:
Verified with a new unit test `test_abort_while_disconnecting` added to
`tests/l2cap_test.py` that stubs a non-responsive peer and calls abort()
during the DISCONNECTING state transition, confirming it completes immediately.
Also includes the test_disconnection_collision unit test.
@uier uier force-pushed the l2cap-disconnect-abort-hang branch from 5464d43 to 4568748 Compare June 16, 2026 18:11
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