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
+51
View File
@@ -591,6 +591,57 @@ url = http://x.com/s2
assert len(mgr.recorders) == 1
assert mgr.recorders[0].name == "good_source"
def test_bad_integer_skips_only_that_source(self, tmp_path):
# A non-numeric split_minutes used to raise ValueError out of the
# config loop and abort the whole load, taking healthy sources with it.
log_file = str(tmp_path / "test.log")
config_text = f"""
[general]
log_level = WARNING
log_file = {log_file}
[broken]
type = stream
url = http://x.com/s
split_minutes = sixty
[good_source]
type = stream
url = http://x.com/s2
"""
cfg_path = tmp_path / "config.ini"
cfg_path.write_text(config_text)
with patch.object(isr, "AudioSystem") as mock_as:
mock_as.return_value.available = False
mgr = isr.RecorderManager(str(cfg_path))
assert [r.name for r in mgr.recorders] == ["good_source"]
def test_zero_split_minutes_is_clamped(self, tmp_path):
# split_minutes = 0 reached get_next_split_time() and divided by zero.
log_file = str(tmp_path / "test.log")
config_text = f"""
[general]
log_level = WARNING
log_file = {log_file}
[zero_split]
type = stream
url = http://x.com/s
split_minutes = 0
"""
cfg_path = tmp_path / "config.ini"
cfg_path.write_text(config_text)
with patch.object(isr, "AudioSystem") as mock_as:
mock_as.return_value.available = False
mgr = isr.RecorderManager(str(cfg_path))
rec = mgr.recorders[0]
assert rec.split_duration == 1
rec.get_next_split_time() # must not raise ZeroDivisionError
def test_missing_config_file_exits(self, tmp_path):
with pytest.raises(SystemExit):
# main() calls sys.exit(1) when the file is missing