fix: keep references to background asyncio tasks

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-10-04 18:56:39 +02:00
parent 03529a5403
commit 4f30ee5c0d
11 changed files with 89 additions and 21 deletions

View File

@ -13,6 +13,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
- **Voice list rows no longer show the "Source unknown" badge** (it crowded the play buttons). The badge stays in the voice inspector, next to the Source & consent panel. - **Voice list rows no longer show the "Source unknown" badge** (it crowded the play buttons). The badge stays in the voice inspector, next to the Source & consent panel.
### Fixed ### Fixed
- **Background jobs could be garbage-collected mid-run.** Fire-and-forget `asyncio.create_task` calls (magazine, conversation, tts, stt, library, sources) now go through `core.tasks.spawn`, which keeps a strong reference until the task ends and logs failures by exception class only.
- **Voice archetype gallery broke `npm run check:frontend`**: its `say` helper collided with `magazine.js`; renamed to `archSay`. - **Voice archetype gallery broke `npm run check:frontend`**: its `say` helper collided with `magazine.js`; renamed to `archSay`.
- **Export failed with HTTP 500 when provenance tagging failed** (EPUB/ZIP/article MP3 with audio ffmpeg cannot tag); it now ships the untagged file and logs a warning. - **Export failed with HTTP 500 when provenance tagging failed** (EPUB/ZIP/article MP3 with audio ffmpeg cannot tag); it now ships the untagged file and logs a warning.
- **Archetype `language` filter accepted only full names.** `GET /api/archetypes?language=de` returned 0 items; it now accepts ISO codes and names, case-insensitive (`de`, `DE`, `German`); unknown values return an empty list. - **Archetype `language` filter accepted only full names.** `GET /api/archetypes?language=de` returned 0 items; it now accepts ISO codes and names, case-insensitive (`de`, `DE`, `German`); unknown values return an empty list.

23
core/tasks.py Normal file
View File

@ -0,0 +1,23 @@
"""Fire-and-forget asyncio tasks that are kept alive until they finish."""
from __future__ import annotations
import asyncio
import logging
log = logging.getLogger(__name__)
_BACKGROUND: set[asyncio.Task] = set()
def _done(task: asyncio.Task) -> None:
_BACKGROUND.discard(task)
if not task.cancelled() and task.exception() is not None:
# class name only: exception text may carry paths/URLs/keys
log.error("background task failed: %s", type(task.exception()).__name__)
def spawn(coro) -> asyncio.Task:
"""create_task + strong reference (the loop only keeps weak ones)."""
task = asyncio.create_task(coro)
_BACKGROUND.add(task)
task.add_done_callback(_done)
return task

View File

@ -23,6 +23,7 @@ from PIL import Image, ImageDraw, ImageOps
from fastapi import APIRouter, File, Form, Header, HTTPException, Request, UploadFile from fastapi import APIRouter, File, Form, Header, HTTPException, Request, UploadFile
from fastapi.responses import Response, StreamingResponse from fastapi.responses import Response, StreamingResponse
from core.tasks import spawn
from core.config import _load_settings, _save_settings, _clean_preview_backend from core.config import _load_settings, _save_settings, _clean_preview_backend
from core.constants import _VOICES_DIR_DEFAULT, _MAX_UPLOAD_BYTES from core.constants import _VOICES_DIR_DEFAULT, _MAX_UPLOAD_BYTES
from core.registry import _registry_get, TEMP_DIR from core.registry import _registry_get, TEMP_DIR
@ -930,7 +931,7 @@ async def match_characters_voices(request: Request):
oldest = min(_CAST_JOB_IDEMPOTENCY, key=lambda key: _CAST_JOB_IDEMPOTENCY[key][1]) oldest = min(_CAST_JOB_IDEMPOTENCY, key=lambda key: _CAST_JOB_IDEMPOTENCY[key][1])
_CAST_JOB_IDEMPOTENCY.pop(oldest, None) _CAST_JOB_IDEMPOTENCY.pop(oldest, None)
_cast_job_trim() _cast_job_trim()
asyncio.create_task(_run_cast_job(job_id, job_data, {"idempotency-key": idempotency_key})) spawn(_run_cast_job(job_id, job_data, {"idempotency-key": idempotency_key}))
return {"job_id": job_id, "stage": "queued"} return {"job_id": job_id, "stage": "queued"}
request_fingerprint = hashlib.sha256(json.dumps(data, sort_keys=True, default=str).encode()).hexdigest() request_fingerprint = hashlib.sha256(json.dumps(data, sort_keys=True, default=str).encode()).hexdigest()
if idempotency_key: if idempotency_key:

View File

@ -11,6 +11,7 @@ from pathlib import Path
from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import FileResponse, Response, StreamingResponse from fastapi.responses import FileResponse, Response, StreamingResponse
from core.tasks import spawn
from core.config import _load_settings from core.config import _load_settings
from core.constants import _VOICES_DIR_DEFAULT, _OUTPUT_DIR_DEFAULT, _VOICE_TARGET_DBFS, _VOICE_PEAK_DBFS, _BENCHMARK_SENTENCES, _MAX_TTS_OUTPUT_SECONDS, _MAX_UPLOAD_BYTES, _MAX_PICTURE_BYTES, _MAX_SOUND_BYTES, _ALLOW_PRIVATE_DOWNLOADS from core.constants import _VOICES_DIR_DEFAULT, _OUTPUT_DIR_DEFAULT, _VOICE_TARGET_DBFS, _VOICE_PEAK_DBFS, _BENCHMARK_SENTENCES, _MAX_TTS_OUTPUT_SECONDS, _MAX_UPLOAD_BYTES, _MAX_PICTURE_BYTES, _MAX_SOUND_BYTES, _ALLOW_PRIVATE_DOWNLOADS
from core.registry import _registry_put, _registry_get, TEMP_DIR from core.registry import _registry_put, _registry_get, TEMP_DIR
@ -739,7 +740,7 @@ async def prepare_missing_references(request: Request):
"errors": [], "errors": [],
"started_at": datetime.now().isoformat(), "started_at": datetime.now().isoformat(),
} }
asyncio.create_task(_run_prepare_missing_job(job_id, candidates, settings)) spawn(_run_prepare_missing_job(job_id, candidates, settings))
return {"job_id": job_id, "queued": len(candidates)} return {"job_id": job_id, "queued": len(candidates)}

View File

@ -38,6 +38,7 @@ from fastapi.responses import FileResponse, Response
from core import magazine as mag from core import magazine as mag
from core import magazine_untitled as untitled from core import magazine_untitled as untitled
from core import word_alignment from core import word_alignment
from core.tasks import spawn
from core.config import _load_settings from core.config import _load_settings
from core.constants import CONFIG_DIR from core.constants import CONFIG_DIR
from core.database import ( from core.database import (
@ -511,7 +512,7 @@ async def magazine_ingest(doc_id: str, request: Request):
meta["ingest"] = {"status": "running", "job_id": job_id} meta["ingest"] = {"status": "running", "job_id": job_id}
_write_meta(d, meta) _write_meta(d, meta)
asyncio.create_task( spawn(
asyncio.to_thread(_ingest_sync, doc_id, job_id, ocr_lang, render_dpi, asyncio.to_thread(_ingest_sync, doc_id, job_id, ocr_lang, render_dpi,
vision_ocr_url, vision_ocr_model, vision_ocr_url, vision_ocr_model,
vision_ocr_fallback_url, vision_ocr_fallback_model, vision_ocr_fallback_url, vision_ocr_fallback_model,
@ -987,7 +988,7 @@ async def magazine_suggest_titles(doc_id: str):
_require_magazine(doc_id) _require_magazine(doc_id)
job_id = uuid.uuid4().hex job_id = uuid.uuid4().hex
background_job_create(job_id, "magazine-suggest-titles", fingerprint=doc_id) background_job_create(job_id, "magazine-suggest-titles", fingerprint=doc_id)
asyncio.create_task(asyncio.to_thread(_suggest_titles_sync, doc_id, job_id)) spawn(asyncio.to_thread(_suggest_titles_sync, doc_id, job_id))
return {"job_id": job_id} return {"job_id": job_id}
@ -1382,7 +1383,7 @@ async def magazine_reocr_page(doc_id: str, page: int, request: Request):
raise HTTPException(409, f"Page {page} is still being read; wait for it to finish") raise HTTPException(409, f"Page {page} is still being read; wait for it to finish")
job_id = uuid.uuid4().hex job_id = uuid.uuid4().hex
background_job_create(job_id, "magazine-reocr-page", fingerprint=f"{doc_id}:{page}") background_job_create(job_id, "magazine-reocr-page", fingerprint=f"{doc_id}:{page}")
asyncio.create_task( spawn(
asyncio.to_thread(_reocr_page_sync, doc_id, job_id, page, asyncio.to_thread(_reocr_page_sync, doc_id, job_id, page,
vision_ocr_url, vision_ocr_model, vision_ocr_url, vision_ocr_model,
vision_ocr_fallback_url, vision_ocr_fallback_model) vision_ocr_fallback_url, vision_ocr_fallback_model)
@ -1420,7 +1421,7 @@ async def magazine_rotate_page(doc_id: str, page: int, request: Request):
raise HTTPException(409, f"Page {page} is still being turned or read; wait for it to finish before turning it again") raise HTTPException(409, f"Page {page} is still being turned or read; wait for it to finish before turning it again")
job_id = uuid.uuid4().hex job_id = uuid.uuid4().hex
background_job_create(job_id, "magazine-rotate-page", fingerprint=f"{doc_id}:{page}") background_job_create(job_id, "magazine-rotate-page", fingerprint=f"{doc_id}:{page}")
asyncio.create_task(asyncio.to_thread(_reocr_page_sync, doc_id, job_id, page, url, model, fb_url, fb_model, degrees)) spawn(asyncio.to_thread(_reocr_page_sync, doc_id, job_id, page, url, model, fb_url, fb_model, degrees))
return {"job_id": job_id, "degrees": degrees} return {"job_id": job_id, "degrees": degrees}
@ -1471,7 +1472,7 @@ def _start_quality_page_job(doc_id: str, page: int, mode: str,
job_id = uuid.uuid4().hex job_id = uuid.uuid4().hex
background_job_create(job_id, f"magazine-quality-{mode}", background_job_create(job_id, f"magazine-quality-{mode}",
fingerprint=f"{doc_id}:{page}") fingerprint=f"{doc_id}:{page}")
asyncio.create_task(asyncio.to_thread( spawn(asyncio.to_thread(
_quality_page_sync, doc_id, job_id, page, mode, _quality_page_sync, doc_id, job_id, page, mode,
str(settings.get("vision_ocr_url") or "").strip() or None, str(settings.get("vision_ocr_url") or "").strip() or None,
str(settings.get("vision_ocr_model") or "").strip() or None, str(settings.get("vision_ocr_model") or "").strip() or None,
@ -1702,7 +1703,7 @@ async def magazine_quality_run(doc_id: str):
_require_magazine(doc_id) _require_magazine(doc_id)
job_id = uuid.uuid4().hex job_id = uuid.uuid4().hex
background_job_create(job_id, "magazine-quality-run", fingerprint=doc_id) background_job_create(job_id, "magazine-quality-run", fingerprint=doc_id)
asyncio.create_task(asyncio.to_thread(_quality_run_sync, doc_id, job_id, _load_settings())) spawn(asyncio.to_thread(_quality_run_sync, doc_id, job_id, _load_settings()))
return {"job_id": job_id} return {"job_id": job_id}
@ -1770,7 +1771,7 @@ async def magazine_autofix_page(doc_id: str, page: int, request: Request):
mode = "boxes" if isinstance(body, dict) and body.get("mode") == "boxes" else "all" mode = "boxes" if isinstance(body, dict) and body.get("mode") == "boxes" else "all"
job_id = uuid.uuid4().hex job_id = uuid.uuid4().hex
background_job_create(job_id, "magazine-autofix", fingerprint=f"{doc_id}:{page}") background_job_create(job_id, "magazine-autofix", fingerprint=f"{doc_id}:{page}")
asyncio.create_task(asyncio.to_thread(_autofix_page_sync, doc_id, job_id, page, mode, _load_settings())) spawn(asyncio.to_thread(_autofix_page_sync, doc_id, job_id, page, mode, _load_settings()))
return {"job_id": job_id} return {"job_id": job_id}
@ -2253,7 +2254,7 @@ async def magazine_refine_figures(doc_id: str):
_require_magazine(doc_id) _require_magazine(doc_id)
job_id = uuid.uuid4().hex job_id = uuid.uuid4().hex
background_job_create(job_id, "magazine-refine-figures", fingerprint=doc_id) background_job_create(job_id, "magazine-refine-figures", fingerprint=doc_id)
asyncio.create_task(asyncio.to_thread(_refine_figures_sync, doc_id, job_id)) spawn(asyncio.to_thread(_refine_figures_sync, doc_id, job_id))
return {"job_id": job_id} return {"job_id": job_id}
@ -2267,7 +2268,7 @@ async def magazine_refine_page_figures(doc_id: str, page: int):
job_id = uuid.uuid4().hex job_id = uuid.uuid4().hex
background_job_create(job_id, "magazine-refine-page-figures", background_job_create(job_id, "magazine-refine-page-figures",
fingerprint=f"{doc_id}:{page}") fingerprint=f"{doc_id}:{page}")
asyncio.create_task(asyncio.to_thread(_refine_figures_sync, doc_id, job_id, page)) spawn(asyncio.to_thread(_refine_figures_sync, doc_id, job_id, page))
return {"job_id": job_id} return {"job_id": job_id}
@ -2328,7 +2329,7 @@ async def magazine_add_region(doc_id: str, page: int, request: Request):
job_id = uuid.uuid4().hex job_id = uuid.uuid4().hex
background_job_create(job_id, "magazine-add-region", fingerprint=f"{doc_id}:{page}") background_job_create(job_id, "magazine-add-region", fingerprint=f"{doc_id}:{page}")
asyncio.create_task( spawn(
asyncio.to_thread(_add_region_sync, doc_id, job_id, page, [float(v) for v in bbox], kind, text, asyncio.to_thread(_add_region_sync, doc_id, job_id, page, [float(v) for v in bbox], kind, text,
vision_ocr_url, vision_ocr_model, vision_ocr_url, vision_ocr_model,
vision_ocr_fallback_url, vision_ocr_fallback_model) vision_ocr_fallback_url, vision_ocr_fallback_model)
@ -2548,7 +2549,7 @@ async def magazine_describe_image(doc_id: str, image_id: str, request: Request):
job_id = uuid.uuid4().hex job_id = uuid.uuid4().hex
background_job_create(job_id, "magazine-describe-image", fingerprint=f"{doc_id}:{image_id}") background_job_create(job_id, "magazine-describe-image", fingerprint=f"{doc_id}:{image_id}")
asyncio.create_task( spawn(
asyncio.to_thread(_describe_image_sync, doc_id, job_id, image_id, asyncio.to_thread(_describe_image_sync, doc_id, job_id, image_id,
vision_ocr_url, vision_ocr_model, vision_ocr_url, vision_ocr_model,
vision_ocr_fallback_url, vision_ocr_fallback_model) vision_ocr_fallback_url, vision_ocr_fallback_model)
@ -3015,7 +3016,7 @@ async def magazine_spelling_agent(doc_id: str, request: Request):
raise HTTPException(400, "No LLM model configured") raise HTTPException(400, "No LLM model configured")
job_id = uuid.uuid4().hex job_id = uuid.uuid4().hex
background_job_create(job_id, "magazine-spelling", fingerprint=f"{doc_id}:{article or page or 'all'}") background_job_create(job_id, "magazine-spelling", fingerprint=f"{doc_id}:{article or page or 'all'}")
asyncio.create_task(asyncio.to_thread(_spelling_agent_job, doc_id, job_id, article, page, settings)) spawn(asyncio.to_thread(_spelling_agent_job, doc_id, job_id, article, page, settings))
return {"job_id": job_id} return {"job_id": job_id}
@ -3221,7 +3222,7 @@ async def magazine_generate_article_audio(doc_id: str, article_id: str, request:
raise HTTPException(400, "No narrator voice given") raise HTTPException(400, "No narrator voice given")
job_id = uuid.uuid4().hex job_id = uuid.uuid4().hex
background_job_create(job_id, "magazine-article-audio", fingerprint=f"{doc_id}:{article_id}") background_job_create(job_id, "magazine-article-audio", fingerprint=f"{doc_id}:{article_id}")
asyncio.create_task(asyncio.to_thread( spawn(asyncio.to_thread(
_audio_generate_one_sync, doc_id, job_id, article_id, voice, interview_voice)) _audio_generate_one_sync, doc_id, job_id, article_id, voice, interview_voice))
return {"job_id": job_id} return {"job_id": job_id}
@ -3397,7 +3398,7 @@ async def magazine_generate_batch_audio(doc_id: str, request: Request):
raise HTTPException(400, "No approved articles to render") raise HTTPException(400, "No approved articles to render")
job_id = uuid.uuid4().hex job_id = uuid.uuid4().hex
background_job_create(job_id, "magazine-audio-batch", fingerprint=doc_id) background_job_create(job_id, "magazine-audio-batch", fingerprint=doc_id)
asyncio.create_task(asyncio.to_thread( spawn(asyncio.to_thread(
_audio_generate_batch_sync, doc_id, job_id, article_ids, voice, interview_voice)) _audio_generate_batch_sync, doc_id, job_id, article_ids, voice, interview_voice))
return {"job_id": job_id, "count": len(article_ids)} return {"job_id": job_id, "count": len(article_ids)}

View File

@ -15,6 +15,7 @@ import uuid
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
from core import magazine_sources as ms from core import magazine_sources as ms
from core.tasks import spawn
from core.config import _load_settings, _save_settings from core.config import _load_settings, _save_settings
from core.constants import CONFIG_DIR from core.constants import CONFIG_DIR
from core.database import background_job_create, background_job_update from core.database import background_job_create, background_job_update
@ -162,5 +163,5 @@ async def magazine_sources_import(request: Request):
async with _import_lock: async with _import_lock:
await asyncio.to_thread(_download_sync, doc_id, job_id, key, item_id) await asyncio.to_thread(_download_sync, doc_id, job_id, key, item_id)
asyncio.create_task(run()) spawn(run())
return {"id": doc_id, "job_id": job_id} return {"id": doc_id, "job_id": job_id}

View File

@ -17,6 +17,7 @@ import requests
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from core.tasks import spawn
from core.config import _load_settings from core.config import _load_settings
from core.constants import _VOICES_DIR_DEFAULT, _MAX_UPLOAD_BYTES, _ALLOW_PRIVATE_DOWNLOADS, CONFIG_DIR, _BENCHMARK_SENTENCES from core.constants import _VOICES_DIR_DEFAULT, _MAX_UPLOAD_BYTES, _ALLOW_PRIVATE_DOWNLOADS, CONFIG_DIR, _BENCHMARK_SENTENCES
from core.registry import _registry_put, _registry_get, TEMP_DIR from core.registry import _registry_put, _registry_get, TEMP_DIR
@ -705,7 +706,7 @@ async def quick_import_voice(request: Request):
_save_meta(wav_dest, meta) _save_meta(wav_dest, meta)
await asyncio.to_thread(upsert_voice_in_index, settings, wav_dest) await asyncio.to_thread(upsert_voice_in_index, settings, wav_dest)
if auto_prepare and not transcript: if auto_prepare and not transcript:
asyncio.create_task(_prepare_imported_voice(wav_dest, settings)) spawn(_prepare_imported_voice(wav_dest, settings))
return { return {
"voice_id": final_id, "voice_id": final_id,
"loudness": loudness, "loudness": loudness,

View File

@ -12,6 +12,7 @@ from pathlib import Path
import requests import requests
from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
from core.tasks import spawn
from core.config import _load_settings from core.config import _load_settings
from core.constants import ( from core.constants import (
_WHISPER_DEFAULT, _FASTER_WHISPER_DEFAULT, _WHISPER_CPP_DEFAULT, _WHISPER_DEFAULT, _FASTER_WHISPER_DEFAULT, _WHISPER_CPP_DEFAULT,
@ -579,7 +580,7 @@ async def transcribe(request: Request):
} }
background_job_create(job_id, "transcription", created=now) background_job_create(job_id, "transcription", created=now)
_stt_jobs_trim() _stt_jobs_trim()
asyncio.create_task(_run_stt_job(job_id, src, settings, backend)) spawn(_run_stt_job(job_id, src, settings, backend))
return {"job_id": job_id} return {"job_id": job_id}
try: try:
text, used_backend = await _bounded_stt_call(_transcribe_audio, src, settings, backend) text, used_backend = await _bounded_stt_call(_transcribe_audio, src, settings, backend)

View File

@ -19,6 +19,7 @@ import requests
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import Response, StreamingResponse from fastapi.responses import Response, StreamingResponse
from core.tasks import spawn
from core.config import _load_settings, _clean_preview_backend, _preview_backend_base_url from core.config import _load_settings, _clean_preview_backend, _preview_backend_base_url
from core.constants import ( from core.constants import (
_VOICES_DIR_DEFAULT, _TTS_CONTAINER, _TTS_CONTAINERS_RAW, _VOICES_DIR_DEFAULT, _TTS_CONTAINER, _TTS_CONTAINERS_RAW,
@ -958,7 +959,7 @@ async def voice_design(request: Request):
} }
background_job_create(job_id, "voice_design", created=_VOICE_DESIGN_JOBS[job_id]["created"]) background_job_create(job_id, "voice_design", created=_VOICE_DESIGN_JOBS[job_id]["created"])
_voice_design_job_trim() _voice_design_job_trim()
asyncio.create_task(_run_voice_design_job(job_id, job_data, idempotency_key, request_fingerprint)) spawn(_run_voice_design_job(job_id, job_data, idempotency_key, request_fingerprint))
return {"job_id": job_id, "stage": "queued"} return {"job_id": job_id, "stage": "queued"}
return await _voice_design_execute(data, idempotency_key, request_fingerprint) return await _voice_design_execute(data, idempotency_key, request_fingerprint)

View File

@ -408,7 +408,7 @@ class EndpointTests(unittest.TestCase):
async def fake_task(coro): async def fake_task(coro):
created.append(coro) created.append(coro)
with mock.patch.object(self.routes, "_load_settings", return_value=settings), \ with mock.patch.object(self.routes, "_load_settings", return_value=settings), \
mock.patch.object(self.routes.asyncio, "create_task", side_effect=lambda c: (c.close(), created.append(1))[1]): mock.patch.object(self.routes.asyncio, "create_task", side_effect=lambda c: (c.close(), created.append(1), mock.MagicMock())[2]):
return self.client.post(f"/api/magazine/{self.doc}/pages/{page}/rotate", json={"degrees": degrees}), created return self.client.post(f"/api/magazine/{self.doc}/pages/{page}/rotate", json={"degrees": degrees}), created
def test_a_valid_request_starts_a_job(self): def test_a_valid_request_starts_a_job(self):

37
tests/test_tasks.py Normal file
View File

@ -0,0 +1,37 @@
import asyncio
import gc
import logging
import unittest
from core import tasks
class SpawnTests(unittest.IsolatedAsyncioTestCase):
async def test_survives_gc_and_set_emptied(self):
done = asyncio.Event()
async def work():
await asyncio.sleep(0.01)
done.set()
tasks.spawn(work())
gc.collect()
self.assertEqual(len(tasks._BACKGROUND), 1)
await asyncio.wait_for(done.wait(), 1)
await asyncio.sleep(0)
self.assertEqual(len(tasks._BACKGROUND), 0)
async def test_exception_logged_without_message(self):
async def boom():
raise ValueError("secret-token")
with self.assertLogs("core.tasks", "ERROR") as cm:
tasks.spawn(boom())
await asyncio.sleep(0.02)
self.assertIn("ValueError", cm.output[0])
self.assertNotIn("secret-token", cm.output[0])
self.assertEqual(len(tasks._BACKGROUND), 0)
if __name__ == "__main__":
unittest.main()