fix: batch A - crash bugs, a leak, and startup/edge fragility
CI / compile (pull_request) Successful in 10s
CI / unit (pull_request) Successful in 19s
CI / integration (pull_request) Successful in 10s
build / build (push) Failing after 46m40s
CI / compile (push) Successful in 20s
CI / unit (push) Successful in 19s
CI / integration (push) Successful in 16s

Seven confirmed defects from the code audit, each small and low-risk.

* ai_functions.get_random_cyclic_message: random.randint(0, len(CYCLIC_WORDS))
  is inclusive -> could return len -> IndexError. Now randrange(len) + guard on
  an empty CYCLIC_WORDS.
* librarian_commands.get_image_sadox: random.randrange(0, len(res)-1) never
  picked the last comic and raised ValueError('empty range') on a single file.
  Now randrange(len) + an empty-dir guard.
* ai_commands image generation: every DALL-E error branch replied but did not
  return, so control fell through to `if response:` with response unbound ->
  UnboundLocalError right after the friendly message. Each branch now returns;
  response is pre-initialised; and PermissionDeniedError no longer passes a
  (message, text) tuple as a single arg.
* search_bot DOI match: `item["DOI"] in data` was a substring test, so a DOI
  that is a prefix of a longer one (10.1/1 vs 10.1/12) produced a false 'exists'
  hit. Now matches the line's first whitespace token exactly, via an O(1) dict
  index built once per consumer (also removes the O(queried-DOIs) per-line scan
  - a real win for large databases).
* communication_subroutine.scan_incoming: matched records were never removed
  from awaiting_q, so it grew unbounded over uptime and a reused UUID could
  re-match a stale record. Matched records are now dropped after dispatch.
* communication_subroutine.id3: (resp.headers.get("icy-name") or "").title()
  guards against a stream that omits headers (was AttributeError on None,
  500-ing the /prepped_tracks "next" handler).
* betoniarka.scan_tracks: waits for the radio logs to exist instead of dying
  with FileNotFoundError on a fresh deploy (which silently killed the
  now-playing forwarder until a restart).

Verified: tests/unit/test_search_bot.py gains exact-match and trailing-metadata
cases; full unit job 43 passed. Remaining observations (image-gen stale
/home/pi fallback paths + dead FileNotFoundError-after-OSError branch; tailer
still vulnerable to mid-run log rotation; DOI-first-token assumption) noted for
follow-up - none are crashes on the normal path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit was merged in pull request #8.
This commit is contained in:
2026-07-31 23:01:27 +02:00
parent e1fca864d8
commit defc482a22
7 changed files with 90 additions and 22 deletions
+14 -5
View File
@@ -235,14 +235,20 @@ def scan_incoming(stop_event: Optional[threading.Event] = None):
try:
answer = incoming_q.get(block=False)
logger.info("DATA FOUND")
record_stored = False
# Collect matched records and drop them from awaiting_q afterwards -
# they used to stay forever (awaiting_q only ever grew), leaking
# memory over the bot's uptime and letting a reused UUID re-match a
# stale record.
matched = []
for record in awaiting_q:
if record.uuid in answer.keys():
record_stored = True
record.stop = True
record.entries = answer[record.uuid]
IN_COMM_Q.put(record)
if not record_stored:
matched.append(record)
for record in matched:
awaiting_q.remove(record)
if not matched:
for key in answer.keys():
record = QueryControl("Orphaned", key, "Orphan", None)
record.stop = True
@@ -270,10 +276,13 @@ def id3(url: str) -> dict:
resp.read(
metaint
) # this isn't seekable so, arbitrarily read to the point we want
# Guard the headers: an Icecast stream that omits icy-name / icy-genre
# (e.g. while the radio is down) made `.title()` raise AttributeError on
# None, 500-ing the /prepped_tracks "next" handler that calls this.
tagdata = dict(
site_url=resp.headers.get("icy-url"),
name=resp.headers.get("icy-name").title(),
genre=resp.headers.get("icy-genre").title(),
name=(resp.headers.get("icy-name") or "").title(),
genre=(resp.headers.get("icy-genre") or "").title(),
title=get_stream_title(resp.read(255)),
)
return tagdata