Skip to content

Commit dd955b2

Browse files
authored
Merge pull request #49
Enhance logging, error reporting, and add tests for benchmarks
2 parents 75d9816 + 4ce1857 commit dd955b2

7 files changed

Lines changed: 254 additions & 37 deletions

File tree

‎src/palabra_ai/benchmark/__main__.py‎

Lines changed: 92 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import asyncio
55
import bisect
66
import re
7+
import sys
78
import traceback
89
import wave
910
from base64 import b64decode
@@ -248,6 +249,27 @@ def truncate_text(text: str, max_len: int = 25) -> str:
248249
remaining = len(text) - max_len
249250
return f"{text[:max_len]}...(+{remaining})"
250251

252+
def flatten_dict_to_paths(d: dict | list, prefix: str = "") -> list[tuple[str, Any]]:
253+
"""Flatten nested dict/list to materialized paths like ('a.b.c', value)"""
254+
result = []
255+
256+
if isinstance(d, dict):
257+
for key, value in d.items():
258+
new_prefix = f"{prefix}.{key}" if prefix else key
259+
if isinstance(value, (dict, list)):
260+
result.extend(flatten_dict_to_paths(value, new_prefix))
261+
else:
262+
result.append((new_prefix, value))
263+
elif isinstance(d, list):
264+
for i, value in enumerate(d):
265+
new_prefix = f"{prefix}.{i}"
266+
if isinstance(value, (dict, list)):
267+
result.extend(flatten_dict_to_paths(value, new_prefix))
268+
else:
269+
result.append((new_prefix, value))
270+
271+
return result
272+
251273
def format_report(report: Report, io_data: IoData, source_lang: str, target_lang: str, in_file: str, out_file: str, config: Config) -> str:
252274
"""Format report as text with tables and histogram"""
253275
lines = []
@@ -273,6 +295,23 @@ def format_report(report: Report, io_data: IoData, source_lang: str, target_lang
273295
lines.append(f"TTS autotempo: ✅ on ({queue_config.min_tempo}-{queue_config.max_tempo})")
274296
else:
275297
lines.append(f"TTS autotempo: ❌ off")
298+
299+
# CONFIG - exact same dict that goes to SetTaskMessage
300+
lines.append("")
301+
lines.append("CONFIG (sent to set_task)")
302+
lines.append("-" * 80)
303+
config_dict = config.to_dict() # Same as SetTaskMessage.from_config uses!
304+
config_paths = flatten_dict_to_paths(config_dict)
305+
306+
table = PrettyTable()
307+
table.field_names = ["Key", "Value"]
308+
table.align["Key"] = "l"
309+
table.align["Value"] = "l"
310+
311+
for key, value in config_paths:
312+
table.add_row([key, value])
313+
314+
lines.append(str(table))
276315
lines.append("")
277316

278317
# Metrics summary table
@@ -374,8 +413,12 @@ def main():
374413
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
375414

376415
# Save sysinfo immediately at startup
416+
sysinfo = get_system_info()
417+
sysinfo["command"] = " ".join(sys.argv)
418+
sysinfo["argv"] = sys.argv
419+
sysinfo["cwd"] = str(Path.cwd())
377420
sysinfo_path = output_dir / f"{timestamp}_bench_sysinfo.json"
378-
sysinfo_path.write_bytes(to_json(get_system_info(), True))
421+
sysinfo_path.write_bytes(to_json(sysinfo, True))
379422

380423
# Get audio duration for progress tracking
381424
with av.open(str(audio_path)) as container:
@@ -426,6 +469,11 @@ def on_transcription(msg):
426469
config.debug = True
427470
config.log_file = str(output_dir / f"{timestamp}_bench.log")
428471

472+
# Save exact config that goes to set_task (SetTaskMessage.from_config uses to_dict)
473+
config_dict = config.to_dict()
474+
config_path = output_dir / f"{timestamp}_bench_config.json"
475+
config_path.write_bytes(to_json(config_dict, True))
476+
429477
# Create progress bar with language info
430478
progress_bar[0] = tqdm(
431479
total=100,
@@ -475,18 +523,26 @@ def on_transcription(msg):
475523
print("This usually means:")
476524
print(" - User interrupted with Ctrl+C")
477525
print(" - Task was cancelled by timeout")
478-
print(" - Internal cancellation due to error\n")
526+
print(" - Internal cancellation due to error")
527+
print(" - One of the subtasks failed and caused cascade cancellation\n")
528+
529+
# For CancelledError, show ALL logs to understand what happened
530+
if result.log_data and result.log_data.logs:
531+
print(f"Full logs (all {len(result.log_data.logs)} entries):")
532+
for log_line in result.log_data.logs:
533+
print(log_line, end='')
534+
print()
479535
else:
480536
print(f"\n{'='*80}")
481537
print(f"BENCHMARK FAILED: {exc_type}: {exc_msg}")
482538
print(f"{'='*80}\n")
483539

484-
# Print more logs (100 instead of 50)
485-
if result.log_data and result.log_data.logs:
486-
print("Last 100 log entries:")
487-
for log_line in result.log_data.logs[-100:]:
488-
print(log_line, end='')
489-
print()
540+
# For other errors, show last 100
541+
if result.log_data and result.log_data.logs:
542+
print("Last 100 log entries:")
543+
for log_line in result.log_data.logs[-100:]:
544+
print(log_line, end='')
545+
print()
490546

491547
# Print traceback from exception if available
492548
if hasattr(result.exc, '__traceback__') and result.exc.__traceback__:
@@ -542,21 +598,47 @@ def on_transcription(msg):
542598
print("\n" + report_text)
543599

544600
except Exception as e:
601+
# Capture traceback IMMEDIATELY - must be done in except block!
602+
tb_string = traceback.format_exc()
603+
545604
# Print full traceback to console
546605
print(f"\n{'='*80}")
547606
print("BENCHMARK CRASHED - FULL TRACEBACK:")
548607
print(f"{'='*80}\n")
549-
traceback.print_exc()
608+
print(tb_string)
550609

551610
# Save error to file if output directory exists
552611
if output_dir and timestamp:
553612
try:
554613
error_file = output_dir / f"{timestamp}_bench_error.txt"
555-
error_file.write_text(f"Benchmark Error:\n\n{traceback.format_exc()}")
614+
error_file.write_text(f"Benchmark Error:\n\n{tb_string}")
556615
print(f"\nError details saved to: {error_file}")
557616
except Exception as save_error:
558617
print(f"Failed to save error file: {save_error}")
559618

619+
# Try to save partial report/audio even on error (for debugging)
620+
if output_dir and timestamp and result and result.io_data:
621+
try:
622+
print("\nAttempting to save partial results for debugging...")
623+
624+
# Try to parse report
625+
report, in_audio, out_audio = Report.parse(result.io_data)
626+
627+
# Save report files
628+
report_path = output_dir / f"{timestamp}_bench_report_partial.json"
629+
report_path.write_bytes(to_json(report, True))
630+
print(f"✓ Partial report saved to: {report_path}")
631+
632+
# Save audio (always when --out is specified)
633+
in_wav = output_dir / f"{timestamp}_bench_in_partial.wav"
634+
out_wav = output_dir / f"{timestamp}_bench_out_partial.wav"
635+
save_wav(in_audio, in_wav, result.io_data.in_sr, result.io_data.channels)
636+
save_wav(out_audio, out_wav, result.io_data.out_sr, result.io_data.channels)
637+
print(f"✓ Partial audio saved: {in_wav.name}, {out_wav.name}")
638+
639+
except Exception as save_err:
640+
print(f"Could not save partial results: {save_err}")
641+
560642
# Re-raise the exception
561643
raise
562644

‎src/palabra_ai/client.py‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ async def _run_with_result(manager: Manager) -> RunResult:
115115
await manager.task
116116
ok = True
117117
except asyncio.CancelledError as e:
118-
debug("Manager task was cancelled")
118+
exception("Manager task was cancelled")
119119
exc = e
120120
except BaseException as e:
121121
exception("Error in manager task")
@@ -234,9 +234,14 @@ async def process(
234234
success("🎉🎉🎉 Translation completed 🎉🎉🎉")
235235

236236
except* asyncio.CancelledError as eg:
237-
debug("TaskGroup received CancelledError")
238-
# Re-raise CancelledError
239-
raise eg.exceptions[0] from eg
237+
# Check if this was graceful completion (internal shutdown)
238+
if manager._graceful_completion:
239+
debug("Graceful completion detected - not propagating CancelledError")
240+
# Don't re-raise - this is expected graceful shutdown
241+
else:
242+
# External cancellation (Ctrl+C, timeout, error)
243+
debug("External cancellation - propagating CancelledError")
244+
raise eg.exceptions[0] from eg
240245
except* Exception as eg:
241246
excs = unwrap_exceptions(eg)
242247
excs_wo_cancel = [

‎src/palabra_ai/task/io/base.py‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from typing import TYPE_CHECKING
88

99
import numpy as np
10+
from websockets.exceptions import ConnectionClosed
1011

1112
from palabra_ai.audio import AudioFrame
1213
from palabra_ai.constant import BOOT_TIMEOUT, BYTES_PER_SAMPLE, SLEEP_INTERVAL_LONG
@@ -120,8 +121,17 @@ async def in_msg_sender(self):
120121
return
121122
raw = to_json(msg)
122123
debug(f"<- {raw[0:30]} / {msg.dbg_delta=}")
123-
await self.send_message(raw)
124-
self.io_events.append(IoEvent(msg._dbg, raw))
124+
try:
125+
await self.send_message(raw)
126+
self.io_events.append(IoEvent(msg._dbg, raw))
127+
except Exception as e:
128+
# Connection closed during shutdown is OK
129+
if isinstance(e, ConnectionClosed):
130+
debug(
131+
f"Connection closed while sending message (OK during shutdown): {e}"
132+
)
133+
return
134+
raise
125135

126136
async def do(self):
127137
"""Main processing loop - read audio chunks and push them."""

‎src/palabra_ai/task/io/ws.py‎

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
from palabra_ai.audio import AudioFrame
77
from palabra_ai.enum import Channel, Direction, Kind
8-
from palabra_ai.message import Dbg, IoEvent
8+
from palabra_ai.message import Dbg, EosMessage, IoEvent, Message
99
from palabra_ai.task.io.base import Io
1010
from palabra_ai.util.logger import debug, trace
1111
from palabra_ai.util.timing import get_perf_ts, get_utc_ts
@@ -34,17 +34,20 @@ async def send_frame(self, frame: AudioFrame, raw: bytes | None = None) -> None:
3434
debug(f"<- {frame} / {frame.dbg_delta=}")
3535
if self.global_start_perf_ts is None:
3636
self.init_global_start_ts()
37-
frame._dbg.dawn_ts = 0.0
37+
if hasattr(frame, "_dbg") and frame._dbg:
38+
frame._dbg.dawn_ts = 0.0
3839
await self.ws.send(raw)
3940

4041
def new_input_frame(self) -> AudioFrame:
4142
return AudioFrame.create(*self.cfg.mode.for_input_audio_frame)
4243

4344
async def ws_receiver(self):
44-
from palabra_ai.message import EosMessage, Message
45-
4645
try:
4746
async for raw_msg in self.ws:
47+
# Check for cancellation/stopper eagerly
48+
if self.stopper:
49+
debug("ws_receiver: stopper detected, exiting")
50+
break
4851
perf_ts = get_perf_ts()
4952
utc_ts = get_utc_ts()
5053
if self.stopper or raw_msg is None:
@@ -115,6 +118,12 @@ async def boot(self):
115118

116119
async def exit(self):
117120
"""Clean up WebSocket connection"""
118-
if self._ws_cm and self.ws:
119-
await self._ws_cm.__aexit__(None, None, None)
121+
# Websocket should already be closed in do(), just cleanup context manager
122+
if self._ws_cm:
123+
try:
124+
await self._ws_cm.__aexit__(None, None, None)
125+
debug(f"{self.name}: WebSocket context manager cleaned up")
126+
except Exception as e:
127+
debug(f"{self.name}: Error exiting websocket context: {e}")
128+
120129
self.ws = None

‎src/palabra_ai/task/manager.py‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
from palabra_ai.task.logger import Logger
2323
from palabra_ai.task.stat import Stat
2424
from palabra_ai.task.transcription import Transcription
25-
from palabra_ai.util.logger import debug, success, warning
25+
from palabra_ai.util.logger import debug, exception, success, warning
2626

2727

2828
@dataclass
@@ -50,6 +50,7 @@ class Manager(Task):
5050
_debug_mode: bool = field(default=True, init=False)
5151
_transcriptions_shown: set = field(default_factory=set, init=False)
5252
_show_banner_loop: asyncio.Task | None = field(default=None, init=False)
53+
_graceful_completion: bool = field(default=False, init=False)
5354

5455
def __post_init__(self):
5556
self.stat = Stat(manager=self, cfg=self.cfg)
@@ -215,6 +216,7 @@ async def do(self):
215216
debug(f"🔚 {self.name}.do() sleep cancelled, exiting...")
216217
debug(f"🔚 {self.name}.do() received EOF or stopper, exiting...")
217218
success("🏁 Done! ⏻ Shutting down...")
219+
self._graceful_completion = True
218220
break
219221
+self.stopper # noqa
220222
await self.graceful_exit()
@@ -247,7 +249,9 @@ async def shutdown_task(self, task, timeout=SHUTDOWN_TIMEOUT):
247249
try:
248250
await asyncio.wait_for(task._task, timeout=timeout)
249251
except TimeoutError:
250-
debug(f"🔧 {self.name}.shutdown_task() {task.name} shutdown timeout!")
252+
# TimeoutError during graceful shutdown is expected for some tasks (e.g. WebSocket)
253+
# Only log as warning, not error
254+
warning(f"{task.name} shutdown timeout ({timeout}s) - cancelling task")
251255
task._task.cancel()
252256
try:
253257
await task._task
@@ -256,7 +260,7 @@ async def shutdown_task(self, task, timeout=SHUTDOWN_TIMEOUT):
256260
except asyncio.CancelledError:
257261
debug(f"🔧 {self.name}.shutdown_task() {task.name} shutdown cancelled!")
258262
except Exception as e:
259-
debug(f"🔧 {self.name}.shutdown_task() {task.name} shutdown error: {e}")
263+
exception(f"Cancelling {task.name} due to shutdown error: {e}")
260264
task._task.cancel()
261265
try:
262266
await task._task
@@ -317,8 +321,8 @@ async def writer_mercy(self):
317321
debug(f"🔧 {self.name}.writer_mercy() writer shutdown timeout!")
318322
attempt += 1
319323
if attempt >= max_attempts:
320-
debug(
321-
f"🔧 {self.name}.writer_mercy() max attempts reached, cancelling writer!"
324+
exception(
325+
f"Cancelling writer {self.writer.name} due to max shutdown attempts ({max_attempts}) reached"
322326
)
323327
self.writer._task.cancel()
324328
try:

0 commit comments

Comments
 (0)