fix(transcript): preserve process_video error over cleanup cancellation (for #1245) - #1247
Conversation
…on (#1245) The worker-thread cleanup offload in `_fallback_transcript_with_gemini` introduced an `await` inside the `finally` block where the previous inline (synchronous) cleanup had none. That await is a cancellation point: if `process_video` raises and the task is cancelled while the shielded cleanup is still running, `CancelledError` — a `BaseException` — replaces the in-flight error per `finally` semantics. The caller's `except Exception` handler (transcript_action_workflow.py:336) then no longer catches it, so a recoverable per-source failure escapes as an uncaught cancellation, breaking the documented "raised-exception behaviour does not change" contract. Capture any in-flight exception with `sys.exc_info()` before the await and suppress a cleanup-time `CancelledError` only when an exception is already unwinding, keeping the original error primary. On the normal path a genuine cancellation still propagates, and cleanup still completes (shield is unchanged). Adds a regression test that drives the real fallback finally: process_video raises, the task is cancelled mid-cleanup, and the original ValueError — not CancelledError — must surface while the temp tree is still removed. Verified to fail against the pre-fix bare-await form (raises CancelledError) and pass with the fix. Full file: 114 passed; ruff clean. Resolves the exception-precedence finding raised by CodeRabbit and independently confirmed on PR #1245. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EUEMeY1ZBRmF5jXrRYPre2
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Important Review skippedAuto reviews are limited based on label configuration. 🏷️ Required labels (at least one) (1)
Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository YAML (base), Repository UI (inherited), Organization UI (inherited) Review profile: ASSERTIVE Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
Agent Completion Truth Gate: BLOCKEDReasons: Machine-readable verdict{
"details": {
"invalid_fields": [
"issue.number",
"policy.agent_login",
"policy.run_id"
]
},
"reasons": [
"invalid_payload"
],
"verdict": "blocked"
} |
|
CI status + the one blocker (not auto-fixable by me). The code-level and governance sub-checks are green on
Blocker:
So the merge decision is yours, and there are two clean paths — no provenance gymnastics needed either way:
I'll keep watching #1247; I won't fabricate the manifest to force it green. Generated by Claude Code |
|
Closing this — the finding it implements was withdrawn by its own author after re-review, and the change it makes is measurably harmful. 1. The originating finding was retractedCodeRabbit raised the exception-precedence concern on #1245, then re-reviewed
That comment carries a 2. The masking defect is already fixed on the base branch — differently and more completely
That fix is covered by After that change the only thing that can emerge from the cleanup await is 3. Suppressing
|
| scenario | base branch (4ab5a7ddc) |
this PR |
|---|---|---|
asyncio.timeout(0.1) around the call |
TimeoutError ✅ |
inner exception — the timeout is silently swallowed ❌ |
TaskGroup sibling failure |
ExceptionGroup['ValueError'] ✅ |
ExceptionGroup['Boom','ValueError'] — spurious extra ❌ |
task.cancelled() after a real cancel |
True ✅ |
False ❌ |
The production consequence is at transcript_action_workflow.py:340:
except Exception as exc: # noqa: BLE001 - resilient multi-source fallback
# A raising source must not abort the whole pipeline; record it and
# continue to the next source so we can still degrade gracefully.CancelledError is a BaseException, so today it escapes that handler and correctly aborts the request. Downgrade it to a caught Exception and the workflow keeps issuing network calls to further transcript sources after the client has already abandoned the request. That is the exact behaviour CodeRabbit named in its retraction.
4. The sys.exc_info() guard is additionally unsound
sys.exc_info() returns the innermost exception being handled anywhere up the stack, not just in this frame. If any caller ever invokes this from inside an except block, pending_exc is non-None even when nothing is in flight locally, and a genuine cancellation is swallowed:
=== caller NOT inside except (baseline) ===
finally: sys.exc_info()[1] = None
RESULT: CancelledError propagated (correct)
task.cancelled() = True
=== caller INSIDE except block ===
finally: sys.exc_info()[1] = RuntimeError('outer source failure')
finally: CancelledError SUPPRESSED
RESULT: returned 'ok' <-- cancellation LOST
task.cancelled() = False
In fairness: I checked, and this is not reachable today — neither _extract_transcript (L132) nor _fallback_transcript_with_gemini (L335) is called from inside a handler, and the finally at L852 has no enclosing except. So this is a latent trap rather than a live bug. But it makes correctness depend on a caller-side invariant that nothing enforces, in a file whose whole design point is a resilient multi-source fallback built out of except blocks.
5. On the test
test_fallback_finally_keeps_process_video_error_over_cancellation constructs its scenario with gemini_service.process_video = AsyncMock(side_effect=original_error). GeminiService.process_video (gemini_service.py:729-840) contains zero raise statements and catches Exception at L795 and L831, returning GeminiResult(success=False, error=...). It cannot raise in production, so the test pins behaviour that only the mock exhibits.
The genuinely reachable propagation path is _parse_gemini_transcript_payload at L840 — inside the guarded try body (L821-850), and it only catches json.JSONDecodeError. Even for that path, a CancelledError replacing a parse failure is correct: the request is gone, and the parse result is moot.
Net: the base branch already fixes the real masking defect and is strictly safer than the pre-change inline cleanup. This PR trades that for a cancellation-suppression bug that CodeRabbit, Linear, and the measurements above all agree is a regression.
Happy to reopen if someone can produce a case where the base branch masks an exception that this change would preserve — that would invert the argument and I'd want to know before #1245 merges.
|
You're right on every point, and I verified rather than take it on faith. Conceding:
I can't produce the inverting counterexample you asked for — the base branch masks nothing my change would have preserved — so there's nothing to reopen. This also supersedes my earlier "resolves the hold-merge finding" note on #1245: the finding was retracted and already fixed on the base, better. Thanks for the thorough write-up; Generated by Claude Code |
Canonical issue
No competing issue claimed. #1245 remains the canonical PR for #1244; this PR targets #1245's own branch (
perf/transcript-cleanup-off-loop) so its diff is exactly the single follow-up fix commit, which #1245 can absorb by merging this. It does not open a second PR againstmainfor #1244.Outcome
Resolves the one confirmed HIGH review finding holding #1245: the worker-thread cleanup offload changed exception precedence under cancellation. This restores the documented "raised-exception behaviour does not change" contract so a recoverable per-source transcript failure is still caught by the caller instead of escaping as an uncaught cancellation.
Scope
transcript_action_workflow.py— thefinallyin_fallback_transcript_with_gemininow captures any in-flight exception viasys.exc_info()before the cleanupawaitand suppresses a cleanup-timeCancelledErroronly when an exception is already unwinding; addsimport sys.finally(process_video raises, task cancelled mid-cleanup) and asserts the originalValueError— notCancelledError— surfaces while the temp tree is still removed._cleanup_download_artifacts, the shield, or which paths are removed — cleanup still runs to completion and still re-raisesCancelledErroron the normal (no-exception) path.Risk
process_videoraise and a cancellation delivered during cleanup. Fix is a targeted precedence guard at the call site; shield semantics are untouched.Verification
Tied to head
ff19580.pytest tests/unit/test_transcript_action_workflow.py→ 114 passedawaitmakes the new test fail withasyncio.CancelledError(proving it catches the exact bug); restoredruff checkon both changed files → All checks passedProduction evidence
No runtime surface change; the fix only alters which exception propagates on the cancellation-during-cleanup edge. Behaviour is pinned by the new async regression test rather than a deployment.
Agent handoff
mainAgent provenance
Human-authored sections may be trimmed by the owner. This is an agent-authored follow-up; the authoritative scope and canonical issue live in #1245 / #1244.
Generated by Claude Code