Skip to content

fix(gpu): record WHERE a wedged job was stuck when its budget expires (#1338) - #1355

Merged
debpalash merged 4 commits into
mainfrom
fix/1338-wedged-worker-stacks
Aug 4, 2026
Merged

fix(gpu): record WHERE a wedged job was stuck when its budget expires (#1338)#1355
debpalash merged 4 commits into
mainfrom
fix/1338-wedged-worker-stacks

Conversation

@debpalash

@debpalash debpalash commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Makes #1338 / #1329 / #1348 diagnosable. Does not fix the underlying hang — see "What this is not" below.

The problem with the reports

Three reporters on v0.4.2 hit "TTS generate ran for more than 300s of actual compute time and was abandoned". Two of them were on an RTX 3050 and an RTX 3060, rendering a single sentence. GpuJobTimeoutError is only raised once a worker has picked the job up — queue wait is bounded separately and surfaces as a retryable 503 — so that is 300+ seconds of execution, not of waiting in line.

On a 12 GB card, one sentence is not a workload. But the message says "too heavy for the available compute", because that is the only story the timeout path is able to tell.

And nothing in the log could contradict it. The timeout branch logged that the budget was exceeded, reset the pool, and returned. The worker thread cannot be cancelled, so it was still running, still on a real stack — and we discarded it. Every report of this class therefore arrives undiagnosable, and the only advice available is "reproduce it under a debugger", which is not a reasonable thing to ask of someone who just wanted to dub a video.

The change

log_gpu_pool_worker_stacks() — at timeout, before abandoning the job, dump the current frame of every GPU-pool worker.

sys._current_frames() reads the frame of every live thread including one wedged inside a C call, which is precisely the case that matters: the worker is stuck somewhere in the model, and it is the one thing we cannot ask it about.

  • Filtered to gpu-pool workers, by the existing _GPU_POOL_THREAD_PREFIX. The process also holds a web server, a watchdog, and a watermark pool; a dump of everything is a dump nobody reads.
  • Capped at 25 frames — deep enough to cross the engine adapter into the model's own stack, shallow enough that a 1-worker and an 8-worker host both produce something a human will read.
  • Can never raise. It runs inside the timeout handler, so a diagnostic that throws would replace a real GpuJobTimeoutError with an unrelated crash.
  • Runs before reset(), and that ordering is asserted rather than assumed. reset() swaps in a fresh executor, and once the wedged thread is no longer named as a pool worker the capture finds nothing — it would still run, still log, and be empty, which is worse than not having it because it looks like it worked.

It logs at WARNING, so it lands in backend.log and therefore in diagnostic bundles and auto-filed bug reports without the user doing anything.

What this is not

This does not fix the hang. It makes the next report of it carry the one fact needed to fix it: which frame the job died in. If the deepest frame is inside the model's forward pass, it is a hang and the "too heavy for the available compute" wording is actively misleading; if it is in a host-to-device copy, the VRAM story was right after all. Right now we cannot tell those apart, which is why #1338 has been stuck on "please attach a log" for a week.

Tests

tests/test_gpu_wedge_stack_capture.py — 5 tests, all failing before this change:

  • the capture reaches the frame the worker is actually parked in (asserted by function name, since that is the only part a maintainer reads);
  • non-pool threads are excluded;
  • no pool worker at all returns empty rather than erroring — the timeout path must not depend on it finding anything;
  • it never raises, even when thread enumeration itself throws;
  • end-to-end through run_on_gpu_pool_guarded: the capture happens before reset(), and the captured text names the wedged function.

Related suites green: test_generate_timeout_730, test_gpu_pool_queue_accounting_1190, test_recurrence_hardening.

One thing I noticed while running these and did not touch here: tests/test_load_budget_split_1033.py::test_speech_survives_a_load_slower_than_the_generate_budget fails when the file is run on its own and passes in the full suite, on clean main as well as on this branch. That is order-dependence in the opposite direction from #1269 and belongs in its own change.

When a GPU job exceeds its execution-time budget, the timeout path logs up to 25 sanitized stack frames for each GPU-pool worker before resetting the pool. The capture filters unrelated and stale workers, supports C calls, and suppresses diagnostic failures so timeout handling continues. Reviewers should assess WARNING-level log volume and confirm sanitization remains fail-closed.

Three reporters on v0.4.2 hit "TTS generate ran for more than 300s of
actual compute time and was abandoned" (#1338, #1329, #1348) — two of them
on an RTX 3050 and an RTX 3060, rendering a single sentence. That is not a
machine too slow for the job. The message says "too heavy for the
available compute" because it is the only story the timeout path can tell.

And nothing in the log could contradict it. The timeout branch logged THAT
the budget was exceeded, reset the pool, and returned. The worker cannot
be cancelled, so it was still running on a real stack — and we threw that
away, which is why every report of this class arrives undiagnosable and
the only advice available is "reproduce it under a debugger".

sys._current_frames() reads the frame of every live thread including one
wedged inside a C call, which is exactly this case. Filtered to gpu-pool
workers so the log names the stuck job rather than the web server, capped
at 25 frames, and it can never raise — a diagnostic that throws would
replace a real GpuJobTimeoutError with an unrelated crash.

Ordering is load-bearing and asserted: the capture runs BEFORE reset(),
because reset() swaps in a fresh executor and the wedged thread then stops
being identifiable as a pool worker — the diagnostic would still run, still
log, and be empty, which looks like it worked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The timeout diagnostic now captures sanitized GPU-worker stacks before resetting the executor and distinguishes current-pool workers from workers abandoned by earlier timeouts.

  • Adds bounded per-worker stack capture with fail-safe sanitization and logging.
  • Adds regression coverage for capture ordering, filtering, redaction, stale-worker attribution, and diagnostic failure handling.
  • Updates the changelog for the improved timeout diagnostics.

Important Files Changed

Filename Overview
backend/services/model_manager.py Adds fail-safe timeout stack capture and resolves the prior stale-worker attribution issue by identifying workers belonging to the current executor.
tests/test_gpu_wedge_stack_capture.py Covers stack visibility, filtering, ordering before reset, sanitization, stale-worker labels, and fallback behavior.
CHANGELOG.md Documents the new timeout diagnostic in the unreleased fixes.

Reviews (4): Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." | Re-trigger Greptile

Comment thread backend/services/model_manager.py
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 1e472dfe-4f04-4ea3-88e5-e53bd6c81b5e

📥 Commits

Reviewing files that changed from the base of the PR and between 497602f and c20814b.

📒 Files selected for processing (1)
  • CHANGELOG.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • CHANGELOG.md

📝 Walkthrough

Walkthrough

GPU timeout handling captures and sanitizes GPU worker stacks before pool reset. It labels current and stale workers and suppresses diagnostic failures. Tests cover filtering, ordering, redaction, and fallback behavior. The changelog documents TTS timeout-stage reporting.

Changes

GPU timeout diagnostics

Layer / File(s) Summary
Capture and classify GPU worker stacks
backend/services/model_manager.py
The timeout path captures GPU-pool worker stacks before pool reset. The helper identifies current and stale workers, formats up to 25 frames, redacts sensitive paths, logs diagnostics, and suppresses capture failures.
Validate worker capture and timeout ordering
tests/test_gpu_wedge_stack_capture.py
Tests cover worker filtering, stale-worker labeling, path redaction, empty and failed captures, unknown executor internals, timeout propagation, and capture ordering.
Document TTS timeout-stage reporting
CHANGELOG.md
The changelog records the processing stage where abandoned TTS jobs stalled.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 9
✅ Passed checks (9 passed)
Check name Status Explanation
Title check ✅ Passed The title uses conventional-commit format with the gpu scope and includes issue reference #1338.
Description check ✅ Passed The description clearly covers the problem, changes, testing, scope, and related issues, although it omits the template checklist.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cross-Platform Default Parity ✅ Passed The default timeout path uses platform-neutral Python APIs (ThreadPoolExecutor, threading, sys._current_frames, traceback, and sanitize) with no macOS, Windows, or Linux branch.
I18n Completeness (21 Locales) ✅ Passed The PR diff adds no frontend files or t(...) keys; it changes only CHANGELOG.md, backend/services/model_manager.py, and tests, so no locale entry is required.
Local-First Guarantee ✅ Passed The PR delta adds only local stack capture, sanitization, logging, and tests; it adds no network client, account, API-key, telemetry, or cloud call.
Backward Compatibility ✅ Passed The PR changes only timeout diagnostics, changelog, and tests; it adds no schema, migration, data, engine-install, or model-cache changes, and existing load paths remain unchanged.

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.

@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: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/services/model_manager.py`:
- Around line 647-658: Redact home-directory paths from the captured stack text
before the logger.warning call in the stack-reporting flow, preserving the
existing stack content otherwise. Reuse the project’s existing home-path
redaction helper if available, and add coverage for Unix and Windows-style home
prefixes confirming account names are absent from the emitted warning.

In `@tests/test_gpu_wedge_stack_capture.py`:
- Around line 98-101: Update test_capture_is_empty_when_no_pool_worker_exists to
control the process-global worker state before calling
log_gpu_pool_worker_stacks. Monkeypatch threading.enumerate() to return no
gpu-pool workers, or establish an equivalent isolated precondition, while
preserving the assertion that the method returns an empty string.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: c15d8d23-7f89-4039-8a2f-460bb197e10f

📥 Commits

Reviewing files that changed from the base of the PR and between e60c536 and c336321.

📒 Files selected for processing (3)
  • CHANGELOG.md
  • backend/services/model_manager.py
  • tests/test_gpu_wedge_stack_capture.py

Comment thread backend/services/model_manager.py
Comment thread tests/test_gpu_wedge_stack_capture.py Outdated
@debpalash

Copy link
Copy Markdown
Owner Author

CI caught a real defect in my own test rather than in the code: test_capture_is_empty_when_no_pool_worker_exists asserted on the ambient thread list, and in a full session earlier tests build a real GPU pool whose idle workers outlive them. So it passed alone and failed in the suite — the same order-dependence class as #1269, in the opposite direction.

Fixed by manufacturing the absence instead of assuming it: the filter prefix is monkeypatched to something nothing uses, which tests the branch rather than the environment. Verified both ways — pytest tests/test_gpu_pool_queue_accounting_1190.py tests/test_gpu_wedge_stack_capture.py is 20 passed with the fix and 1 failed without it.

Both review findings on this PR, both valid.

CodeRabbit (CWE-532): traceback frames carry absolute source paths, which
on a user machine start with their home directory — their account name.
This log lands in backend.log, which goes into diagnostic bundles and
prefilled bug reports, so it has to be sanitized like every other surfaced
text. Reuses core.failure.sanitize rather than inventing a second answer;
if sanitizing itself fails the stacks are dropped, not logged raw.

greptile P1: a wedged worker survives reset() — it cannot be cancelled and
keeps running under the same gpu-pool name the replacement pool uses. The
second timeout in a session would log both with nothing to tell them
apart, and the stale one is the more misleading, since it names an
operation that is not the job that just failed. The live pool is now
identified through its own thread set and the others are marked STALE.

That set comes from ThreadPoolExecutor._threads, which is private, so
unknown internals degrade to labelling nothing rather than to failing —
a diagnostic that vanishes because an attribute moved is worse than an
unlabelled one, and that degrade path has its own test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@debpalash

Copy link
Copy Markdown
Owner Author

Both findings valid and fixed.

CodeRabbit (CWE-532) — the important one. traceback.format_stack() emits absolute source paths, which on a user's machine begin with their home directory, i.e. their account name. This log goes to backend.log, and backend.log goes into diagnostic bundles and prefilled bug reports — so an unredacted capture would have leaked the username of everyone who ever hit a timeout, in a feature whose whole purpose is to get more of these logs sent to us. Now routed through core.failure.sanitize (the repo's existing answer, not a second one), which also covers HF tokens and *TOKEN*/*KEY*/*SECRET* env values. If sanitizing itself throws, the stacks are dropped rather than logged raw.

greptile P1 — also real. A wedged worker survives reset(): it cannot be cancelled, so it keeps running under the same gpu-pool prefix the replacement pool uses. The second timeout in a session would log both with nothing to distinguish them, and the stale one is the more misleading of the two because it names an operation that is not the job that just failed. The live pool is now identified through its own thread set; everything else is marked STALE — a worker abandoned by an earlier timeout, still running; not the job that just failed.

That set comes from ThreadPoolExecutor._threads, which is private, so I added a third test for the degrade path: unknown internals must cost the label, never the stacks. A diagnostic that disappears because an attribute moved in a future Python is worse than an unlabelled one.

Three new tests; the redaction and stale-label ones both fail against the previous commit. Related suites green (43 passed).

@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: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/test_gpu_wedge_stack_capture.py`:
- Around line 197-198: Ensure readiness-check failures still execute cleanup by
moving the assertion at tests/test_gpu_wedge_stack_capture.py:197-198 inside its
surrounding try/finally; likewise move both readiness checks at
tests/test_gpu_wedge_stack_capture.py:248-249 and the assertion at
tests/test_gpu_wedge_stack_capture.py:285-286 inside their respective try
blocks, preserving the existing cleanup behavior.
- Around line 200-206: Update the redaction test in the worker stack capture
test to construct a worker frame whose filename is explicitly nested under the
resolved home directory, rather than relying on the checkout path. Assert that
this home-path prefix is redacted from the captured text while the worker
function name remains present, ensuring the test fails before and passes after
the redaction fix.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: c9a0afc9-bad4-4137-b21d-a8825032a411

📥 Commits

Reviewing files that changed from the base of the PR and between 953dd25 and 497602f.

📒 Files selected for processing (2)
  • backend/services/model_manager.py
  • tests/test_gpu_wedge_stack_capture.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • backend/services/model_manager.py

Comment on lines +197 to +198
assert entered.wait(5)
try:

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.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Guarantee cleanup after a failed readiness check.

At Line 197, Line 248, and Line 285, a failed Event.wait() assertion occurs before finally, leaving a pool worker blocked and able to contaminate later process-wide captures.

  • tests/test_gpu_wedge_stack_capture.py#L197-L198: move the readiness assertion inside try
  • tests/test_gpu_wedge_stack_capture.py#L248-L249: move both readiness checks inside try
  • tests/test_gpu_wedge_stack_capture.py#L285-L286: move the readiness assertion inside try
    As per coding guidelines, “Fix the root cause and entire bug class.”
📍 Affects 1 file
  • tests/test_gpu_wedge_stack_capture.py#L197-L198 (this comment)
  • tests/test_gpu_wedge_stack_capture.py#L248-L249
  • tests/test_gpu_wedge_stack_capture.py#L285-L286
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_gpu_wedge_stack_capture.py` around lines 197 - 198, Ensure
readiness-check failures still execute cleanup by moving the assertion at
tests/test_gpu_wedge_stack_capture.py:197-198 inside its surrounding
try/finally; likewise move both readiness checks at
tests/test_gpu_wedge_stack_capture.py:248-249 and the assertion at
tests/test_gpu_wedge_stack_capture.py:285-286 inside their respective try
blocks, preserving the existing cleanup behavior.

Source: Coding guidelines

Comment on lines +200 to +206
home = os.path.expanduser("~")
# This test file lives under the developer's home on any dev machine
# and under the runner's home in CI, so the raw frame necessarily
# contains it — which is what makes this a real check rather than a
# tautology.
assert home and home != "~", "cannot verify redaction without a home dir"
assert home not in text, (

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Make the redaction input deterministic.

At Line 200, home comes from the runner, but the worker stack uses the checkout path; if the checkout is outside home, the assertion passes without testing redaction. Create a worker frame with a filename explicitly below home, then assert that this prefix is removed while the function name remains. As per coding guidelines, “add a fail-before/pass-after regression test,” and path instructions, “the test would fail before the fix and pass after.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_gpu_wedge_stack_capture.py` around lines 200 - 206, Update the
redaction test in the worker stack capture test to construct a worker frame
whose filename is explicitly nested under the resolved home directory, rather
than relying on the checkout path. Assert that this home-path prefix is redacted
from the captured text while the worker function name remains present, ensuring
the test fails before and passes after the redaction fix.

Sources: Coding guidelines, Path instructions

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.

1 participant