Skip to content

Add cancellable DTLS connection attempts - #35

Merged
QuiteYellow merged 2 commits into
QuiteYellow:mainfrom
Moballo-LLC:codex/py-08b-session-interruption
Aug 15, 2026
Merged

Add cancellable DTLS connection attempts#35
QuiteYellow merged 2 commits into
QuiteYellow:mainfrom
Moballo-LLC:codex/py-08b-session-interruption

Conversation

@Jason-Morcos

@Jason-Morcos Jason-Morcos commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Problem

A caller can bound connect(), but it cannot stop an attempt that is no longer needed. Polling a threading.Event adds cancellation latency and requires separate socket ownership just to interrupt a blocked receive.

Changes

  • Add keyword-only connect(cancel=...) using a one-way ConnectCancellation signal.
  • Back each active subscription with a socketpair; set() makes the wake socket readable immediately.
  • Wait on the UDP socket and wake socket together with select(), without a polling interval or watcher thread.
  • Check cancellation before and after synchronous authentication, OpenSSL, and endpoint setup phases.
  • Close only the temporary connection-attempt socket and raise the existing SessionClosedError when cancellation wins.
  • Make the completion/cancellation race explicit: cancellation observed before the wake subscription is retired wins; a later signal does not alter the established session.
  • Keep the signal reusable across simultaneous attempts and ensure every per-attempt socketpair is removed and closed.

This revision deliberately removes quiesce_for_close(), abort(), the in-progress-socket field, pending-request locking/wakeup changes, and all established-session lifecycle changes. Those should be introduced only with a concrete consumer that establishes their required shape.

An ordinary threading.Event has no selectable file descriptor, so using it with select() would require either polling or a helper thread. ConnectCancellation exposes the same small set() / is_set() shape while providing the socket wakeup directly.

Validation

  • 198 tests with current dependencies.
  • 198 tests on Python 3.11 with pyOpenSSL 23.1.
  • All 1,070 LocalThings tests against this exact checkout.
  • 25 repeated cancellation stress runs.
  • Focused Ruff, compile, share-safety, distribution-content checks, and isolated wheel/sdist imports.

Tests cover invalid and pre-set signals, cancellation across setup phases, immediate wake from a blocked select(), multiple subscribers, the handshake-success race, successful non-cancelled connection state, socket cleanup, and redacted local wakeup failures.

Stack

Depends only on #34 at a44930f, which is now directly on current main and independent of #33. The new commit for this slice is 3f0e437.

@QuiteYellow

Copy link
Copy Markdown
Owner

cancel= is the part I want. The rest I'd like to hold, and I'll explain the reasoning.

Three new lifecycle verbs land here: cancel=, quiesce_for_close() and abort(). Today the session has one, close(). Callers of the other two:

  • mqtt_demo/bridge.py calls sess.close() and nothing else.
  • mbillow/localthings calls sess.close() from _close_session() and nothing else.

So quiesce_for_close() and abort() ship as public API with no consumer in either place. I'd rather the fix for #9 land first and pull in whichever verb it turns out to need. Public surface added ahead of a caller tends to fix the shape of the caller before we know what shape it wants.

On the cancellation mechanism itself. Polling a threading.Event every 100 ms works, but it puts a latency floor under every cancel and it needs the in-progress-socket field to stop a blocked receive. A socketpair does the same job without either:

self._wake_r, self._wake_w = socket.socketpair()
...
ready, _, _ = select.select([sock, self._wake_r], [], [], remaining)

Cancel writes a byte, select returns immediately, the receive was never blocked in the first place. That's the standard shape: libcoap and iotivity-lite run a single non-blocking event loop (coap_io_process, oc_main_poll), aiocoap gets it from asyncio, and none of them have a quiesce or abort call because interrupting a wait is a socket operation rather than a lifecycle state. It should also take a good chunk out of the 302 lines this adds to dtls_session.py.

I know that's a design change to code you've already written, tested and stress-run 25 times, which is the most expensive kind of review comment, but I think it's for the best here.

Same rebase note as #34. e4fa70d touches dtls_session.py, the public-API contract test and the new interruption tests, with no auth.py dependency, so it moves onto main behind #34 without waiting on the certificate profile.

@Jason-Morcos
Jason-Morcos force-pushed the codex/py-08b-session-interruption branch from e4fa70d to 3f0e437 Compare August 14, 2026 21:39
@Jason-Morcos Jason-Morcos changed the title Add cancellable DTLS session interruption Add cancellable DTLS connection attempts Aug 14, 2026
@Jason-Morcos

Copy link
Copy Markdown
Contributor Author

Agreed. I’ve pulled quiesce_for_close(), abort(), the in-progress-socket state, pending-request changes, and all established-session lifecycle behavior out of this PR. It now does connection-attempt cancellation only and is stacked solely on #34, with no #33 dependency.

One implementation detail changed from the original version: an ordinary threading.Event has no selectable file descriptor, so combining it with select() would still require either polling or a watcher thread. I replaced it with a one-way ConnectCancellation signal backed by socketpairs for active attempts. set() writes the wake byte, select() returns immediately, and the per-attempt sockets are retired afterward. A signal arriving after the connection has been established does not alter that session.

Happy to change anything else as you see fit!

@QuiteYellow
QuiteYellow merged commit 9ef5598 into QuiteYellow:main Aug 15, 2026
8 checks passed
@QuiteYellow

Copy link
Copy Markdown
Owner

Merged in v0.1.7, thanks. The socketpair-backed ConnectCancellation is what I had in mind.

One narrow race for a follow-up, non-blocking. The "does not alter an already established session" contract has a gap at the very end of connect(). If cancel.set() lands between _drive_dtls_handshake returning completed=True and the finally: _unsubscribe(), then interrupted comes back True and the completed handshake is torn down with SessionClosedError. It's benign (a cancel that arrives exactly at completion getting honoured is defensible), but it contradicts the comment. Either gate the teardown on not completed, or soften the comment to say a cancel racing completion may still win.

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