From 87acd2081179156a7f9b77ca4f7dc141e507927a Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Fri, 4 Sep 2026 09:07:52 +0200 Subject: [PATCH] fix: make transcription cancellation slot-safe --- CHANGELOG.md | 2 ++ routes/stt.py | 16 ++++++++++++---- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15c21f0..f16e0b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi batch work is capped at two concurrent operations. - **Optional Background Transcription.** `/api/transcribe` now accepts `background: true` and exposes bounded job status/cancellation endpoints with timeout and failure states; existing synchronous callers remain compatible. +- **STT Cancellation Safety.** Queued transcription cancels before consuming the single STT slot, and timed-out + requests keep the device slot held until the underlying worker actually exits. - **API Boundary Hardening.** External API authentication is enabled by default, request and mutation limits are enforced, and settings responses redact provider credentials for non-browser callers. - **Server-side Provider Credentials.** Provider keys are now masked even for same-origin Settings responses, are diff --git a/routes/stt.py b/routes/stt.py index 6dc7097..30521a5 100644 --- a/routes/stt.py +++ b/routes/stt.py @@ -50,12 +50,20 @@ def _stt_job_set(job_id: str, **fields) -> None: async def _run_stt_job(job_id: str, src: Path, settings: dict, backend: str) -> None: + if _STT_JOBS.get(job_id, {}).get("cancel_requested"): + _stt_job_set(job_id, stage="cancelled", detail="Cancelled", done=True, cancelled=True, error="Transcription cancelled") + return _stt_job_set(job_id, stage="running", detail="Transcribing…") try: - text, used_backend = await asyncio.wait_for( - _bounded_stt_call(_transcribe_audio, src, settings, backend), - timeout=_STT_JOB_TIMEOUT, - ) + # Shield the worker task so wait_for timeout cannot cancel the + # coroutine's finally block and release the semaphore while its + # underlying to_thread operation is still using the device. + call = asyncio.create_task(_bounded_stt_call(_transcribe_audio, src, settings, backend)) + try: + text, used_backend = await asyncio.wait_for(asyncio.shield(call), timeout=_STT_JOB_TIMEOUT) + except asyncio.TimeoutError: + call.add_done_callback(lambda task: task.exception() if not task.cancelled() else None) + raise if _STT_JOBS.get(job_id, {}).get("cancel_requested"): _stt_job_set(job_id, stage="cancelled", detail="Cancelled", done=True, cancelled=True, error="Transcription cancelled") else: