fix: two error paths that made a failure worse than the thing that failed

/api/clip: _clip_wav/_clip_flac send audio headers and then stream frames.
A decode failure mid-stream unwound into _api_clip's handler, which called
_json_err(500) — writing a second status line and a JSON body into a
response that had already promised Content-Type: audio/wav and an exact
Content-Length. Under HTTP/1.1 keep-alive that desyncs the connection for
every subsequent request on it, so one bad clip corrupted unrelated
traffic. Track whether the headers went out; if they did, log and force
the connection closed instead, the same way _copy_to_response already
handles under-delivery.

config.ini: the int() coercion for split_minutes/max_retries/etc sat
outside the try that wraps recorder construction, so `split_minutes =
sixty` aborted the entire config load with an unhandled ValueError rather
than skipping that one section like every other config error. Moved
inside. split_duration is also clamped to >= 1 — 0 divided by zero in
get_next_split_time().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 20:29:54 +02:00
co-authored by Claude Opus 5
parent 1541110ffb
commit 9bb1229aa9
4 changed files with 150 additions and 15 deletions
+69
View File
@@ -1,7 +1,10 @@
"""Tests for the adaptive loud-section detector in web.py."""
import io
import math
import wave
import web
from web import _cut_filename, _loud_sections, _noise_floor_db, _recording_start
WINDOW_DUR = 0.1 # 100 ms windows, as produced by WINDOW_SAMPLES at 48 kHz
@@ -160,3 +163,69 @@ def test_cut_filename_rolls_over_the_hour():
def test_cut_filename_falls_back_for_nonstandard_name():
name = _cut_filename("mixtape", ".mp3", 740, 750.4)
assert name == "mixtape_cut_740s-750s.mp3"
# ---------------------------------------------------------------------------
# /api/clip error handling
# ---------------------------------------------------------------------------
class _OfflineHandler(web._Handler):
"""A _Handler with no socket, for driving handler methods directly."""
def __init__(self, recordings_dir):
self.recordings_dir = str(recordings_dir)
self.analyses_dir = str(recordings_dir)
self.wfile = io.BytesIO()
self.close_connection = False
self.codes = []
def send_response(self, code):
self.codes.append(code)
def send_header(self, key, value):
pass
def end_headers(self):
pass
def _write_wav(path, seconds=2, framerate=8000):
with wave.open(str(path), 'wb') as wf:
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(framerate)
wf.writeframes(b'\x00\x00' * (seconds * framerate))
def test_clip_failure_after_headers_closes_connection_instead_of_writing_json(tmp_path):
# Once the audio headers are out, an error body would be a second response
# on a keep-alive connection that already promised audio/wav plus an exact
# Content-Length — desyncing every later request on that connection.
_write_wav(tmp_path / 'rec.wav')
h = _OfflineHandler(tmp_path)
def boom(path, start, end):
h._send_clip_headers(b'HDR', 10, 1, 2)
raise RuntimeError('decode exploded')
h._clip_wav = boom
h._api_clip({'file': ['rec.wav'], 'start': ['0'], 'end': ['1']})
assert h.close_connection is True
assert h.codes == [200] # no second status line
assert b'error' not in h.wfile.getvalue() # no JSON body
def test_clip_failure_before_headers_still_reports_json(tmp_path):
_write_wav(tmp_path / 'rec.wav')
h = _OfflineHandler(tmp_path)
def boom(path, start, end):
raise RuntimeError('could not open')
h._clip_wav = boom
h._api_clip({'file': ['rec.wav'], 'start': ['0'], 'end': ['1']})
assert h.close_connection is False
assert h.codes == [500]
assert b'could not open' in h.wfile.getvalue()