Skip to content

Fix generation cancellation flow - #444

Merged
jamiepine merged 1 commit into
jamiepine:mainfrom
Bortlesboat:codex/voicebox-cancel-generation-435
Apr 18, 2026
Merged

jamiepine merged 1 commit into
jamiepine:mainfrom
Bortlesboat:codex/voicebox-cancel-generation-435

Conversation

@Bortlesboat

@Bortlesboat Bortlesboat commented Apr 17, 2026 •

Copy link
Copy Markdown
Contributor

Closes #435

What changed

  • add a cancel endpoint for active generations
  • teach the serial generation queue to track generation IDs so queued jobs can be skipped and running jobs can be cancelled
  • surface a cancel action in the history row while a generation is still in progress
  • add regression coverage for queued and running cancellation paths

Verification

  • python -m pytest backend/tests/test_task_queue_cancellation.py backend/tests/test_cors.py
  • python -m compileall backend/routes/generations.py backend/services/generation.py backend/services/task_queue.py

Summary by CodeRabbit

  • New Features

    • Users can now cancel active generations with a dedicated cancel button in the history view
    • Cancel button displays a loading spinner while the cancellation request is processing
    • Cancelled generations are marked with a failed status and appropriate error message
  • Tests

    • Added automated tests for generation cancellation scenarios

@coderabbitai

coderabbitai Bot commented Apr 17, 2026 •

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

This change implements a full-stack task cancellation feature allowing users to stop in-progress generation tasks. The frontend UI now displays a cancel button for active tasks, which triggers an API endpoint that coordinates cancellation across the backend's route layer, task queue manager, and generation service.

Changes

Cohort / File(s) Summary
Frontend Generation Control
app/src/components/History/HistoryTable.tsx, app/src/lib/api/client.ts
Added cancel UI button (with spinner state) for in-progress generations and a new cancelGeneration() API method that calls the backend cancel endpoint with React Query mutation and toast notifications.
Backend Cancellation Endpoint
backend/routes/generations.py
Added POST /generate/{generation_id}/cancel endpoint that validates generation status, delegates to task queue cancellation, and handles queued vs. running job states differently. Updated enqueue_generation() calls to pass generation_id as explicit first argument.
Backend Cancellation Coordination
backend/services/task_queue.py
Introduced GenerationJob dataclass and global tracking for queued and running generation IDs. Refactored worker to detect and skip cancelled jobs, close coroutines on cancellation, and maintain lifecycle cleanup. Updated enqueue_generation(generation_id, coro) signature and added cancel_generation() method with init_queue(force: bool) support.
Task Failure Handling
backend/services/generation.py
Added explicit asyncio.CancelledError exception handler to mark cancelled generations as failed with error message "Generation cancelled" before propagating to generic error handler.
Cancellation Tests
backend/tests/test_task_queue_cancellation.py
Added two async pytest tests: one verifying queued job cancellation (prevents execution), another confirming running job cancellation (closes coroutine on CancelledError).

Sequence Diagram

sequenceDiagram
    participant UI as User / HistoryTable
    participant API as ApiClient
    participant Endpoint as POST /generate/{id}/cancel
    participant DB as Database
    participant TQ as TaskQueue Manager
    participant Worker as Worker Task
    participant Service as Generation Service

    UI->>API: cancelGeneration(generationId)
    API->>Endpoint: POST /generate/{id}/cancel
    Endpoint->>DB: Fetch generation record
    Endpoint->>TQ: cancel_generation(generation_id)
    
    alt Generation is Queued
        TQ->>TQ: Mark ID as cancelled
        TQ->>Worker: Skip queued job execution
        Endpoint->>DB: Update status → failed, error: "Generation cancelled"
        Endpoint->>API: 200 {"message": "Queued generation cancelled"}
    else Generation is Running
        TQ->>Worker: Close coroutine (asyncio.CancelledError)
        Worker->>Service: Catch CancelledError
        Service->>DB: Update status → failed, error: "Generation cancelled"
        Endpoint->>API: 200 {"message": "Generation cancellation requested"}
    end
    
    API->>UI: Show success toast
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 A hop, skip, and cancel away,
Long tasks need not stay all day,
With buttons bright and queues that clear,
No more stuck jobs to fear,
Progress flows, as it should be,
From running task to history! 🎉

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Fix generation cancellation flow' directly and concisely describes the primary objective of the pull request: implementing generation cancellation functionality.
Linked Issues check ✅ Passed The PR implements the primary coding requirement from #435: a cancel mechanism for active/in-progress generations via endpoint, queue tracking, and UI controls.
Out of Scope Changes check ✅ Passed All changes directly support the cancellation feature: API client cancel method, backend endpoint, queue tracking, generation service error handling, and cancellation tests.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@Bortlesboat
Bortlesboat marked this pull request as ready for review April 17, 2026 05:32

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (4)
backend/services/task_queue.py (1)

9-9: Import Coroutine from collections.abc.

typing.Coroutine has been a deprecated alias since Python 3.9 (flagged by Ruff UP035).

♻️ Proposed fix
-from dataclasses import dataclass
-from typing import Coroutine, Literal
+from collections.abc import Coroutine
+from dataclasses import dataclass
+from typing import Literal
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/services/task_queue.py` at line 9, Replace the deprecated
typing.Coroutine import by importing Coroutine from collections.abc: update the
import statement that currently references "Coroutine" (in
backend/services/task_queue.py) to import Coroutine from collections.abc instead
of typing, keeping other imports like Literal unchanged so functions/types using
Coroutine continue to work with the non-deprecated symbol.
backend/tests/test_task_queue_cancellation.py (2)

1-54: Add teardown to stop the worker between tests.

Both tests call init_queue(force=True) at the top, which cancels the previous worker, but they never stop the worker after the test completes. Test 2 in particular leaves a cancelled-task coroutine and a live _generation_worker_task background task in the module. With pytest-asyncio's function-scoped event loop, this background task is torn down with the loop and can produce Task was destroyed but it is pending! / unawaited-coroutine warnings that mask real issues.

Consider a fixture (module- or function-scoped) that calls init_queue(force=True) in setup and cancels _generation_worker_task + awaits it in teardown.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/tests/test_task_queue_cancellation.py` around lines 1 - 54, Tests
start new background worker with task_queue.init_queue(force=True) but never
stop it, causing pending background tasks and warnings; add a pytest async
fixture (function- or module-scoped) that calls
task_queue.init_queue(force=True) in setup and in teardown checks for
task_queue._generation_worker_task, calls .cancel() on it and awaits it (e.g.,
inside an asyncio.wait_for to avoid hanging) to ensure the worker is fully
stopped between tests; update both tests to use this fixture instead of calling
init_queue directly or remove their init_queue calls if the fixture performs
setup.

29-32: Replace the fixed asyncio.sleep(0.1) with deterministic synchronization.

await asyncio.sleep(0.1) assumes the worker can drain the queue within 100 ms, which is fine locally but can flake on a loaded CI runner. Prefer waiting on an observable signal, e.g. await task_queue._generation_queue.join() (with the test already guaranteeing both jobs were enqueued), or poll on _queued_generation_ids / _cancelled_generation_ids being empty via asyncio.wait_for.

♻️ Proposed fix
     release_running.set()
-    await asyncio.sleep(0.1)
+    await asyncio.wait_for(task_queue._generation_queue.join(), timeout=1)

     assert not queued_ran.is_set()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/tests/test_task_queue_cancellation.py` around lines 29 - 32, The test
uses a fixed await asyncio.sleep(0.1) which can flake; replace it with
deterministic synchronization by waiting for the queue to drain or for
observable internal state changes. Specifically, after release_running.set(),
await task_queue._generation_queue.join() (since the test already enqueued both
jobs) or use asyncio.wait_for to poll until task_queue._queued_generation_ids
and/or task_queue._cancelled_generation_ids reflect the expected empty/state,
then assert not queued_ran.is_set(); update the assertion block around
release_running, queued_ran and the task_queue internals (e.g.,
_generation_queue.join(), _queued_generation_ids, _cancelled_generation_ids) to
remove the sleep and use these deterministic waits.
app/src/components/History/HistoryTable.tsx (1)

652-666: LGTM on the cancel button, minor a11y nit on the icon size.

Behavior and aria-label="Cancel generation" are correct. The h-2 w-2 on both Loader2 and Square matches the surrounding icons in this row, but those are unusually small (~8 px) and below WCAG 2.5.5 minimum touch-target guidance even inside a 24 px button. Consider h-3 w-3 for the cancel icon specifically so it's visually discoverable as a "stop" affordance. Non-blocking.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/components/History/HistoryTable.tsx` around lines 652 - 666, Increase
the icon size for the cancel button to improve discoverability: in the
isGenerating branch where the Button with aria-label="Cancel generation" (and
props disabled={isCancelling}, onClick={() => cancelGeneration.mutate(gen.id)})
renders Loader2 and Square, change their className from "h-2 w-2" to "h-3 w-3"
so the cancel icon is slightly larger while keeping the surrounding Button
styling unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/src/components/History/HistoryTable.tsx`:
- Around line 132-148: The onSuccess of cancelGeneration currently immediately
calls queryClient.invalidateQueries and refetches history while backend
cancellation is still being processed, causing a race where the row remains in
in-progress state and the cancel button stays enabled; fix this by tracking the
cancelled generation id locally (e.g. add a useState holding a Set<string> of
pendingCancels), push the generationId into that set inside
cancelGeneration.onSuccess, and update the History row/button render logic to
disable or hide the cancel button when the id is present until the refetch shows
a non-in-progress status (then remove the id from the set); alternatively, if
you prefer a lighter change, defer the invalidateQueries call in
cancelGeneration.onSuccess with a short timeout (e.g. 500–1000ms) so the
backend’s CancelledError handler has time to flip the status before refetch.

In `@backend/routes/generations.py`:
- Around line 193-218: The queued-cancellation branch can leave the DB row in an
orphaned active state if history.update_generation_status raises after
cancel_generation_job and task_manager.complete_generation have already modified
the queue; wrap the call to history.update_generation_status(...) in a
try/except that catches database errors (e.g. OperationalError/Exception), logs
the failure with logger.info or logger.error including generation_id and
cancellation_state, and returns a partial-success response (e.g. message
indicating queue removed but DB update failed) instead of letting the exception
propagate; alternatively persist the new status (call
history.update_generation_status) before calling
get_task_manager().complete_generation to ensure DB is updated first. Also add a
minimal response_model (e.g. {"message": str}) to the cancel_generation route
decorator and add a logger.info("Cancellation requested for generation %s
(state=%s)", generation_id, cancellation_state) before performing queue changes
to improve visibility.
- Around line 200-201: The current status check is racy; replace the expression
(gen.status or "completed") not in ("loading_model", "generating") with a strict
"never-was-active" check: if gen.status is None: raise
HTTPException(status_code=400, detail="Generation was never active and cannot be
cancelled"). Leave cancellation and the subsequent conflict handling to
cancel_generation_job(generation_id) so transitions after this check are handled
consistently; update the error message to refer only to the never-active case
and reference gen.status and cancel_generation_job for locating the code.

In `@backend/services/generation.py`:
- Around line 130-136: The except block that catches asyncio.CancelledError
around the generation flow (catching asyncio.CancelledError and calling
history.update_generation_status with generation_id and db=bg_db) must re-raise
the CancelledError after persisting the failure state so cancellation propagates
correctly; modify the handler to first persist the "failed" status via
history.update_generation_status (use
asyncio.shield(history.update_generation_status(...)) to protect the DB update
from being cancelled mid-write) and then re-raise the original
asyncio.CancelledError.

In `@backend/services/task_queue.py`:
- Around line 39-62: The worker _generation_worker relies on run_generation to
propagate asyncio.CancelledError so the worker can detect cancelled jobs; update
backend/services/generation.py so that any except asyncio.CancelledError handler
in run_generation (or any coroutine invoked by it) does not swallow the
exception — either remove the CancelledError catch or re-raise it (raise) after
any cleanup, ensuring CancelledError reaches the awaiting code in
_generation_worker.
- Around line 89-108: init_queue(force=True) cancels the existing
_generation_worker_task and running tasks but immediately resets the module
state, risking race conditions where cancelled tasks' finally blocks access
cleared structures; instead, after calling .cancel() on _generation_worker_task
and each task from _running_generation_tasks, await their termination before
clearing/overwriting _generation_queue, _queued_generation_ids,
_running_generation_tasks, and _cancelled_generation_ids — e.g., collect the
cancelled asyncio.Task objects (including _generation_worker_task and values()
of _running_generation_tasks), await them using asyncio.gather(...,
return_exceptions=True) or asyncio.wait with appropriate shielding
(asyncio.shield) to ensure they run their cleanup, then only after they complete
reset the module-level tracking and start a new worker with
create_background_task(_generation_worker()).

---

Nitpick comments:
In `@app/src/components/History/HistoryTable.tsx`:
- Around line 652-666: Increase the icon size for the cancel button to improve
discoverability: in the isGenerating branch where the Button with
aria-label="Cancel generation" (and props disabled={isCancelling}, onClick={()
=> cancelGeneration.mutate(gen.id)}) renders Loader2 and Square, change their
className from "h-2 w-2" to "h-3 w-3" so the cancel icon is slightly larger
while keeping the surrounding Button styling unchanged.

In `@backend/services/task_queue.py`:
- Line 9: Replace the deprecated typing.Coroutine import by importing Coroutine
from collections.abc: update the import statement that currently references
"Coroutine" (in backend/services/task_queue.py) to import Coroutine from
collections.abc instead of typing, keeping other imports like Literal unchanged
so functions/types using Coroutine continue to work with the non-deprecated
symbol.

In `@backend/tests/test_task_queue_cancellation.py`:
- Around line 1-54: Tests start new background worker with
task_queue.init_queue(force=True) but never stop it, causing pending background
tasks and warnings; add a pytest async fixture (function- or module-scoped) that
calls task_queue.init_queue(force=True) in setup and in teardown checks for
task_queue._generation_worker_task, calls .cancel() on it and awaits it (e.g.,
inside an asyncio.wait_for to avoid hanging) to ensure the worker is fully
stopped between tests; update both tests to use this fixture instead of calling
init_queue directly or remove their init_queue calls if the fixture performs
setup.
- Around line 29-32: The test uses a fixed await asyncio.sleep(0.1) which can
flake; replace it with deterministic synchronization by waiting for the queue to
drain or for observable internal state changes. Specifically, after
release_running.set(), await task_queue._generation_queue.join() (since the test
already enqueued both jobs) or use asyncio.wait_for to poll until
task_queue._queued_generation_ids and/or task_queue._cancelled_generation_ids
reflect the expected empty/state, then assert not queued_ran.is_set(); update
the assertion block around release_running, queued_ran and the task_queue
internals (e.g., _generation_queue.join(), _queued_generation_ids,
_cancelled_generation_ids) to remove the sleep and use these deterministic
waits.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1b3c03b5-8c95-446c-890c-25b28a8762c6

📥 Commits

Reviewing files that changed from the base of the PR and between 67bf8e9 and 2e27306.

📒 Files selected for processing (6)
  • app/src/components/History/HistoryTable.tsx
  • app/src/lib/api/client.ts
  • backend/routes/generations.py
  • backend/services/generation.py
  • backend/services/task_queue.py
  • backend/tests/test_task_queue_cancellation.py

Comment on lines +132 to +148
const cancelGeneration = useMutation({
mutationFn: (generationId: string) => apiClient.cancelGeneration(generationId),
onSuccess: async (data) => {
await queryClient.invalidateQueries({ queryKey: ['history'] });
toast({
title: 'Cancelling generation',
description: data.message,
});
},
onError: (error) => {
toast({
title: 'Cancel failed',
description: error instanceof Error ? error.message : 'Could not cancel generation',
variant: 'destructive',
});
},
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

onSuccess invalidation races the actual status transition for running cancels.

For the "running" branch, the backend only requests cancellation — the DB row remains in generating/loading_model until run_generation's CancelledError handler flushes status="failed". The mutation's onSuccess fires immediately and invalidates ['history'], so the refetch will almost always return the row still in progress, leaving the cancel button visible and enabled. A quick second click then hits the backend during the narrow window and gets a 409 "Generation is no longer cancellable" toast even though cancellation was already in flight.

Two light-touch options:

  • After cancelling, locally track the generation id (e.g. useState<Set<string>>) and keep the button disabled (or hide it) until the row's status leaves the in-progress states.
  • Defer the invalidateQueries slightly (e.g. setTimeout(..., 1000)) — less clean, but avoids the immediate stale refetch.

The first option also makes the "Cancelling…" UI accurate end-to-end.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/components/History/HistoryTable.tsx` around lines 132 - 148, The
onSuccess of cancelGeneration currently immediately calls
queryClient.invalidateQueries and refetches history while backend cancellation
is still being processed, causing a race where the row remains in in-progress
state and the cancel button stays enabled; fix this by tracking the cancelled
generation id locally (e.g. add a useState holding a Set<string> of
pendingCancels), push the generationId into that set inside
cancelGeneration.onSuccess, and update the History row/button render logic to
disable or hide the cancel button when the id is present until the refetch shows
a non-in-progress status (then remove the id from the set); alternatively, if
you prefer a lighter change, defer the invalidateQueries call in
cancelGeneration.onSuccess with a short timeout (e.g. 500–1000ms) so the
backend’s CancelledError handler has time to flip the status before refetch.

Comment on lines +193 to +218
@router.post("/generate/{generation_id}/cancel")
async def cancel_generation(generation_id: str, db: Session = Depends(get_db)):
"""Cancel a queued or running generation."""
gen = db.query(DBGeneration).filter_by(id=generation_id).first()
if not gen:
raise HTTPException(status_code=404, detail="Generation not found")

if (gen.status or "completed") not in ("loading_model", "generating"):
raise HTTPException(status_code=400, detail="Only active generations can be cancelled")

cancellation_state = cancel_generation_job(generation_id)
if cancellation_state is None:
raise HTTPException(status_code=409, detail="Generation is no longer cancellable")

if cancellation_state == "queued":
task_manager = get_task_manager()
task_manager.complete_generation(generation_id)
await history.update_generation_status(
generation_id=generation_id,
status="failed",
db=db,
error="Generation cancelled",
)
return {"message": "Queued generation cancelled"}

return {"message": "Generation cancellation requested"}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Inconsistent state possible if the DB update fails after the queue has been marked cancelled.

For the "queued" branch, the sequence is:

  1. cancel_generation_job — marks the ID as cancelled in task_queue (side effect committed).
  2. task_manager.complete_generation — removes it from active tasks (side effect committed).
  3. await history.update_generation_status(..., status="failed", ...) — may raise.

If step 3 raises (e.g. OperationalError), the client sees a 500 but the queue has already discarded the job, and the DB row stays at generating forever (orphaned — exactly the problem referenced in issue #435). Wrap step 3 in a try/except that at minimum logs and returns a partial-success response, or persist the status before touching the queue.

Additionally:

  • Consider adding a minimal response_model (e.g. {"message": str}) for schema consistency with the rest of the module.
  • A logger.info("Cancellation requested for generation %s (state=%s)", generation_id, cancellation_state) would meaningfully improve the "logging/visibility for task status endpoints" item from issue #435.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/routes/generations.py` around lines 193 - 218, The
queued-cancellation branch can leave the DB row in an orphaned active state if
history.update_generation_status raises after cancel_generation_job and
task_manager.complete_generation have already modified the queue; wrap the call
to history.update_generation_status(...) in a try/except that catches database
errors (e.g. OperationalError/Exception), logs the failure with logger.info or
logger.error including generation_id and cancellation_state, and returns a
partial-success response (e.g. message indicating queue removed but DB update
failed) instead of letting the exception propagate; alternatively persist the
new status (call history.update_generation_status) before calling
get_task_manager().complete_generation to ensure DB is updated first. Also add a
minimal response_model (e.g. {"message": str}) to the cancel_generation route
decorator and add a logger.info("Cancellation requested for generation %s
(state=%s)", generation_id, cancellation_state) before performing queue changes
to improve visibility.

Comment on lines +200 to +201
if (gen.status or "completed") not in ("loading_model", "generating"):
raise HTTPException(status_code=400, detail="Only active generations can be cancelled")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Status check is susceptible to a benign TOCTOU.

Between this check and cancel_generation_job(generation_id) below, the generation can transition to completed/failed and the 400 message ("Only active generations can be cancelled") will no longer be accurate — the request will instead correctly bounce to 409 from the next check. Not a correctness bug (the 409 path is sound), but worth noting that the 400 branch is racy and primarily serves the clear "never was active" case. No change required.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/routes/generations.py` around lines 200 - 201, The current status
check is racy; replace the expression (gen.status or "completed") not in
("loading_model", "generating") with a strict "never-was-active" check: if
gen.status is None: raise HTTPException(status_code=400, detail="Generation was
never active and cannot be cancelled"). Leave cancellation and the subsequent
conflict handling to cancel_generation_job(generation_id) so transitions after
this check are handled consistently; update the error message to refer only to
the never-active case and reference gen.status and cancel_generation_job for
locating the code.

Comment on lines +130 to +136
except asyncio.CancelledError:
await history.update_generation_status(
generation_id=generation_id,
status="failed",
db=bg_db,
error="Generation cancelled",
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, find and read the generation.py file around the specified lines
fd generation.py --type f

Repository: jamiepine/voicebox

Length of output: 92


🏁 Script executed:

# Read the generation.py file to see the context around lines 130-136
cat -n backend/services/generation.py | head -180

Repository: jamiepine/voicebox

Length of output: 7032


🏁 Script executed:

# Find and read task_queue.py to understand worker behavior
fd task_queue.py --type f

Repository: jamiepine/voicebox

Length of output: 92


🏁 Script executed:

# Read task_queue.py to see how the worker handles task completion
cat -n backend/services/task_queue.py

Repository: jamiepine/voicebox

Length of output: 4499


🏁 Script executed:

# Search for history.update_generation_status to understand what it does
fd history.py --type f | grep -E "services|backend"

Repository: jamiepine/voicebox

Length of output: 115


🏁 Script executed:

# Read history.py to see the update_generation_status function
cat -n backend/services/history.py

Repository: jamiepine/voicebox

Length of output: 13039


🏁 Script executed:

# Let's verify the exact behavior by checking if there are any tests that demonstrate the cancellation behavior
fd -e py test | head -20

Repository: jamiepine/voicebox

Length of output: 362


🏁 Script executed:

# Read the task queue cancellation test to understand expected behavior
cat -n backend/tests/test_task_queue_cancellation.py

Repository: jamiepine/voicebox

Length of output: 1903


🏁 Script executed:

# Let's check if there are any other places where run_generation is called or cancelled
rg "run_generation|cancel_generation" --type py -B 2 -A 2 | head -80

Repository: jamiepine/voicebox

Length of output: 4094


🏁 Script executed:

# Let's verify the task.cancelled() behavior by checking Python's asyncio documentation
# and the actual implementation of how task.cancelled() works
python3 << 'EOF'
import asyncio

# Test 1: Task that re-raises CancelledError
async def task_with_reraise():
    try:
        await asyncio.sleep(10)
    except asyncio.CancelledError:
        print("Caught, re-raising")
        raise

# Test 2: Task that swallows CancelledError
async def task_swallowing():
    try:
        await asyncio.sleep(10)
    except asyncio.CancelledError:
        print("Caught and swallowed")

async def test():
    # Test re-raise
    t1 = asyncio.create_task(task_with_reraise())
    await asyncio.sleep(0.01)
    t1.cancel()
    try:
        await t1
    except asyncio.CancelledError:
        print(f"Test 1 - task.cancelled() = {t1.cancelled()}, task was re-raised")
    
    # Test swallow
    t2 = asyncio.create_task(task_swallowing())
    await asyncio.sleep(0.01)
    t2.cancel()
    try:
        await t2
    except asyncio.CancelledError:
        print(f"Test 2 - Should not reach here")
    else:
        print(f"Test 2 - task.cancelled() = {t2.cancelled()}, task was swallowed (no exception)")

asyncio.run(test())
EOF

Repository: jamiepine/voicebox

Length of output: 223


🏁 Script executed:

# Let's verify the double-cancellation scenario by checking the init_queue behavior more carefully
cat -n backend/services/task_queue.py | sed -n '89,103p'

Repository: jamiepine/voicebox

Length of output: 752


Re-raise CancelledError after persisting the failure state.

Swallowing asyncio.CancelledError is an asyncio anti-pattern:

  1. Task appears successful to the worker. The task in task_queue.py completes "normally," so task.cancelled() is False and the worker's if not task.cancelled(): raise guard becomes dead code. Observability (e.g. Task.result() / logging) will show a successful completion of what was actually a cancellation.
  2. Breaks structured concurrency. Any caller that cancels this coroutine and awaits it (today: the worker; tomorrow: potentially a supervisor during shutdown) will see it complete successfully and may proceed assuming the task ran to completion.
  3. Double-cancellation hazard. If the owning task is cancelled a second time (e.g. during worker shutdown via init_queue(force=True)), the await history.update_generation_status(...) inside the handler is itself a cancellation point and can raise CancelledError midway, leaving the DB row stuck in generating.
🐛 Proposed fix — persist, then re-raise
     except asyncio.CancelledError:
         await history.update_generation_status(
             generation_id=generation_id,
             status="failed",
             db=bg_db,
             error="Generation cancelled",
         )
+        raise
     except Exception as e:

For extra robustness against the double-cancel case, consider shielding the status update:

await asyncio.shield(history.update_generation_status(...))
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
except asyncio.CancelledError:
await history.update_generation_status(
generation_id=generation_id,
status="failed",
db=bg_db,
error="Generation cancelled",
)
except asyncio.CancelledError:
await history.update_generation_status(
generation_id=generation_id,
status="failed",
db=bg_db,
error="Generation cancelled",
)
raise
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/services/generation.py` around lines 130 - 136, The except block that
catches asyncio.CancelledError around the generation flow (catching
asyncio.CancelledError and calling history.update_generation_status with
generation_id and db=bg_db) must re-raise the CancelledError after persisting
the failure state so cancellation propagates correctly; modify the handler to
first persist the "failed" status via history.update_generation_status (use
asyncio.shield(history.update_generation_status(...)) to protect the DB update
from being cancelled mid-write) and then re-raise the original
asyncio.CancelledError.

Comment on lines 39 to 62
async def _generation_worker():
"""Worker that processes generation tasks one at a time."""
while True:
coro = await _generation_queue.get()
job = await _generation_queue.get()
try:
await coro
if job.generation_id in _cancelled_generation_ids:
_cancelled_generation_ids.discard(job.generation_id)
job.coro.close()
continue

task = asyncio.create_task(job.coro)
_running_generation_tasks[job.generation_id] = task
_queued_generation_ids.discard(job.generation_id)
try:
await task
except asyncio.CancelledError:
if not task.cancelled():
raise
except Exception:
traceback.print_exc()
finally:
_running_generation_tasks.pop(job.generation_id, None)
_queued_generation_ids.discard(job.generation_id)
_generation_queue.task_done()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Worker cancellation handling looks correct, but depends on run_generation re-raising CancelledError.

The except asyncio.CancelledError / if not task.cancelled(): raise pattern only works as intended if the inner coroutine propagates CancelledError. In this PR, backend/services/generation.py catches CancelledError and does not re-raise (see the separate comment there), so await task here will return normally after a user-initiated cancel and task.cancelled() will be False. The branch is effectively dead and the worker loses the ability to distinguish cancelled-runs from completed-runs.

Once run_generation is fixed to re-raise, this block will function correctly. No change needed here beyond the cross-file fix.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/services/task_queue.py` around lines 39 - 62, The worker
_generation_worker relies on run_generation to propagate asyncio.CancelledError
so the worker can detect cancelled jobs; update backend/services/generation.py
so that any except asyncio.CancelledError handler in run_generation (or any
coroutine invoked by it) does not swallow the exception — either remove the
CancelledError catch or re-raise it (raise) after any cleanup, ensuring
CancelledError reaches the awaiting code in _generation_worker.

Comment on lines +89 to +108
def init_queue(force: bool = False):
"""Initialize the generation queue and start the worker.

Must be called once during application startup (inside a running event loop).
"""
global _generation_queue
global _generation_queue, _generation_worker_task
global _queued_generation_ids, _running_generation_tasks, _cancelled_generation_ids

if _generation_worker_task is not None and not _generation_worker_task.done():
if not force:
return
_generation_worker_task.cancel()
for task in list(_running_generation_tasks.values()):
task.cancel()

_generation_queue = asyncio.Queue()
create_background_task(_generation_worker())
_queued_generation_ids = set()
_running_generation_tasks = {}
_cancelled_generation_ids = set()
_generation_worker_task = create_background_task(_generation_worker())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

init_queue(force=True) leaves the old worker in a weird state before re-init.

When cancelling _generation_worker_task and the running tasks, the cancels are fire-and-forget — you immediately overwrite the tracking structures on lines 104–108 without awaiting the prior worker/task teardown. If a cancelled in-flight task is partway through run_generation (e.g., awaiting DB I/O in the new CancelledError handler), its finally may still try to call into stale module state after you've reset _running_generation_tasks, etc.

This is primarily exercised in tests (init_queue(force=True) at the start of each test) and at process shutdown, so it's a minor concern, but consider awaiting shielded teardown of the old worker before resetting, or document that force=True is only safe during shutdown.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/services/task_queue.py` around lines 89 - 108, init_queue(force=True)
cancels the existing _generation_worker_task and running tasks but immediately
resets the module state, risking race conditions where cancelled tasks' finally
blocks access cleared structures; instead, after calling .cancel() on
_generation_worker_task and each task from _running_generation_tasks, await
their termination before clearing/overwriting _generation_queue,
_queued_generation_ids, _running_generation_tasks, and _cancelled_generation_ids
— e.g., collect the cancelled asyncio.Task objects (including
_generation_worker_task and values() of _running_generation_tasks), await them
using asyncio.gather(..., return_exceptions=True) or asyncio.wait with
appropriate shielding (asyncio.shield) to ensure they run their cleanup, then
only after they complete reset the module-level tracking and start a new worker
with create_background_task(_generation_worker()).

@jamiepine
jamiepine merged commit 54a3bf3 into jamiepine:main Apr 18, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Unable to cancel active tasks and cannot delete stale tasks after restart

2 participants