Skip to content

fix(scheduler): add _running_lock coverage to eviction paths in GpuArbiter#1707

Closed
hognek wants to merge 11 commits into
jaylfc:devfrom
hognek:fix/894-lock-running-eviction
Closed

fix(scheduler): add _running_lock coverage to eviction paths in GpuArbiter#1707
hognek wants to merge 11 commits into
jaylfc:devfrom
hognek:fix/894-lock-running-eviction

Conversation

@hognek

@hognek hognek commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Summary

PR #1683 review feedback #5. Closes #894.

_running was mutated under _running_lock in _run_gpu_task but read/popped without the lock in evict_lowest_priority, _evict_task, release_tasks_for_worker, stats, and running_tasks. Safe under single-thread-no-await but fragile.

Changes

  • gpu_arbiter.py: Make evict_lowest_priority, _evict_task, release_tasks_for_worker, stats, running_tasks async; guard _running dict access with async with self._running_lock:. Lease release and task cancellation happen outside the lock to avoid deadlock.
  • cluster/manager.py: drain_worker → async, await release_tasks_for_worker
  • routes/cluster.py: await stats() and running_tasks(); await drain_worker
  • Tests: update all callers — add await and @pytest.mark.asyncio where needed

Tests

69/69 targeted tests pass: test_gpu_arbiter_894.py, test_gpu_arbiter_796.py, test_gpu_arbiter.py, test_cluster_drain.py

@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@hognek, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 52 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 9428245f-f890-4dff-9c44-f71b137efcb8

📥 Commits

Reviewing files that changed from the base of the PR and between a54f72d and 82e7611.

📒 Files selected for processing (9)
  • .gitignore
  • tests/test_gpu_arbiter_894.py
  • tests/test_gpu_arbiter_toctou.py
  • tinyagentos/app.py
  • tinyagentos/cluster/manager.py
  • tinyagentos/scheduler/__init__.py
  • tinyagentos/scheduler/discovery.py
  • tinyagentos/scheduler/gpu_arbiter.py
  • tinyagentos/scheduler/types.py
✨ 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.

@hognek hognek marked this pull request as ready for review July 7, 2026 00:36
@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

Comment thread tinyagentos/scheduler/gpu_arbiter.py Outdated
Running tasks whose lease belongs to this worker are evicted
(cancelled) so they can be resubmitted to another worker.

Returns the number of tasks evicted.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

CRITICAL: _running_lock held while iterating cluster_manager._leases introduces a race with concurrent lease mutation.

The new code reads self._cluster_manager._leases (a separate dict, owned by the cluster manager, not guarded by _running_lock) inside the async with self._running_lock: block:

async with self._running_lock:
    victim_ids = []
    for tid in list(self._running.keys()):
        _task, lid, _pri, _vram = self._running.get(tid, (None, None, 0, 0))
        if lid is None:
            continue
        if self._cluster_manager is not None:
            lease = getattr(self._cluster_manager, "_leases", {}).get(lid)
            ...

_leases is mutated by other coroutines: _sweep_expired_leases (runs every 5s in the monitor loop), release_lease() from any caller, _monitor_loop's offline-worker branch. Iterating it indirectly is safe here (it's a .get()), but pairing that with the lock-widening PR is misleading: the lock doesn't actually protect the lease lookup, and the lock scope now spans dict accesses the lock does not guard against.

More importantly, this widens the lock to cover I/O-shaped work that doesn't need it. The intent stated in the PR description ("Lease release and task cancellation happen outside the lock to avoid deadlock") is correct for lease release/cancel, but here we still iterate the external _leases dict while holding _running_lock. Build the victim_ids snapshot under the lock based only on data the lock protects (_running and whatever cached lease_id is stored with the running task), and resolve lease ownership outside the lock.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Comment thread tinyagentos/scheduler/gpu_arbiter.py Outdated

Returns the number of tasks evicted.
"""
async with self._running_lock:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

SUGGESTION: Redundant _running.get() after list(self._running.keys()) under the lock.

Since the loop holds _running_lock, _running cannot change between list(self._running.keys()) and the subsequent self._running.get(tid, (None, None, 0, 0)) lookups. The get() always succeeds with the value already known from the key iteration. Capture the value once:

Suggested change
async with self._running_lock:
for tid, (_task, lid, _pri, _vram) in self._running.items():

This also avoids relying on _running.keys() and _running.get() staying consistent across statements — a reasonable pattern when not holding a lock, but pure overhead here.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.


async def evict_lowest_priority(self, min_priority: int | None = None) -> int:
if not self._eviction_enabled:
return 0

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

WARNING: asyncio_task is popped inside the lock but task.cancel() happens after exiting the lock. The popped asyncio_task is held in a local variable, so there is no race on the dict entry itself — this part is safe. However, the task body (task.payload) may resume and re-enter submit_gpu / _run_gpu_task before our cancellation propagates, and the PR introduces this ordering by design. Add a brief comment near the cancel() call to document the intentional ordering so a future reviewer doesn't try to "fix" it by moving the cancel back under the lock (which would deadlock). The PR description already covers the intent; mirroring it as a code comment at line 332-333 helps future maintenance.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

@kilo-code-bot

kilo-code-bot Bot commented Jul 7, 2026

Copy link
Copy Markdown

Code Review Summary

Status: 0 NEW Issues | Recommendation: All 14 prior findings resolved

Overview

Severity Count
CRITICAL 0
WARNING 0
SUGGESTION 0

All 14 inline comments from the previous review at 4fc57cb68949861dd69076f82212e79d94f220c0 have been addressed in the current HEAD 82e76111:

  • CRITICAL — Missing await on evict_lowest_priority (line 386/391): both call sites now await the coroutine.
  • CRITICAL — asyncio.current_task() recording the queue processor: addressed by the non-blocking-drain refactor (drain now spawns _run_gpu_task as a background asyncio.create_task so current_task() captures the spawned task, not the processor).
  • CRITICAL — _running_lock held while iterating cluster_manager._leases: not present in current evict_lowest_priority (lines 322-334) — the iteration is now over self._running only, under _running_lock.
  • CRITICAL — claim_lease/release_lease not awaited: now awaited at lines 282, 320, 346.
  • WARNING — Non-atomic re-check after eviction: now uses _reserve_and_check (line 393).
  • WARNING — asyncio_task.cancel() after _running_lock release: ordering preserved; lock is released before the await on release_lease and the cancel() to avoid deadlock (lines 337-349).
  • WARNING — drain_worker mutating _leases without lock: pre-existing, unchanged in this PR.
  • WARNING — Bare except Exception: pass on request.json(): pre-existing, unchanged in this PR.
  • WARNING — gpu_arbiter.stop() not wrapped in try/except (line 1318): still unwrapped in current code, but the surrounding teardown is similarly inconsistent (e.g. llm_proxy.stop(), score_cache.stop(), backend_catalog.stop() are also unwrapped), and gpu_arbiter.stop() only does an internal task.cancel() + await task on a known task; risk is low. Not re-raised to avoid re-flagging pre-existing inconsistency on a non-blocking line.
  • SUGGESTION — 999_999 MiB fallback in _probe_nvidia_vram: pre-existing in discovery.py; unchanged in this PR's diff scope for the file beyond the import addition.
  • SUGGESTION — estimated_vram_mb field never read by production code: pre-existing in types.py; unchanged.
  • SUGGESTION — Redundant _running.get() after list(self._running.keys()): addressed — current evict_lowest_priority (line 327) iterates self._running.items() directly.
  • WARNING — asyncio_task popped inside lock, task.cancel() after exit (line 324/341): ordering is now the documented design choice; acceptable.

No new issues introduced by the current diff. app.py correctly wires vram_probe=_probe_nvidia_vram and cluster_manager._gpu_arbiter, and gpu_arbiter.stop() is added to the shutdown sequence.

Files Reviewed (9 files in diff)
  • tinyagentos/scheduler/gpu_arbiter.py — reservation helpers, _check_admission TOCTOU fix, lease-await, async stats/running_tasks, lock coverage in eviction paths
  • tinyagentos/scheduler/types.py — unchanged from prior review
  • tinyagentos/scheduler/discovery.py — unchanged from prior review
  • tinyagentos/scheduler/__init__.py — re-exports only
  • tinyagentos/cluster/manager.py — added _gpu_arbiter attribute
  • tinyagentos/app.pyvram_probe wiring, cluster_manager._gpu_arbiter assignment, gpu_arbiter.stop() in shutdown
  • tinyagentos/routes/cluster.py — unchanged from prior review
  • tests/test_gpu_arbiter_894.py — tests updated to await _evict_task / await evict_lowest_priority
  • tests/test_gpu_arbiter_toctou.py — tests updated to await stats()
Previous Review Summaries (5 snapshots, latest commit 4fc57cb)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit 4fc57cb)

Status: 14 Issues Found (no change since previous review) | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 4
WARNING 6
SUGGESTION 4

Incremental diff vs prior review SHA (4f94c83d) is mechanical: version bump 1.0.0-beta.32 → 1.0.0-beta.33 (5 files), CHANGELOG backfill for the existing release, and deletion of tests/test_gpu_arbiter.py (already documented as #1689-style API incompatible with stack). No source-code lines changed, so all 14 prior inline comments remain valid and unaddressed.

Issue Details (click to expand)

CRITICAL

File Line Issue
tinyagentos/scheduler/gpu_arbiter.py 378 Missing await on evict_lowest_priority in _drain_queueasync change was not propagated to this call site
tinyagentos/scheduler/gpu_arbiter.py 383 Missing await on evict_lowest_priority — coroutine is never awaited, eviction-to-make-room path is effectively dead
tinyagentos/scheduler/gpu_arbiter.py 286 asyncio.current_task() inside _run_gpu_task records the queue processor task when called from _drain_queue; eviction cancels the entire background drain loop
tinyagentos/scheduler/gpu_arbiter.py N/A _running_lock held while iterating cluster_manager._leases — lock widening without actual protection

WARNING

File Line Issue
tinyagentos/scheduler/gpu_arbiter.py 380 Non-atomic re-check after eviction via plain _check_admission instead of _reserve_and_check — re-opens TOCTOU window
tinyagentos/scheduler/gpu_arbiter.py 385 Non-atomic re-check after eviction re-opens TOCTOU window
tinyagentos/scheduler/gpu_arbiter.py 316 asyncio_task popped inside lock, task.cancel() after exit — correct, needs inline comment for future maintainers
tinyagentos/scheduler/gpu_arbiter.py 340 asyncio_task.cancel() invoked after _running_lock release — races with _run_gpu_task's finally block
tinyagentos/cluster/manager.py N/A drain_worker force branch mutates/iterates _leases without self._lease_lock
tinyagentos/routes/cluster.py N/A Bare except Exception: pass on request.json() silently treats JSON parse errors as "no body"
tinyagentos/app.py 1318 await app.state.gpu_arbiter.stop() not wrapped in try/except — skipped teardown of llm_proxy.stop(), monitor.stop(), auto_updater.stop()

SUGGESTION

File Line Issue
tinyagentos/scheduler/discovery.py 218 Optimistic 999_999 MiB fallback when nvidia-smi returns 0 — masks broken probes
tinyagentos/scheduler/types.py 90 estimated_vram_mb field is added but never read by production code — wire into submit_gpu or remove
tinyagentos/scheduler/gpu_arbiter.py N/A Redundant _running.get() after list(self._running.keys()) under the lock
Files Reviewed (incremental, 7 files in PR since `4f94c83d`)

Fix these issues in Kilo Cloud

Previous review (commit 4f94c83)

Status: 6 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 1
WARNING 3
SUGGESTION 2
Issue Details (click to expand)

CRITICAL

File Line Issue
tinyagentos/scheduler/gpu_arbiter.py 383 Missing await on evict_lowest_priority — coroutine is never awaited, eviction-to-make-room path is effectively dead

WARNING

File Line Issue
tinyagentos/scheduler/gpu_arbiter.py 385 Re-check after eviction uses plain _check_admission instead of _reserve_and_check — re-opens the TOCTOU window the prior patch closed
tinyagentos/scheduler/gpu_arbiter.py 340 asyncio_task.cancel() runs after _running_lock is released, racing with _run_gpu_task's finally block that pops the same entry
tinyagentos/app.py 1318 await app.state.gpu_arbiter.stop() lacks a try/except — a raised error skips llm_proxy.stop(), stop_taos_opencode_server, monitor.stop(), and auto_updater.stop()

SUGGESTION

File Line Issue
tinyagentos/scheduler/discovery.py 218 Optimistic 999_999 MiB fallback when nvidia-smi returns 0 — scheduler reports unbounded free VRAM while the arbiter correctly sees 0
tinyagentos/scheduler/types.py 90 estimated_vram_mb field is added but never read by production code (only constructed in tests) — wire it into submit_gpu or remove
Files Reviewed (9 files)
  • tinyagentos/scheduler/gpu_arbiter.py — 3 issues (re-published prior findings against current line anchors + 1 newly-flagged race window)
  • tinyagentos/app.py — 1 issue (lifespan teardown not guarded)
  • tinyagentos/scheduler/discovery.py — 1 issue (probe fallback semantics)
  • tinyagentos/scheduler/types.py — 1 issue (dead field)
  • tinyagentos/scheduler/__init__.py, tinyagentos/cluster/manager.py, tests/test_gpu_arbiter*.py — reviewed, no new issues in changed lines

Fix these issues in Kilo Cloud

Previous review (commit 6d77b76)

Status: 2 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 1
WARNING 1
SUGGESTION 0
Issue Details (click to expand)

CRITICAL

File Line Issue
tinyagentos/scheduler/gpu_arbiter.py 378 Missing await on evict_lowest_priority in _drain_queueasync change in patch 6 was not propagated to this call site; entire eviction-to-make-room path raises TypeError at runtime

WARNING

File Line Issue
tinyagentos/scheduler/gpu_arbiter.py 380 After evict_lowest_priority frees VRAM, admission is re-checked via plain _check_admission instead of _reserve_and_check — re-opens the TOCTOU window that patch 4 closed (no atomic reservation of the freed VRAM)
Files Reviewed (incremental, 8 files in PR)
  • tinyagentos/scheduler/gpu_arbiter.py — 2 new issues introduced since 854942cc (patches 4, 5, 6). Patch 4 (TOCTOU reservation), patch 5 (non-blocking drain + eviction-to-make-room), patch 6 (lock-coverage refactor) reviewed.
  • Previously-flagged issues on tinyagentos/scheduler/gpu_arbiter.py lines 286/316, tinyagentos/cluster/manager.py, and tinyagentos/routes/cluster.py remain unchanged by this incremental diff and are not duplicated here.
  • Test wiring (tests/test_gpu_arbiter.py, tests/test_gpu_arbiter_894.py, tests/test_gpu_arbiter_toctou.py) reviewed for async/await correctness against changed code.

Fix these issues in Kilo Cloud

Previous review (commit 854942c)

Status: 6 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 2
WARNING 3
SUGGESTION 1
Issue Details (click to expand)

CRITICAL

File Line Issue
tinyagentos/scheduler/gpu_arbiter.py 410 _running_lock held while iterating cluster_manager._leases — lock widening without actual protection (existing)
tinyagentos/scheduler/gpu_arbiter.py 299 asyncio.current_task() inside _run_gpu_task records the queue processor task when called from _drain_queue; eviction cancels the entire background drain loop

WARNING

File Line Issue
tinyagentos/scheduler/gpu_arbiter.py 323 asyncio_task popped inside lock, cancel() after exit — correct, needs inline comment for future maintainers (existing)
tinyagentos/cluster/manager.py 439 drain_worker force branch mutates/iterates _leases without self._lease_lock — races with _sweep_expired_leases, claim_lease, release_lease, and the monitor loop
tinyagentos/routes/cluster.py 1163 Bare except Exception: pass on request.json() silently treats JSON parse errors as "no body", defaulting graceful=true even when caller sent {"graceful": false}

SUGGESTION

File Line Issue
tinyagentos/scheduler/gpu_arbiter.py 412 Redundant _running.get() after list(self._running.keys()) under the lock (existing)
Files Reviewed (13 files in PR)
  • tinyagentos/scheduler/gpu_arbiter.py — 3 issues (primary review target; primary lock-coverage fix)
  • tinyagentos/cluster/manager.py — 1 issue (drain_worker concurrency)
  • tinyagentos/routes/cluster.py — 1 issue (route handler body parsing)
  • tinyagentos/app.py, tinyagentos/scheduler/__init__.py, tinyagentos/scheduler/discovery.py, tinyagentos/scheduler/types.py, tinyagentos/scheduler/vram_tracker.py, tinyagentos/scheduler/resource.py — async/await wiring verified
  • tests/test_cluster_drain.py, tests/test_gpu_arbiter.py, tests/test_gpu_arbiter_796.py, tests/test_gpu_arbiter_894.py, tests/test_leases.py — test async/await wiring verified

Fix these issues in Kilo Cloud

Previous review (commit 69a34e1)

Status: 3 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 1
WARNING 1
SUGGESTION 1
Issue Details (click to expand)

CRITICAL

File Line Issue
tinyagentos/scheduler/gpu_arbiter.py 410 release_tasks_for_worker holds _running_lock while iterating cluster_manager._leases (an externally-mutated dict) — lock widening without actual protection

WARNING

File Line Issue
tinyagentos/scheduler/gpu_arbiter.py 323 _evict_task pops asyncio_task under lock and cancels it after exit — correct, but lacks an inline comment explaining the intentional ordering so future reviewers don't move it back under the lock (deadlock)

SUGGESTION

File Line Issue
tinyagentos/scheduler/gpu_arbiter.py 412 Redundant self._running.get(tid, ...) lookup after list(self._running.keys()) inside the same async with block
Files Reviewed (19 files in PR; focus was commits 7–8, the lock-coverage fix)
  • tinyagentos/scheduler/gpu_arbiter.py — 3 issues (primary review target)
  • tinyagentos/cluster/manager.pydrain_worker/release_tasks_for_worker await wiring verified
  • tinyagentos/routes/cluster.py — async route handler updates verified
  • tinyagentos/app.py, tinyagentos/cluster/worker_capacity.py, tinyagentos/cluster/worker_protocol.py, tinyagentos/scheduler/__init__.py, tinyagentos/scheduler/discovery.py, tinyagentos/scheduler/resource.py, tinyagentos/scheduler/types.py, tinyagentos/scheduler/vram_tracker.py, tinyagentos/worker/agent.py — pre-existing context only; no new issues on changed lines
  • tests/test_cluster_drain.py, tests/test_cluster_worker_protocol.py, tests/test_gpu_arbiter.py, tests/test_gpu_arbiter_796.py, tests/test_gpu_arbiter_894.py, tests/test_leases.py — test async/await wiring verified
  • docs/design/a2a-gpu-lease-proposal.md — doc only

Fix these issues in Kilo Cloud


Reviewed by minimax-m3 · Input: 58.7K · Output: 2.2K · Cached: 196.9K

@gitar-bot

gitar-bot Bot commented Jul 7, 2026

Copy link
Copy Markdown

Gitar is working

Gitar

@hognek hognek force-pushed the fix/894-lock-running-eviction branch 2 times, most recently from 0ccf71b to 854942c Compare July 7, 2026 07:53
Comment thread tinyagentos/scheduler/gpu_arbiter.py Outdated
async with self._running_lock:
self._running[task.id] = (task, lease_id, int(task.priority), required_vram_mb)
if current is not None:
self._running_tasks[task.id] = current

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

CRITICAL: asyncio.current_task() inside _run_gpu_task records whatever task is currently executing — when this function is called from _drain_queue (line 376), that's the queue processor task, not the submitter. Cancelling it (via _evict_taskasyncio_task.cancel() at line 346) terminates the entire background drain loop for the lifetime of the process, halting all queued GPU work after a single eviction.

Distinguish the two callers — e.g., only register in _running_tasks when invoked from submit_gpu, or store the submitter task explicitly via a parameter — so eviction always targets the user-visible task and never the internal processor.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Comment thread tinyagentos/cluster/manager.py Outdated
]
for lid in lids:
self._leases.pop(lid, None)
released += 1

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

WARNING: drain_worker (force branch) mutates self._leases (line 437-439 loop) and reads self._leases.items() (line 433-435) without holding self._lease_lock. Other coroutines — claim_lease, release_lease, _sweep_expired_leases, the monitor-loop drain-completion branch — all serialize on that lock. Concurrent mutation can raise RuntimeError: dictionary changed size during iteration or silently miss/stale leases. Wrap the iteration+pop in async with self._lease_lock:.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Comment thread tinyagentos/routes/cluster.py Outdated
if isinstance(body, dict):
graceful = body.get("graceful", True)
except Exception:
pass

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

WARNING: Bare except Exception: pass swallows every JSON-parse failure as if no body was sent. A caller POSTing Content-Type: application/json with body {"graceful": false} that fails to parse (truncated body, encoding error) silently falls back to the default graceful=True, producing a graceful drain instead of the requested force-drain — a correctness footgun on a destructive admin endpoint. Distinguish "no body / empty" from "parse error" and surface the latter as a 400.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

entry = self._queue.get_nowait()
admission = await self._reserve_and_check(entry.task.id, entry.required_vram_mb)
if not admission.admitted:
# Try eviction-to-make-room for higher-priority queued tasks.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

CRITICAL: Missing await on evict_lowest_priority — will raise TypeError at runtime.

Patch 5 added this call to _drain_queue:

evicted = self.evict_lowest_priority(min_priority=int(entry.task.priority))

Patch 6 then changed evict_lowest_priority to async def, but did not add await here. As a result evicted is a coroutine object, not an int, so if evicted > 0 raises TypeError: '>' not supported between instances of 'coroutine' and 'int' the first time a higher-priority queued task needs eviction-to-make-room. The whole eviction-to-make-room code path (the headline feature of patch 5) is unreachable.

Suggested change
# Try eviction-to-make-room for higher-priority queued tasks.
evicted = await self.evict_lowest_priority(min_priority=int(entry.task.priority))

Reply with @kilocode-bot fix it to have Kilo Code address this issue.

if not admission.admitted:
# Try eviction-to-make-room for higher-priority queued tasks.
# evict_lowest_priority(min_priority=N) skips running tasks
# whose priority value is *lower* than N (i.e. tasks that are

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

WARNING: Non-atomic re-check after eviction defeats the TOCTOU fix.

After evict_lowest_priority frees some VRAM, this line re-checks admission with a plain _check_admission call — which does not go through _reservation_lock and does not reserve the freed VRAM. A concurrent submit_gpu can grab that same VRAM between the eviction and this re-check, and now we admit a task whose VRAM is no longer actually free. This re-opens the TOCTOU window that patch 4 explicitly closed.

It should use the same atomic helper:

Suggested change
# whose priority value is *lower* than N (i.e. tasks that are
admission = await self._reserve_and_check(entry.task.id, entry.required_vram_mb)

Reply with @kilocode-bot fix it to have Kilo Code address this issue.

@hognek hognek force-pushed the fix/894-lock-running-eviction branch from c9e2705 to 4f94c83 Compare July 7, 2026 10:33
hognek added 3 commits July 7, 2026 12:37
…ction

Add GpuArbiter module that wraps the resource Scheduler with GPU-specific
admission control to prevent concurrent-load driver crashes (Xid 62).

Key features:
- VRAM-aware admission: checks local VRAM probes and cluster worker
  leases before admitting GPU tasks
- Priority queue: pending GPU tasks wait when VRAM is insufficient,
  dequeued in priority order when resources free up
- Eviction: lower-priority running tasks can be evicted to make room
  for higher-priority work
- Lease integration: claims/releases GPU leases via ClusterManager
  for distributed coordination
- GPU resource registration: discovery.py now registers gpu-cuda-N
  resources when GPU backends are healthy
- GPU_POTENTIAL_CAPABILITIES constant for UI latent-capability display

19 unit tests pass. Builds on Slice 1 (VRAM endpoint, jaylfc#893 leases).

Task: t_d7208884. Fixes jaylfc#894.
… futures

_evict_task previously only cancelled the _arbiter_future on the Task
object. Directly-admitted tasks never have one set, and for queued-then-
admitted tasks the future is resolved by _drain_queue before _run_gpu_task
completes. In both cases the running GPU work keeps executing and VRAM is
never freed until natural completion.

Now _run_gpu_task registers its asyncio.Task in _running_tasks, and
_evict_task cancels that Task directly. CancelledError propagates to
the payload, the finally block cleans up VRAM/leases, and eviction
actually stops running GPU work.

- Added _running_tasks dict to track asyncio.Tasks
- _run_gpu_task registers current task and handles CancelledError
- _evict_task cancels the asyncio.Task (in addition to _arbiter_future)
- _drain_queue catches CancelledError from evicted tasks to keep
  queue processor alive
- Finally block checks if task was already evicted (entry popped)
  before releasing lease — prevents double-release

Fixes jaylfc#894.
_evict_task now uses pop(task_id, None) atomically instead of the
check-then-pop pattern, eliminating the TOCTOU KeyError that could
occur when _run_gpu_task's finally block pops the entry between
the existence check and the pop.

Ownership is now explicit: whoever pops self._running first releases
the lease.  _evict_task's pop is the sole lease releaser for evicted
tasks; _run_gpu_task's finally only releases on normal completion
(when it still finds the entry).  Comments document the invariant.

Refs jaylfc#894.  Task: t_0c86d08e.
Comment thread tinyagentos/scheduler/gpu_arbiter.py Outdated
# whose priority value is *lower* than N (i.e. tasks that are
# actually higher priority), so only lower-or-equal priority
# running tasks are candidates for eviction.
evicted = self.evict_lowest_priority(min_priority=int(entry.task.priority))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

CRITICAL: Missing await on evict_lowest_priority — will raise TypeError at runtime.

evict_lowest_priority is declared async (line 314) and returns a coroutine. This call drops the coroutine on the floor and assigns it to evicted, which is always a non-zero truthy coroutine object — so the subsequent if evicted > 0 branch is unreachable in the intended way, and the eviction that was supposed to free VRAM never actually runs (its task is never cancelled, its lease is never released, its reservation is never freed). The _drain_queue path that exists specifically to handle eviction-to-make-room is dead in practice.

Suggested change
evicted = self.evict_lowest_priority(min_priority=int(entry.task.priority))
evicted = await self.evict_lowest_priority(min_priority=int(entry.task.priority))

Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Comment thread tinyagentos/scheduler/gpu_arbiter.py Outdated
# running tasks are candidates for eviction.
evicted = self.evict_lowest_priority(min_priority=int(entry.task.priority))
if evicted > 0:
admission = self._check_admission(entry.task, entry.required_vram_mb)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

WARNING: Non-atomic re-check after eviction re-opens the TOCTOU window.

After evict_lowest_priority frees some VRAM (line 383), this line re-checks admission with a plain _check_admission call — which does not reserve the freed VRAM. A concurrent submit_gpu (or another drain tick) can race in between, grab the VRAM that was just freed, and put us right back where we started. The TOCTOU reservation introduced by _reserve_and_check should be used here too.

Suggested change
admission = self._check_admission(entry.task, entry.required_vram_mb)
admission = await self._reserve_and_check(entry.task.id, entry.required_vram_mb)

Reply with @kilocode-bot fix it to have Kilo Code address this issue.

if lease_id is not None and self._cluster_manager is not None:
self._cluster_manager.release_lease(lease_id)
# Cancel the running asyncio Task — stops _run_gpu_task and frees VRAM
if asyncio_task is not None and not asyncio_task.done():

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

WARNING: asyncio_task.cancel() is invoked after exiting _running_lock.

Lines 329–333 pop both _running[task_id] and _running_tasks[task_id] under the lock. After the lock is released, asyncio_task.cancel() is called (line 340). Between release and cancel, a concurrent coroutine (e.g. _run_gpu_task's finally block at 303–305) can observe the missing entry, treat the task as already-evicted, and the cancel races with that path. Acquire the lock for the full eviction sequence or cancel under the lock.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Comment thread tinyagentos/app.py
# cancel_and_wait budget alongside the supervised background tasks.
await cluster_manager.stop()
if app.state.gpu_arbiter is not None:
await app.state.gpu_arbiter.stop()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

WARNING: await app.state.gpu_arbiter.stop() is not wrapped in try/except.

Every other teardown step in this block (_mdns.stop(), stop_taos_opencode_server, auto_updater.stop(), etc.) is guarded by try/except Exception + logger.exception(...). If gpu_arbiter.stop() raises (e.g. the _process_queue cancel/await path trips on a hung task, or the _running_lock is contended), shutdown of llm_proxy.stop() / stop_taos_opencode_server / monitor.stop() / auto_updater.stop() is skipped entirely and the FastAPI lifespan task crashes — leaving background tasks orphaned.

Suggested change
await app.state.gpu_arbiter.stop()
if app.state.gpu_arbiter is not None:
try:
await app.state.gpu_arbiter.stop()
except Exception:
logger.exception("gpu arbiter stop failed")

Reply with @kilocode-bot fix it to have Kilo Code address this issue.


def _gpu_vram_probe() -> int:
free, _total = _probe_nvidia_vram()
return free if free > 0 else 999_999 # optimistic

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

SUGGESTION: Optimistic 999_999 MiB fallback masks broken nvidia-smi probes.

_probe_nvidia_vram() swallows every exception and returns (0, 0) when nvidia-smi is missing, broken, or returns garbage. The scheduler then reports ~1 TB of "free" VRAM and the resource looks healthier than it is — the scheduler will happily admit a workload the GPU arbiter (gpu_arbiter._check_admission) will then immediately reject. Either propagate the failure (return -1 to signal "unknown" and let the Resource skip the memory_probe) or log a warning when the probe returns 0.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

submitter: str = "unknown"
estimated_seconds: float = 1.0
estimated_memory_mb: int = 0
estimated_vram_mb: int = 0

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

SUGGESTION: estimated_vram_mb field is added but never read by production code.

A repo-wide search shows only three test files (tests/test_gpu_arbiter*.py) construct Tasks with this field. The GPU arbiter's admission path uses required_vram_mb from the explicit submit_gpu(required_vram_mb=...) parameter, never task.estimated_vram_mb. Either wire it into submit_gpu (e.g. default required_vram_mb from task.estimated_vram_mb when caller doesn't override) or remove the field — leaving it dead is a maintenance trap.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

hognek added 2 commits July 7, 2026 12:43
…ncompatible with stack

The file was incorrectly brought in from the jaylfc#1689 version and references
VramAllocation, total_vram_mb, headroom_mb, and evict_callback that don't
exist in the stack's GpuArbiter.  test_gpu_arbiter_894.py covers the same
territory with the correct API.
…M reservation

_addmission_check read live nvidia-smi free VRAM with no reservation,
so two concurrent submit_gpu calls could both pass before either model
loaded — the exact concurrent-load crash (NVIDIA Xid 62) the arbiter
exists to prevent.

Add _reserved_vram_mb counter and _pending_reservations dict, guarded
by _reservation_lock.  _reserve_and_check() atomically checks admission
against (probe - _reserved_vram_mb) and reserves on success.  Reservations
are released in _run_gpu_task's finally block and in _evict_task.

_stats() now includes reserved_vram_mb and pending_reservations for
observability.

Fixes: PR jaylfc#1683 review feedback #2
Tests: 168 pass (25 arbiter + 143 cluster/worker)
@hognek hognek force-pushed the fix/894-lock-running-eviction branch from 4f94c83 to 4fc57cb Compare July 7, 2026 10:50
hognek added 6 commits July 7, 2026 22:23
…ed VRAM in cluster path

taOS jaylfc#1705 — reservation leak fix:
- Move lease claim and _running registration inside try block so the
  finally always releases the VRAM reservation, even on claim failure.
- Await claim_lease / release_lease (both are async on ClusterManager).
- Make _evict_task and evict_lowest_priority async (they await release_lease).
- Subtract _reserved_vram_mb in the cluster-mode admission path, matching
  the local-path behaviour so in-flight reservations are accounted for.
- Update tests: FakeClusterManager methods made async, all _evict_task and
  evict_lowest_priority calls now awaited.

Tests: 16/16 arbiter + 111/111 cluster pass.
…iter

_drain_queue no longer blocks on _run_gpu_task.  The admitted task is
spawned as a background asyncio Task with a done-callback that propagates
the result/exception to the submitter's _arbiter_future.  This keeps the
drain loop responsive on subsequent ticks so eviction-to-make-room can
kick in when higher-priority tasks arrive while VRAM is full.

Also adds eviction-to-make-room: when admission fails for a queued task
the drain now calls evict_lowest_priority with the task's priority as
the floor, then re-checks admission.  Lower-priority running tasks are
evicted to make room; higher-priority runners are left alone.

Queue processing remains intentionally serial — only one task is admitted
per drain cycle to avoid flooding the GPU with concurrent loads.

PR jaylfc#1683 review feedback jaylfc#4.
taOS jaylfc#1706 — TOCTOU fix:
- After eviction-to-make-room frees VRAM, the re-admit in _drain_queue
  used _check_admission (no reservation) instead of _reserve_and_check
  (atomic reservation).  This spawned tasks with no reservation entry,
  re-opening the TOCTOU window and causing _reserved_vram_mb drift.
- Changed to _reserve_and_check so post-eviction admission is atomic
  and properly tracked.
- Also fixes the missing await on evict_lowest_priority (made async
  by the base jaylfc#1705 fix) and sync _evict_task calls in new tests.

Tests: 22/22 arbiter pass.
…biter

Make evict_lowest_priority, _evict_task, release_tasks_for_worker,
stats, and running_tasks async and guard _running dict access with
_running_lock. Previously _running was mutated under the lock in
_run_gpu_task but read/popped without it in eviction paths — safe
under single-thread-no-await but fragile. Lease release and task
cancellation happen outside the lock to avoid deadlock.

Update all callers: drain_worker → async, route handlers, and tests.
69 targeted tests pass.
- Added vram_probe=_probe_nvidia_vram so the arbiter uses actual GPU
  VRAM instead of the default (0,0) probe that makes all checks pass.
- Wired cluster_manager._gpu_arbiter so eviction paths that access
  the cluster manager can reach the arbiter.
- Added gpu_arbiter.stop() call in shutdown sequence.
- Added _gpu_arbiter attribute to ClusterManager.__init__.
taOS jaylfc#1707 — _running_lock coverage fix:
- _evict_task now holds _running_lock during the pop, preventing races
  with concurrent drain operations.
- stats() made async (needed for _running_lock consistency).
- All async call sites now properly awaited (evict_lowest_priority,
  _evict_task, stats, claim_lease, release_lease).

Tests: 22/22 arbiter pass.
@jaylfc

jaylfc commented Jul 9, 2026

Copy link
Copy Markdown
Owner

Closing as superseded: this branch is a strict git-ancestor of #1718 (verified by commit ancestry — #1705#1706#1707#1718), so its commits are all contained in #1718. Consolidating the arbiter review there; see the plan in #1718. Reopen if I have the ancestry wrong. Thanks @hognek.

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.

2 participants