From 97377648e5d2c3b4004a4728a6c510e916dd24c1 Mon Sep 17 00:00:00 2001 From: Jack Nagy Date: Sun, 4 Oct 2026 18:20:44 +0100 Subject: [PATCH] Release the appliance peer a completed diagnostic allocated diagnose_dtls_handshake closed its socket without a shutdown, so every run that completed left a peer entry on the appliance. The firmware frees one on close_notify and has no idle timeout that reclaims it otherwise, so the entry stayed for the life of the appliance. Measured on 2026-10-04: six completed diagnostics inside six minutes took a healthy dryer's DTLS endpoint down, the bridge going from 94 ok / 0 err to 0 ok / 14 err with 14 timeouts, and it stayed there about seven minutes. Throughout it the dryer answered 8/8 pings and served its plaintext 5683 directory first try, so only DTLS was affected. Recovery came from the bridge's own watchdog, logging "unreachable for 175s -- forcing session reconnect". The connection is a memory BIO, so shutdown() only queues the alert and it has to be read back out and sent. The send is best effort: the caller already holds its result, and a peer this fails to release is the behaviour that shipped before, so nothing on this path raises. Scope is the completed handshake only. Before the handshake finishes there is no session to shut down, and an appliance that answers with a fatal alert drops the peer itself, so those paths are unchanged. A handshake that times out after a HelloVerifyRequest does still leave a peer; that needs its own change and its own hardware measurement. Verified on hardware after the fix: eight paced handshakes across both appliances each sent 39 bytes of close_notify and cost two ClientHellos, where a run over a peer the appliance had not freed costs three, and every bridge poll window stayed at 0 err / 0 ping-fail / 0 timeouts. --- smartthings_local/protocol/dtls_probe.py | 36 ++++++++++++++++++ tests/test_dtls_probe.py | 47 ++++++++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/smartthings_local/protocol/dtls_probe.py b/smartthings_local/protocol/dtls_probe.py index 82da7d5..636ddbc 100644 --- a/smartthings_local/protocol/dtls_probe.py +++ b/smartthings_local/protocol/dtls_probe.py @@ -45,6 +45,10 @@ from .dtls_handshake import _drive_dtls_handshake from .endpoint import open_host_filtered_udp_socket +# A close_notify is a two-byte alert inside one record, so this only has to +# clear OpenSSL's queued shutdown rather than a whole flight. +_SHUTDOWN_READ_SIZE = 4096 + # DTLS record content types (RFC 6347 ยง4.1) _CT_CHANGE_CIPHER_SPEC = 20 _CT_ALERT = 21 @@ -713,6 +717,37 @@ def _accept_any_peer_chain(*_args): return True +def _release_peer(connection, sock): + """Send close_notify so the appliance frees the peer this run allocated. + + The appliance firmware frees a peer entry when it receives close_notify, + and has no idle timeout that would reclaim one otherwise, so a diagnostic + that closed its socket without a shutdown left association state behind + for the life of the appliance. Measured on 2026-10-04: six completed + diagnostics inside six minutes took a healthy dryer's DTLS endpoint down + for about seven minutes, while its ICMP and plaintext CoAP kept answering + normally. + + The connection is a memory BIO, so ``shutdown()`` only queues the alert; + it has to be read back out and sent here. Best effort by design -- the + caller already has its result, and a peer this fails to release is the + behaviour that shipped before, so nothing here may raise. + """ + try: + connection.shutdown() + except SSL.Error: + return + try: + pending = connection.bio_read(_SHUTDOWN_READ_SIZE) + except SSL.Error: + return + for record in split_dtls(pending): + try: + sock.send(record) + except OSError: + return + + def _validate_diagnostic_auth(auth, cert_pem, key_pem, cert_path, key_path): if auth is None: return @@ -855,6 +890,7 @@ def record_datagram(datagram): result.outcome = COMPLETED if result.rtt_s is None: result.rtt_s = time.monotonic() - started + _release_peer(conn, sock) except SSL.Error: # A fatal Alert lands here; record_datagram() has already classified # the alert record before it is fed back into OpenSSL. diff --git a/tests/test_dtls_probe.py b/tests/test_dtls_probe.py index a7a6454..9c15e24 100644 --- a/tests/test_dtls_probe.py +++ b/tests/test_dtls_probe.py @@ -955,3 +955,50 @@ def test_iter_handshake_fragments_falls_back_on_a_truncated_header(): assert [(name, seq, off) for name, seq, off, _ in got] == [ ('HelloVerifyRequest', None, 0)] + + +def test_completed_diagnostic_releases_the_peer_with_close_notify(monkeypatch): + # The appliance firmware frees a peer entry when it receives close_notify + # and has no idle timeout that reclaims one, so a diagnostic that + # completed and then closed its socket left association state behind. + # Measured on 2026-10-04: six completed diagnostics inside six minutes + # took a healthy dryer's DTLS endpoint down for about seven minutes while + # its ICMP and plaintext CoAP kept answering normally. + cert_pem, key_pem = _synthetic_ec_server_credentials() + server_cert, server_key = _synthetic_ec_server_credentials() + fake = _FakeSock( + _LoopbackDtlsServer( + _server_context( + cipher=b'ECDHE-ECDSA-AES128-GCM-SHA256:@SECLEVEL=0', + cert_pem=server_cert, + key_pem=server_key, + ) + ) + ) + _patch_sock(monkeypatch, fake) + + result = p.diagnose_dtls_handshake( + '127.0.0.1', 5684, cert_pem=cert_pem, key_pem=key_pem, timeout=2.0) + + assert result.outcome == p.COMPLETED + assert fake.sends, 'the diagnostic sent nothing' + last = fake.sends[-1] + assert last[0] == p._CT_ALERT + # Sent under the negotiated epoch, so the two alert bytes are ciphertext + # here; the record type and a non-zero epoch are what identify it. + assert last[3:5] == b'\x00\x01' + + +def test_diagnostic_that_never_completed_sends_no_alert(monkeypatch): + # Scope line for the release above. Before the handshake finishes there is + # no session for OpenSSL to shut down, and an appliance that answers with + # a fatal alert has already dropped the peer itself, so this path stays as + # it shipped. + fake = _FakeSock(lambda sock: None) + _patch_sock(monkeypatch, fake) + + result = p.diagnose_dtls_handshake( + '127.0.0.1', 5684, timeout=0.3, retries=0) + + assert result.outcome != p.COMPLETED + assert all(datagram[0] != p._CT_ALERT for datagram in fake.sends)