2929import subprocess
3030import sys
3131import threading
32+ import time
3233import weakref
3334from types import TracebackType
3435from typing import Any , Callable , Generator , Literal , Protocol
@@ -203,6 +204,14 @@ def _deadman_attach(proc: subprocess.Popen[Any]) -> Any:
203204 return None # POSIX path is the preexec
204205
205206
207+ _BUSY_MARKERS = ("Resource busy" , "(-1000)" )
208+
209+
210+ def _is_busy (text : str ) -> bool :
211+ # hackrf_open()'s "device still claimed" signature; see busy_retries.
212+ return any (m in text for m in _BUSY_MARKERS )
213+
214+
206215class _Stoppable (Protocol ):
207216 # What the atexit backstop actually needs. _Process and PersistentReceiver
208217 # both satisfy it; a Protocol keeps _LIVE honest about holding both without
@@ -385,6 +394,14 @@ def __init__(self, tools_dir: str | None = None, verbose: bool = False,
385394 # TX handles are ALWAYS atexit-stopped. RX handles are too by default;
386395 # set backstop_rx=False for deliberate fire-and-forget receivers.
387396 self .backstop_rx = True
397+ # Device-busy retry: hackrf_open() reports "Resource busy (-1000)"
398+ # when the previous child's USB claim has not been released yet --
399+ # on Linux the kernel takes a beat after a tool exits, so rapid
400+ # back-to-back operations (scan_frequencies, test suites, any
401+ # capture-then-sweep script) can lose the race. Bounded retries with
402+ # backoff absorb it; set busy_retries=0 to fail immediately.
403+ self .busy_retries = 3
404+ self .busy_backoff = 0.25 # seconds; doubles per attempt
388405
389406 # ---- parameter readback ----
390407 # Populated by every validated operation with the ACTUAL values used
@@ -739,6 +756,42 @@ def _run(self, argv: list[Any], *, mode: str = "blocking",
739756 if mode == "timed" and duration is None :
740757 raise HackRFValueError ("timed mode requires duration" )
741758
759+ for _busy_attempt in range (max (0 , int (self .busy_retries )) + 1 ):
760+ out , err , rc = self ._run_once (resolved , mode , duration , text )
761+ blob = self ._text_of (err ) + self ._text_of (out )
762+ if rc in (0 , None ) or not _is_busy (blob ):
763+ break
764+ if _busy_attempt < self .busy_retries :
765+ delay = self .busy_backoff * (2 ** _busy_attempt )
766+ self .warn (f"device busy (previous claim not yet released); "
767+ f"retrying in { delay :.2f} s "
768+ f"({ _busy_attempt + 1 } /{ self .busy_retries } )" )
769+ time .sleep (delay )
770+
771+ # timed runs end via our SIGINT, so a non-zero rc there is expected.
772+ if check and mode != "timed" and rc not in (0 , None ):
773+ errtxt = self ._text_of (err )
774+ detail = errtxt .strip ()
775+ if not detail :
776+ # some tools report failures on STDOUT with an empty stderr
777+ # (Linux hackrf_info prints "No HackRF boards found." there,
778+ # exit 1) -- without this fallback the error read
779+ # "exited 1: " with the actual reason discarded
780+ tail = [ln for ln in self ._text_of (out ).splitlines ()
781+ if ln .strip ()]
782+ detail = tail [- 1 ].strip () if tail else ""
783+ raise HackRFDeviceError (
784+ f"{ C .TOOLS [argv [0 ]]} exited { rc } : { detail } " )
785+ return out , err , rc
786+
787+ @staticmethod
788+ def _text_of (blob : Any ) -> str :
789+ if isinstance (blob , bytes ):
790+ return blob .decode (errors = "replace" )
791+ return blob or ""
792+
793+ def _run_once (self , resolved : list [Any ], mode : str ,
794+ duration : float | None , text : bool ) -> tuple [Any , Any , Any ]:
742795 proc = subprocess .Popen (resolved , stdout = subprocess .PIPE ,
743796 stderr = subprocess .PIPE , text = text ,
744797 creationflags = _CREATION_FLAGS )
@@ -759,23 +812,7 @@ def _run(self, argv: list[Any], *, mode: str = "blocking",
759812 # capture would just start the next segment).
760813 self ._sigint_and_reap (proc )
761814 raise
762-
763- rc = proc .returncode
764- # timed runs end via our SIGINT, so a non-zero rc there is expected.
765- if check and mode != "timed" and rc not in (0 , None ):
766- errtxt = err .decode (errors = "replace" ) if isinstance (err , bytes ) else err
767- detail = errtxt .strip ()
768- if not detail :
769- # some tools report failures on STDOUT with an empty stderr
770- # (Linux hackrf_info prints "No HackRF boards found." there,
771- # exit 1) -- without this fallback the error read
772- # "exited 1: " with the actual reason discarded
773- outtxt = out .decode (errors = "replace" ) if isinstance (out , bytes ) else (out or "" )
774- tail = [ln for ln in outtxt .splitlines () if ln .strip ()]
775- detail = tail [- 1 ].strip () if tail else ""
776- raise HackRFDeviceError (
777- f"{ C .TOOLS [argv [0 ]]} exited { rc } : { detail } " )
778- return out , err , rc
815+ return out , err , proc .returncode
779816
780817 @staticmethod
781818 def _sigint_and_reap (proc : subprocess .Popen [Any ],
@@ -790,6 +827,29 @@ def _sigint_and_reap(proc: subprocess.Popen[Any],
790827
791828 def _stream (self , resolved : list [str ], text : bool = False ,
792829 read_samples : int = 65536 ) -> Generator [Any , None , None ]:
830+ # Device-busy retry, stream flavor: if the child dies with the
831+ # hackrf_open busy signature BEFORE anything was yielded, respawn
832+ # with backoff (same policy as _run). Once data has flowed, a busy
833+ # error can no longer be a stale-claim race and is raised as-is.
834+ for _busy_attempt in range (max (0 , int (self .busy_retries )) + 1 ):
835+ yielded = False
836+ try :
837+ for item in self ._stream_once (resolved , text , read_samples ):
838+ yielded = True
839+ yield item
840+ return
841+ except HackRFDeviceError as e :
842+ if (yielded or not _is_busy (str (e ))
843+ or _busy_attempt >= self .busy_retries ):
844+ raise
845+ delay = self .busy_backoff * (2 ** _busy_attempt )
846+ self .warn (f"device busy (previous claim not yet released); "
847+ f"retrying in { delay :.2f} s "
848+ f"({ _busy_attempt + 1 } /{ self .busy_retries } )" )
849+ time .sleep (delay )
850+
851+ def _stream_once (self , resolved : list [str ], text : bool = False ,
852+ read_samples : int = 65536 ) -> Generator [Any , None , None ]:
793853 # Generator twin of _run. Launch, yield stdout as it arrives, clean up
794854 # on the caller breaking out (GeneratorExit) so we never leave a
795855 # transmitting/receiving process orphaned.
@@ -848,10 +908,39 @@ def _eat(stream: Any, sink: list[Any]) -> None:
848908 finally :
849909 if proc .poll () is None :
850910 _interrupt (proc )
911+ # brief window with the pipe OPEN: a well-behaved child's
912+ # SIGINT handler runs (flush, marker, clean close) and it
913+ # exits here
914+ try :
915+ proc .wait (timeout = 0.5 )
916+ except subprocess .TimeoutExpired :
917+ pass
918+ if proc .poll () is None :
919+ # Still alive: with the consumer gone, the 64 KB pipe fills
920+ # in milliseconds at capture rates and the child is stuck
921+ # inside write(); its SIGINT/SIGTERM handlers only set an
922+ # exit flag a blocked write never returns to check. On real
923+ # hardware the old interrupt->wait->terminate sequence left
924+ # hackrf_transfer frozen in write(), HOLDING THE USB CLAIM
925+ # until interpreter exit, and every later open failed
926+ # "Resource busy". Closing our read end turns the frozen
927+ # write into EPIPE/SIGPIPE so the child can die (the kernel
928+ # releases the claim on any death) -- then finish the
929+ # ladder, which previously stopped at an unreaped
930+ # terminate().
931+ try :
932+ stdout .close ()
933+ except OSError :
934+ pass
851935 try :
852936 proc .wait (timeout = 2.0 )
853937 except subprocess .TimeoutExpired :
854938 proc .terminate ()
939+ try :
940+ proc .wait (timeout = 1.0 )
941+ except subprocess .TimeoutExpired :
942+ proc .kill ()
943+ proc .wait ()
855944 if not broke_out and proc .returncode not in (0 , None ):
856945 t .join (timeout = 2.0 ) # let the drain finish before reading it
857946 err = (b"" if not text else "" ).join (err_tail )
0 commit comments