Skip to content

Commit 86e5484

Browse files
committed
race condition testing
1 parent 975cad1 commit 86e5484

4 files changed

Lines changed: 283 additions & 19 deletions

File tree

‎hackrfpy/src/hackrfpy/core.py‎

Lines changed: 106 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
import subprocess
3030
import sys
3131
import threading
32+
import time
3233
import weakref
3334
from types import TracebackType
3435
from 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+
206215
class _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)

‎hackrfpy/tests/conftest.py‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,23 @@ def fixtures_dir():
7171
EMIT_BYTES = {emit_bytes!r}
7272
TAIL_ON_INTERRUPT = {tail_on_interrupt!r}
7373
IGNORE_INTERRUPT = {ignore_interrupt!r}
74+
BUSY_FAILS = {busy_fails!r}
75+
STDOUT_FLOOD = {stdout_flood!r}
76+
77+
if BUSY_FAILS:
78+
# simulate hackrf_open()'s stale-claim race: fail with the Resource
79+
# busy signature the first N invocations, then behave normally. State
80+
# lives in a counter file next to the stub so it survives respawns.
81+
_cf = __file__ + ".busycount"
82+
try:
83+
_n = int(open(_cf).read())
84+
except (OSError, ValueError):
85+
_n = 0
86+
if _n < BUSY_FAILS:
87+
open(_cf, "w").write(str(_n + 1))
88+
sys.stderr.write("hackrf_open() failed: Resource busy (-1000)\\n")
89+
sys.stderr.flush()
90+
sys.exit(1)
7491
7592
def _on_signal(signum, frame):
7693
# record WHICH signal arrived, so tests can distinguish the clean
@@ -121,6 +138,16 @@ def _on_signal(signum, frame):
121138
sys.stdout.write(line + "\\n")
122139
sys.stdout.flush()
123140
141+
if STDOUT_FLOOD:
142+
# keep writing far past pipe capacity so a consumer that stops reading
143+
# leaves this child BLOCKED inside write() -- the frozen-writer state
144+
# that held the USB claim on real hardware
145+
_w = 0
146+
while _w < STDOUT_FLOOD:
147+
sys.stdout.buffer.write(b"\\x2a" * 4096)
148+
_w += 4096
149+
sys.stdout.buffer.flush()
150+
124151
if IDLE:
125152
_idle_until = time.monotonic() + 30.0 # safety ceiling: never leak a
126153
while time.monotonic() < _idle_until: # stub child on CI, even one
@@ -136,15 +163,16 @@ def _on_signal(signum, frame):
136163
def _write_stub(tools_dir, name, *, stdout_lines=(), stderr_lines=(),
137164
stderr_flood=0, idle=False, exit_code=0, marker=None,
138165
emit_bytes=None, tail_on_interrupt=None,
139-
ignore_interrupt=False):
166+
ignore_interrupt=False, busy_fails=0, stdout_flood=0):
140167
py_path = os.path.join(tools_dir, name + ".py")
141168
body = _STUB_TEMPLATE.format(
142169
marker=marker, stdout_lines=list(stdout_lines),
143170
stderr_lines=list(stderr_lines), stderr_flood=stderr_flood,
144171
idle=idle, exit_code=exit_code,
145172
emit_bytes=list(emit_bytes) if emit_bytes else None,
146173
tail_on_interrupt=tail_on_interrupt,
147-
ignore_interrupt=ignore_interrupt)
174+
ignore_interrupt=ignore_interrupt, busy_fails=busy_fails,
175+
stdout_flood=stdout_flood)
148176
with open(py_path, "w") as f:
149177
f.write(body)
150178

‎hackrfpy/tests/test_busy_retry.py‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
#! /usr/bin/python3
2+
3+
##--------------------------------------------------------------------\\
4+
# hackrfpy 'tests/test_busy_retry.py'
5+
# Device-busy retry: hackrf_open() reports "Resource busy (-1000)"
6+
# when the PREVIOUS child's USB claim has not been released yet. On
7+
# Linux the kernel takes a beat after a tool exits, so rapid back-to-
8+
# back operations lose the race -- found on the first real-hardware
9+
# Linux run (7 test failures, all this one error, all in tests that
10+
# reopen the device immediately after another process used it).
11+
# The library absorbs it with bounded, backed-off retries in every
12+
# acquisition mode; these tests pin that with a stub that fails busy
13+
# N times and then behaves.
14+
#
15+
#
16+
# Author(s): Lauren Linkous
17+
##--------------------------------------------------------------------\\
18+
19+
import os
20+
21+
import pytest
22+
23+
from hackrfpy.exceptions import HackRFDeviceError
24+
25+
26+
def _fast(h):
27+
h.busy_backoff = 0.01 # keep test wall time negligible
28+
return h
29+
30+
31+
def _spawn_count(h, tool="hackrf_operacake"):
32+
# the counter sits next to the EXECUTED file: the extensionless launcher
33+
# on POSIX, the .py on Windows (the .bat re-invokes it)
34+
for suffix in ("", ".py"):
35+
cf = os.path.join(h._tmp_path, tool + suffix + ".busycount")
36+
if os.path.exists(cf):
37+
return int(open(cf).read())
38+
return 0
39+
40+
41+
# ---- blocking mode ----------------------------------------------------------
42+
def test_blocking_retries_through_busy(stub_device):
43+
h = _fast(stub_device(operacake=dict(stdout_lines=["OK boards: none"],
44+
busy_fails=2)))
45+
out, err, rc = h.operacake("-l")
46+
assert rc == 0 and "OK boards" in out
47+
assert _spawn_count(h) == 2 # two busy failures were absorbed
48+
49+
50+
def test_blocking_raises_when_retries_exhausted(stub_device):
51+
h = _fast(stub_device(operacake=dict(stdout_lines=["never seen"],
52+
busy_fails=99)))
53+
with pytest.raises(HackRFDeviceError, match="Resource busy"):
54+
h.operacake("-l")
55+
assert _spawn_count(h) == h.busy_retries + 1 # initial try + retries
56+
57+
58+
def test_busy_retries_zero_fails_immediately(stub_device):
59+
h = _fast(stub_device(operacake=dict(busy_fails=1)))
60+
h.busy_retries = 0
61+
with pytest.raises(HackRFDeviceError, match="Resource busy"):
62+
h.operacake("-l")
63+
assert _spawn_count(h) == 1
64+
65+
66+
# ---- stream mode (sweep, monitor, and the persistent receiver ride on it) --
67+
_SWEEP_ROW = ("2026-06-18, 12:00:00.000000, 88000000, 88500000, 100000.00, "
68+
"8192, -71.2, -70.1, -69.9, -72.4, -71.8")
69+
70+
71+
def test_stream_retries_before_first_row(stub_device):
72+
h = _fast(stub_device(sweep=dict(stdout_lines=[_SWEEP_ROW],
73+
busy_fails=1)))
74+
rows = h.sweep_collect(88e6, 89e6, num_sweeps=1)
75+
assert rows and rows[0]["hz_low"] == 88000000
76+
77+
78+
def test_receiver_open_retries_through_busy(stub_device):
79+
h = _fast(stub_device(transfer=dict(emit_bytes=[0, 64] * 65536,
80+
busy_fails=1)))
81+
with h.open_receiver(100e6, 2e6) as rx:
82+
iq = rx.read(1024)
83+
assert len(iq) == 1024
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
#! /usr/bin/python3
2+
3+
##--------------------------------------------------------------------\\
4+
# hackrfpy 'tests/test_stream_teardown.py'
5+
# The frozen-writer leak: a stream consumer that breaks out stops
6+
# reading the pipe; at capture rates the 64 KB pipe fills in
7+
# milliseconds and the child blocks inside write(). Signal handlers
8+
# that only set an exit flag can never reach it from a blocked write,
9+
# and the old teardown ended at an unreaped terminate() -- so on real
10+
# hardware, hackrf_transfer stayed frozen, HOLDING THE USB CLAIM, and
11+
# every later open in the process failed "Resource busy" (found on the
12+
# Linux verification run: a wall of 7 failures starting immediately
13+
# after the two stream-breakout tests). Teardown now gives the clean
14+
# interrupt a window, then closes the read end to unblock the writer,
15+
# then completes the terminate->kill ladder.
16+
#
17+
#
18+
# Author(s): Lauren Linkous
19+
##--------------------------------------------------------------------\\
20+
21+
import time
22+
23+
24+
def test_breakout_reaps_flooding_writer(stub_device):
25+
# a deaf, flooding child: blocked in write() once we stop reading,
26+
# immune to the flag-setting signals -- the hardware failure mode
27+
h = stub_device(transfer=dict(stdout_flood=8_000_000,
28+
ignore_interrupt=True))
29+
gen = h._run(["transfer", "-r", "-"], mode="stream")
30+
next(gen) # stream is live; now abandon it
31+
t0 = time.monotonic()
32+
gen.close() # GeneratorExit -> teardown under test
33+
took = time.monotonic() - t0
34+
assert took < 6.0, f"teardown took {took:.1f}s -- ladder not bounded"
35+
36+
37+
def test_breakout_then_immediate_reopen_works(stub_device):
38+
# the user-visible contract: after abandoning one stream, the next
39+
# device operation must not find a leaked child in the way
40+
h = stub_device(transfer=dict(stdout_flood=8_000_000,
41+
ignore_interrupt=True))
42+
gen = h._run(["transfer", "-r", "-"], mode="stream")
43+
next(gen)
44+
gen.close()
45+
gen2 = h._run(["transfer", "-r", "-"], mode="stream")
46+
assert next(gen2) # second stream delivers data
47+
gen2.close()
48+
49+
50+
def test_breakout_clean_child_still_gets_interrupt_window(stub_device,
51+
tmp_path):
52+
# the fix must NOT cost well-behaved children their clean exit: the
53+
# SIGINT handler (marker write) runs before the pipe is closed
54+
marker = str(tmp_path / "sig")
55+
h = stub_device(transfer=dict(emit_bytes=[0, 64] * 4096, idle=True,
56+
marker=marker))
57+
gen = h._run(["transfer", "-r", "-"], mode="stream")
58+
next(gen)
59+
gen.close()
60+
import os
61+
deadline = time.monotonic() + 3.0
62+
while time.monotonic() < deadline and not os.path.exists(marker):
63+
time.sleep(0.05)
64+
assert os.path.exists(marker), "clean interrupt window was lost"

0 commit comments

Comments
 (0)