Skip to content

Commit 2b9178e

Browse files
committed
Handle raw capture output safely
1 parent a0068da commit 2b9178e

2 files changed

Lines changed: 77 additions & 8 deletions

File tree

tests/test_check_headless_pipewire_runtime.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,16 @@ def test_raw_s16le_rms_skips_initial_frames(tmp_path) -> None:
112112
assert headless.raw_s16le_rms(raw_path, skip_frames=4, channels=2) == pytest.approx(1000 / 32768.0)
113113

114114

115+
def test_raw_stdout_capture_is_binary_safe(tmp_path) -> None:
116+
capture_path = tmp_path / "capture.raw"
117+
payload = b"\xff\x7f\x00\x80"
118+
119+
headless.materialize_raw_stdout_capture(capture_path, payload)
120+
121+
assert capture_path.read_bytes() == payload
122+
assert headless.signal_capture_output_text(payload, b"status ok\n", raw_stdout=True) == "status ok"
123+
124+
115125
def test_wav_s16le_rms_skips_initial_frames(tmp_path) -> None:
116126
samples = array.array("h", [0, 0] * 4 + [1000, -1000] * 4)
117127
if sys.byteorder != "little":
@@ -138,6 +148,11 @@ def fake_run(command, **kwargs):
138148
assert headless.pw_record_supports_sample_count() is True
139149

140150

151+
def test_pw_record_capture_uses_raw_only_with_sample_count() -> None:
152+
assert headless.pw_record_capture_features("usage\n--raw\n--sample-count COUNT\n") == (True, True)
153+
assert headless.pw_record_capture_features("usage\n--raw\n") == (False, False)
154+
155+
141156
def test_pw_record_capture_command_omits_sample_count_for_old_pipewire() -> None:
142157
command = headless.build_pw_record_capture_command(
143158
Path("/tmp/capture.raw"),

tools/check_headless_pipewire_runtime.py

Lines changed: 62 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,12 @@ def pw_record_supports_raw(help_text: str | None = None) -> bool:
222222
return "--raw" in (help_text if help_text is not None else pw_record_help())
223223

224224

225+
def pw_record_capture_features(help_text: str) -> tuple[bool, bool]:
226+
include_sample_count = pw_record_supports_sample_count(help_text)
227+
raw_output = include_sample_count and pw_record_supports_raw(help_text)
228+
return include_sample_count, raw_output
229+
230+
225231
def build_pw_record_capture_command(
226232
path: Path,
227233
*,
@@ -259,6 +265,33 @@ def build_pw_record_capture_command(
259265
return command
260266

261267

268+
def decode_process_output(payload: bytes | None) -> str:
269+
if not payload:
270+
return ""
271+
return payload.decode("utf-8", errors="replace").strip()
272+
273+
274+
def signal_capture_output_text(
275+
stdout: bytes | None,
276+
stderr: bytes | None,
277+
*,
278+
raw_stdout: bool,
279+
) -> str:
280+
parts: list[str] = []
281+
if not raw_stdout:
282+
parts.append(decode_process_output(stdout))
283+
parts.append(decode_process_output(stderr))
284+
return "\n".join(part for part in parts if part)
285+
286+
287+
def materialize_raw_stdout_capture(capture_path: Path, stdout: bytes | None) -> None:
288+
if not stdout:
289+
return
290+
if capture_path.exists() and capture_path.stat().st_size > 0:
291+
return
292+
capture_path.write_bytes(stdout)
293+
294+
262295
def capture_sink_monitor_rms(
263296
sink_name: str,
264297
path: Path,
@@ -271,8 +304,7 @@ def capture_sink_monitor_rms(
271304
sample_width_bytes = 2
272305
expected_capture_bytes = sample_count * channels * sample_width_bytes
273306
help_text = pw_record_help()
274-
include_sample_count = pw_record_supports_sample_count(help_text)
275-
raw_output = pw_record_supports_raw(help_text)
307+
include_sample_count, raw_output = pw_record_capture_features(help_text)
276308
capture_path = path if raw_output else path.with_suffix(".wav")
277309
capture_path.unlink(missing_ok=True)
278310
command = build_pw_record_capture_command(
@@ -282,12 +314,15 @@ def capture_sink_monitor_rms(
282314
raw_output=raw_output,
283315
)
284316
print(f"$ {format_command(command)}", flush=True)
285-
recorder = subprocess.Popen(command, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
317+
recorder = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
286318
try:
287319

288320
def capture_node() -> Any:
289321
if recorder.poll() is not None:
290-
output = recorder.stdout.read().strip() if recorder.stdout is not None else ""
322+
stdout, stderr = recorder.communicate()
323+
if raw_output:
324+
materialize_raw_stdout_capture(capture_path, stdout)
325+
output = signal_capture_output_text(stdout, stderr, raw_stdout=raw_output)
291326
detail = f": {output}" if output else ""
292327
raise RuntimeError(f"signal capture exited before stream appeared{detail}")
293328
return live.node_by_name(CAPTURE_NODE_NAME)
@@ -302,18 +337,32 @@ def capture_node() -> Any:
302337

303338
if include_sample_count:
304339
try:
305-
output, _stderr = recorder.communicate(timeout=timeout_seconds)
340+
stdout, stderr = recorder.communicate(timeout=timeout_seconds)
306341
except subprocess.TimeoutExpired as exc:
307342
recorder.kill()
308-
output, _stderr = recorder.communicate(timeout=2.0)
343+
stdout, stderr = recorder.communicate(timeout=2.0)
344+
if raw_output:
345+
materialize_raw_stdout_capture(capture_path, stdout)
346+
output = signal_capture_output_text(stdout, stderr, raw_stdout=raw_output)
309347
raise RuntimeError(f"timed out waiting for signal capture to finish: {output.strip()}") from exc
348+
if raw_output:
349+
materialize_raw_stdout_capture(capture_path, stdout)
350+
output = signal_capture_output_text(stdout, stderr, raw_stdout=raw_output)
310351
else:
311352
dispatch_until(
312353
"Mini EQ signal capture data",
313354
lambda: capture_path.exists() and capture_path.stat().st_size >= expected_capture_bytes,
314355
timeout_seconds,
315356
)
316-
output = live.terminate_process(recorder, "pw-record signal capture")
357+
recorder.terminate()
358+
try:
359+
stdout, stderr = recorder.communicate(timeout=5.0)
360+
except subprocess.TimeoutExpired:
361+
recorder.kill()
362+
stdout, stderr = recorder.communicate(timeout=5.0)
363+
if raw_output:
364+
materialize_raw_stdout_capture(capture_path, stdout)
365+
output = signal_capture_output_text(stdout, stderr, raw_stdout=raw_output)
317366

318367
if output:
319368
print(f"Mini EQ signal capture output:\n{output.rstrip()}", flush=True)
@@ -330,7 +379,12 @@ def capture_node() -> Any:
330379
return wav_s16le_rms(capture_path, skip_frames=sample_count // 4, channels=channels)
331380
finally:
332381
if recorder.poll() is None:
333-
live.terminate_process(recorder, "pw-record signal capture")
382+
recorder.terminate()
383+
try:
384+
recorder.communicate(timeout=5.0)
385+
except subprocess.TimeoutExpired:
386+
recorder.kill()
387+
recorder.communicate(timeout=5.0)
334388

335389

336390
def assert_signal_is_attenuated(label: str, *, baseline_rms: float, measured_rms: float) -> None:

0 commit comments

Comments
 (0)