fix: make transcription cancellation slot-safe

This commit is contained in:
mARTin-B78 2026-09-04 09:07:52 +02:00
parent 3964690444
commit 87acd20811
2 changed files with 14 additions and 4 deletions

View File

@ -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. batch work is capped at two concurrent operations.
- **Optional Background Transcription.** `/api/transcribe` now accepts `background: true` and exposes bounded job - **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. 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 - **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. 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 - **Server-side Provider Credentials.** Provider keys are now masked even for same-origin Settings responses, are

View File

@ -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: 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…") _stt_job_set(job_id, stage="running", detail="Transcribing…")
try: try:
text, used_backend = await asyncio.wait_for( # Shield the worker task so wait_for timeout cannot cancel the
_bounded_stt_call(_transcribe_audio, src, settings, backend), # coroutine's finally block and release the semaphore while its
timeout=_STT_JOB_TIMEOUT, # 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"): 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") _stt_job_set(job_id, stage="cancelled", detail="Cancelled", done=True, cancelled=True, error="Transcription cancelled")
else: else: