@@ -469,6 +469,11 @@ def __init__(self, host, port, cert_path=None, key_path=None, *,
469469 self ._lifecycle_lock = threading .Lock ()
470470
471471 self ._send_lock = threading .Lock ()
472+ # Only the thread performing orderly close may send an Observe
473+ # deregistration after terminal quiescence. A thread-local exception
474+ # keeps concurrent application senders blocked without changing the
475+ # existing private send-hook signatures used by test/session adapters.
476+ self ._orderly_close_send_thread_id = None
472477 # Guards the MID/token counters and pending-request registries.
473478 # The refetch worker makes the session its own second concurrent
474479 # get() caller, so two threads can mint tokens at once; without
@@ -533,6 +538,18 @@ def pace(self) -> None:
533538 if remaining > 0 :
534539 self ._stop .wait (remaining )
535540
541+ def _pace_orderly_close (self ) -> None :
542+ """Honor request spacing after terminal quiescence.
543+
544+ ``quiesce_for_close()`` sets ``_stop`` so ordinary paced work wakes
545+ immediately. A later orderly close still has to space its explicit
546+ Observe deregistrations, so this teardown-only path cannot wait on the
547+ already-set event.
548+ """
549+ remaining = self ._min_req_interval - (time .monotonic () - self ._last_send_ts )
550+ if remaining > 0 :
551+ time .sleep (remaining )
552+
536553 # ---- lifecycle ---------------------------------------------------
537554
538555 def connect (
@@ -712,8 +729,16 @@ def _send_observe_dereg(self, tok, path_segs, query=()):
712729 opts .append ((URI_QUERY , value .encode ()))
713730 opts .append ((OBSERVE , OBSERVE_DEREGISTER ))
714731 opts .append ((ACCEPT , CF_CBOR ))
715- self ._send_dgram (
716- build_coap (TYPE_CON , METHOD_GET , mid , tok , opts ))
732+ self ._send_dgram (build_coap (TYPE_CON , METHOD_GET , mid , tok , opts ))
733+
734+ def _send_observe_dereg_after_quiesce (self , tok , path_segs , query = ()):
735+ """Permit one orderly-close deregistration on the closing thread."""
736+ previous = self ._orderly_close_send_thread_id
737+ self ._orderly_close_send_thread_id = threading .get_ident ()
738+ try :
739+ self ._send_observe_dereg (tok , path_segs , query )
740+ finally :
741+ self ._orderly_close_send_thread_id = previous
717742
718743 @staticmethod
719744 def _send_close_notify (connection , sock ):
@@ -764,17 +789,25 @@ def _close_orderly(self):
764789 # Send dereg for every active observation while the conn is
765790 # still healthy. Tiny sleep lets the records reach the wire
766791 # before we shut DTLS down.
767- if ( not self ._lifecycle_cancel . is_set () and self . conn is not None
768- and self ._observe_tokens ):
792+ if self .conn is not None and self . _observe_tokens :
793+ quiesced = self ._lifecycle_cancel . is_set ()
769794 with self ._state_lock :
770795 observations = tuple (self ._observe_tokens .items ())
771796 observe_queries = dict (self ._observe_queries )
772797 for tok , href in observations :
773798 segs = [s for s in href .split ('/' ) if s ]
774799 try :
775- self .pace ()
776- self ._send_observe_dereg (
777- tok , segs , observe_queries .get (tok , ()))
800+ if quiesced :
801+ self ._pace_orderly_close ()
802+ self ._send_observe_dereg_after_quiesce (
803+ tok ,
804+ segs ,
805+ observe_queries .get (tok , ()),
806+ )
807+ else :
808+ self .pace ()
809+ self ._send_observe_dereg (
810+ tok , segs , observe_queries .get (tok , ()))
778811 except Exception as e :
779812 logger .warning ("dereg %s: %s" , href , e )
780813 time .sleep (0.1 )
@@ -918,7 +951,7 @@ def _clear_observe_relations(self):
918951 self ._observe_sequences .clear ()
919952
920953 def _observe_relation_active (self , href , query , legacy ):
921- """Return whether one confirmed relation still owns this identity."""
954+ """Return whether one relation still owns this callback identity."""
922955 with self ._state_lock :
923956 for tok , observed_href in self ._observe_tokens .items ():
924957 if observed_href != href or \
@@ -927,7 +960,14 @@ def _observe_relation_active(self, href, query, legacy):
927960 if legacy :
928961 if tok in self ._legacy_observe_tokens :
929962 return True
930- elif tok in self ._observe_sequences :
963+ elif tok in self ._observe_sequences or (
964+ tok in self ._observe_plain_response_mids
965+ and tok not in self ._legacy_observe_tokens ):
966+ # A probationary optionless response is not proof of an
967+ # Observe relation, but its complete representation still
968+ # belongs on the ordinary notification callback. Keep a
969+ # Block2 refetch alive until the token is retired or later
970+ # proves the legacy relation.
931971 return True
932972 return False
933973
@@ -955,9 +995,17 @@ def _observe_sequence_is_fresh(previous, current, received_at):
955995
956996 def _send_dgram (self , datagram ):
957997 """Send a CoAP datagram. Holds the send lock for the
958- BIO-drain so two writers can't interleave records."""
998+ BIO-drain so two writers can't interleave records.
999+
1000+ The orderly-close deregistration helper grants only its calling thread
1001+ a teardown send after application workers have joined. All ordinary
1002+ request paths remain blocked once terminal quiescence begins.
1003+ """
9591004 with self ._send_lock :
960- if self ._lifecycle_cancel .is_set () or self .conn is None :
1005+ orderly_close_send = (
1006+ self ._orderly_close_send_thread_id == threading .get_ident ())
1007+ if (self ._lifecycle_cancel .is_set () and not orderly_close_send ) or \
1008+ self .conn is None :
9611009 raise SessionClosedError ()
9621010 send_failed = False
9631011 try :
@@ -1058,7 +1106,12 @@ def _reader_loop(self):
10581106 with self ._refetch_cond :
10591107 self ._refetch_pending .clear ()
10601108 self ._refetch_cond .notify_all ()
1061- self ._clear_observe_relations ()
1109+ # Two-phase shutdown retains relation metadata for close(), which
1110+ # runs after application workers have joined and sends the paced
1111+ # deregistration sweep. Unexpected reader death has no later
1112+ # orderly phase and must still retire everything immediately.
1113+ if not self ._lifecycle_cancel .is_set ():
1114+ self ._clear_observe_relations ()
10621115
10631116 def _dispatch_coap (self , datagram ):
10641117 try :
@@ -1221,7 +1274,14 @@ def _dispatch_coap(self, datagram):
12211274 except Exception as e :
12221275 logger .debug (
12231276 "observe pending callback %s: %s" , href , e )
1224- return
1277+ with self ._state_lock :
1278+ if self ._observe_tokens .get (tok ) != href or \
1279+ self ._observe_queries .get (tok , ()) != \
1280+ observe_query or \
1281+ self ._observe_plain_response_mids .get (tok ) != \
1282+ mid or tok in self ._legacy_observe_tokens or \
1283+ tok in self ._observe_sequences :
1284+ return
12251285 # RFC 7959 §2.6: a notification carries only the first block
12261286 # of the representation. Handing the callback a partial CBOR
12271287 # buffer is what #39 was about, so anything with M=1 (or a
0 commit comments