Skip to content

[None][fix] reject nested control_action() instead of corrupting its handshake - #17346

Open
lowsfer wants to merge 6 commits into
NVIDIA:mainfrom
lowsfer:control-action-reentrancy-guard
Open

[None][fix] reject nested control_action() instead of corrupting its handshake#17346
lowsfer wants to merge 6 commits into
NVIDIA:mainfrom
lowsfer:control-action-reentrancy-guard

Conversation

@lowsfer

@lowsfer lowsfer commented Aug 6, 2026

Copy link
Copy Markdown
Member

Description

Follow-up to #17163, per the review discussion there (thread): @shuyixiong raised the risk of nested control_action_decorator calls, and we agreed the right place to address it is PyExecutor.control_action() itself rather than any individual caller.

control_request_barrier and control_action_done are single-slot events shared by every control action. If a control action's body opened another one, the inner exit would clear the barrier and set done — releasing the executor loop while the outer body was still running, so the drain the outer caller asked for would silently no longer hold.

Nothing nests today:

  • WorkerExtension.update_weights() reaches the reuse state through the undecorated self.engine.reset_prefix_cache(), not the decorated wrapper.
  • The Ray worker's sleep() / wakeup() call no decorated sibling.
  • The three base_worker control blocks only touch CUDA and memory.

The hazard is real enough that it already had to be worked around by hand — tests/unittest/_torch/ray_orchestrator/multi_gpu/test_inflight_weight_update.py calls WorkerExtension.update_weights.__wrapped__(...) with the comment "Invoking super().update_weights(...) directly would re-enter the drain=True control_action context manager and nest control actions." Without a guard, getting that wrong corrupts the handshake silently instead of failing at the mistake.

Two details of the implementation:

  • The check raises before enqueue_control_request(), so a refused nesting attempt leaves no orphaned control request and does not touch the events the outer action still owns.
  • The flag is cleared in the existing finally, so a body that raises does not wedge the executor.

Test Coverage

tests/unittest/executor/test_control_action_reentrancy.py — 4 CPU-only tests (no GPU, MPI or weights; the executor is built with object.__new__ and only the attributes control_action() touches, following the pattern in test_sleep_collective_rpc_guards.py):

  • nesting is rejected;
  • the rejection leaves the outer handshake intact — barrier still held, done not set, which is the corruption the guard exists to prevent;
  • sequential control actions are still allowed (the guard rejects nesting, not repeated use);
  • the flag is cleared when the body raises.

Verified by mutation, not just by passing:

  • removing the guard → 2 failed (DID NOT RAISE <class 'RuntimeError'>);
  • dropping the finally reset → 3 failed;
  • restored → 4 passed.

The new file is registered in tests/integration/test_lists/test-db/l0_cpu.yml; test-db lists test files individually, so an unregistered unit test never runs in CI.

PR Checklist

  • Please check this after reviewing the above items as appropriate for this PR.

Dev Engineer Review

  • PyExecutor.control_action() rejects same-thread re-entrant calls with RuntimeError.
  • The guard preserves the outer control-action handshake.
  • Bounded barrier polling prevents hangs during shutdown or missed event edges.
  • Cleanup runs when the action body raises.
  • The CPU test-list entry uses the correct path and scope.
  • No correctness, configuration, or consistency issues were identified.

QA Engineer Review

  • Added tests for nested-action rejection, handshake preservation, concurrent serialization, sequential actions, barrier-edge loss, shutdown handling, and cleanup after failures.
  • The test file is registered in tests/integration/test_lists/test-db/l0_cpu.yml.
  • All added tests have CPU CI coverage.
  • Verdict: sufficient.

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

PyExecutor.control_action() now serializes callers, rejects same-thread re-entry, and bounds control-barrier waits. CPU tests cover contention, timeout, shutdown, sequential reuse, exception cleanup, and test-list registration.

Changes

Control action re-entrancy

Layer / File(s) Summary
Enforce control action non-reentrancy and bounded waits
tensorrt_llm/_torch/pyexecutor/py_executor.py
control_action() tracks the owning thread, rejects same-thread re-entry, serializes concurrent callers, and clears ownership on exit. Barrier waits poll with a deadline and fail when the executor shuts down, dies, or loses the barrier edge.
Validate re-entrancy and failure cleanup
tests/unittest/executor/test_control_action_reentrancy.py, tests/integration/test_lists/test-db/l0_cpu.yml
CPU tests cover nested rejection, handshake preservation, thread contention, thread-specific ownership, sequential reuse, barrier timeout, shutdown failure, exception cleanup, and suite registration.

Estimated code review effort: 3 (Moderate) | ~30 minutes

Suggested reviewers: asfiyab-nvidia, bo-nv, schetlur-nv

Sequence Diagram(s)

sequenceDiagram
  participant CallingThread
  participant PyExecutor
  participant ExecutorBarrier
  participant ControlActionBody
  CallingThread->>PyExecutor: control_action()
  PyExecutor->>PyExecutor: acquire lock and check owner thread
  alt same thread already owns control_action
    PyExecutor-->>CallingThread: raise RuntimeError
  else first caller or waiting thread
    PyExecutor->>ControlActionBody: enqueue and execute action
    PyExecutor->>ExecutorBarrier: poll barrier until completion or deadline
    alt shutdown, dead executor, or lost barrier edge
      ExecutorBarrier-->>PyExecutor: raise RuntimeError
    else barrier handshake completes
      ExecutorBarrier-->>PyExecutor: return control completion
      ControlActionBody-->>PyExecutor: complete action
    end
    PyExecutor->>PyExecutor: clear owner and release lock
    PyExecutor-->>CallingThread: return or raise
  end
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.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
Title check ✅ Passed The title uses the required format and clearly identifies the primary fix for nested control_action() calls.
Description check ✅ Passed The description explains the problem, implementation, test coverage, and checklist status with sufficient detail.
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.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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: 1

🤖 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 `@tensorrt_llm/_torch/pyexecutor/py_executor.py`:
- Around line 4451-4464: Make control-action admission atomic in the
control_action flow by acquiring a non-reentrant threading.Lock before the
existing re-entrancy check, request enqueue, and control_request_barrier.wait().
Hold the lock through the outer finally cleanup, release it on every exit path,
and ensure a second thread cannot enter while the first action is waiting or
executing. Add a two-thread test that releases the barrier only after both
callers attempt entry.
🪄 Autofix

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: Enterprise

Run ID: 331be719-1b8e-406f-89db-9988bf09f0f6

📥 Commits

Reviewing files that changed from the base of the PR and between 0b650e6 and 45a6345.

📒 Files selected for processing (3)
  • tensorrt_llm/_torch/pyexecutor/py_executor.py
  • tests/integration/test_lists/test-db/l0_cpu.yml
  • tests/unittest/executor/test_control_action_reentrancy.py

Comment thread tensorrt_llm/_torch/pyexecutor/py_executor.py Outdated
handshake.
"""

if self._control_action_in_progress:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Agreeing with CodeRabbit's thread below, with the evidence that I think settles it: this codebase already documents exactly that race, and already works around it by hand. base_worker.py:719-723:

Serialise concurrent sleep/wakeup calls. control_action() uses an Event-based barrier, not a mutex, so two concurrent callers can both pass the barrier and then interleave sends/recvs on _sleep_wakeup_comm, consuming the wrong ACKs or resuming the event loop prematurely.

That workaround is _sleep_wakeup_lock, and it covers only the three base_worker sites (:725, :923, :961). The @control_action_decorator path takes no lock at all — _ray_utils.py:51 is a bare with self.engine.control_action(drain=drain) — so ray_gpu_worker.py:263,284 and rlhf_utils.py:36,162 can race a sleep()/wakeup() or each other, and land in the corruption this PR's docstring says it prevents.

Which is really an argument for your framing, not against it. You already say the right place is control_action() itself rather than any individual caller — a non-reentrant threading.Lock acquired before the enqueue and released in the finally gives you the nesting rejection (owner thread re-entering → deadlock-free reject via a stored owner ident) and subsumes _sleep_wakeup_lock, so the hand-rolled workaround can go. As written, the bool narrows one of the two ways this handshake gets corrupted and leaves the other looking handled.

Not blocking on scope if you'd rather keep this PR to nesting — but then the docstring and the RuntimeError text should say "same thread", because a concurrent caller currently gets "call the undecorated operation instead", which is the wrong advice for that case.

The rest reads well: rejecting before barrier.wait() and before the rank-0 enqueue is the right placement, clearing the flag ahead of control_action_done.set() is the right order, and the four tests cover the outer-handshake-intact and body-raises cases without needing a GPU.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Thanks — the base_worker.py:715 pointer is what settled it, and it changed my mind. Fixed in f9fa918.

Confirmed your reading: _sleep_wakeup_lock covers only :719, :917, :955, while _ray_utils.py:51 is a bare with self.engine.control_action(drain=drain), so the five decorated methods (rlhf_utils.py:93/211/221, ray_gpu_worker.py:263/284) take no lock at all. As you say, the bool narrowed one route into the corruption and left the other looking handled — which is worse than not touching it.

Also checked the concurrency is real rather than theoretical, since it was reasonably questioned: rpc_server.py:69 creates a ThreadPoolExecutor(max_workers=num_workers) with num_workers=4 and dispatches registered methods via run_in_executor (:476), so two RPC calls genuinely land on different threads. (The Ray actor path is separately serialised — RayWorkerWrapper.options(...) sets no max_concurrency, so Ray's default of 1 applies. The RPC path is where it bites.)

Implemented as you suggested: threading.Lock acquired before the enqueue and released in the finally, with a stored owner ident so a nested call from the holding thread is rejected deadlock-free rather than blocked. Concurrent callers serialise, matching what _sleep_wakeup_lock already gives its three sites.

One deliberate omission: I have not removed _sleep_wakeup_lock. It is now redundant, but deleting it touches the multi-rank sleep/wakeup paths this PR otherwise does not, and those are hard to exercise locally. Happy to do it as a follow-up, or in this PR if you would rather see it closed out here.

Tests: the two-thread cases are added — one proving concurrent callers serialise rather than raise, one proving the owner check keys on thread ident so a sibling thread is not mistaken for nesting. Mutation-verified: drop the lock and the concurrency test fails; drop the owner pre-check and nesting deadlocks rather than raising.

@lowsfer

lowsfer commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

/bot run --disable-fail-fast

@tensorrt-cicd

Copy link
Copy Markdown
Collaborator

PR_Github #64312 [ run ] triggered by Bot. Commit: 45a6345 Link to invocation

@lowsfer
lowsfer force-pushed the control-action-reentrancy-guard branch from 45a6345 to f9fa918 Compare August 6, 2026 14:21
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

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

🤖 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/unittest/executor/test_control_action_reentrancy.py`:
- Around line 38-55: Add precise return annotations to all newly added test and
worker functions in this file, including the deferred-import _make_executor()
function, which should use a quoted PyExecutor return type. Annotate procedural
helpers and tests with None, and ensure every function covered by the comment
has an explicit return type.
🪄 Autofix

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: Enterprise

Run ID: 59ddf5cc-a3b0-49c0-97db-3029b278e86e

📥 Commits

Reviewing files that changed from the base of the PR and between 8e588da and f9fa918.

📒 Files selected for processing (3)
  • tensorrt_llm/_torch/pyexecutor/py_executor.py
  • tests/integration/test_lists/test-db/l0_cpu.yml
  • tests/unittest/executor/test_control_action_reentrancy.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/integration/test_lists/test-db/l0_cpu.yml

Comment thread tests/unittest/executor/test_control_action_reentrancy.py Outdated
@tensorrt-cicd

Copy link
Copy Markdown
Collaborator

PR_Github #64312 [ run ] completed with state SUCCESS. Commit: 45a6345
/LLM/main/L0_MergeRequest_PR pipeline #52209 completed with status: 'FAILURE'

CI Report

⚠️ Action Required:

  • Please check the failed tests and fix your PR
  • If you cannot view the failures, ask the CI triggerer to share details
  • Once fixed, request an NVIDIA team member to trigger CI again

CI Agent Failure Analysis

Link to invocation

@lowsfer

lowsfer commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

/bot run --disable-fail-fast

@tensorrt-cicd

Copy link
Copy Markdown
Collaborator

PR_Github #64367 [ run ] triggered by Bot. Commit: 7e4d9b2 Link to invocation

@brnguyen2 brnguyen2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approving — the comments below are optional touch-ups, not blockers.

The guard is correct and well placed. Raising before enqueue_control_request() means a refused attempt leaves no orphaned sentinel, and clearing the owner inside the lock's finally is right. Two things to address.

The description doesn't match the diff. It describes a re-entrancy flag ("the flag is cleared in the existing finally"), but the code also adds _control_action_lock and holds it for the whole action. That is a mutual-exclusion change that affects every existing caller, including base_worker._multi_rank_sleep_wakeup, which already carries _sleep_wakeup_lock for the same reason (base_worker.py:714). It's the bigger half of the PR and should get its own paragraph: what it now guarantees, and whether _sleep_wakeup_lock is still needed or should be documented as covering only the wider send/recv critical section.

[None] in the title. Fine here, since this is review-driven hardening for a hazard with no reported failure. But if #17163's discussion produced a tracking ticket, cite it.

Inline comments cover the untimed wait under the lock and a gap in the tests.

finally:
self.control_action_done.set()
self.control_request_barrier.clear()
self.control_request_barrier.wait()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The lock is now held across an untimed control_request_barrier.wait(). That changes the failure mode of an existing narrow race: _handle_control_request does barrier.set(); barrier.clear(); return on the aborted-control-request path (py_executor.py:4327-4329) without waiting for control_action_done. A caller that hasn't reached wait() yet when that pulse happens misses the edge and blocks forever. Before, that stalled one caller; now it wedges every later control action behind the lock, silently and with no diagnostic.

Since you're adding the lock anyway, consider bounding the wait: if not self.control_request_barrier.wait(timeout=...): raise RuntimeError(...), so a lost barrier fails loudly on the caller responsible instead of deadlocking sleep/wakeup/weight-update forever. Same argument for shutdown, where the executor loop may already be gone.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Confirmed and fixed in aac7f28afa — you are right that the lock turns a single stalled caller into a silently wedged executor.

I verified the path you describe: _handle_control_request really does set(); clear(); return on the aborted path (py_executor.py:4324-4329) with no control_action_done wait, so a caller not yet parked in wait() misses the edge permanently.

I did not take wait(timeout=...) verbatim, because a single deadline would have been wrong here: with drain=True the sentinel is parked until active_requests and waiting_queue drain, so a legitimate wait is bounded only by in-flight request duration. A tight timeout would trade your deadlock for spurious failures on long batches. Instead _wait_for_control_barrier() splits the two failure modes:

  • shutdown / dead executor loop — polls shutdown_event and worker_thread.is_alive(), so it fails in one poll interval (~0.5s) rather than waiting out the deadline. This covers your shutdown point directly.
  • lost barrier edge — backstopped by _CONTROL_BARRIER_TIMEOUT_S = 1800.0.

The 30 min is deliberately generous and documented as a wedge-breaker rather than a latency budget; because shutdown is caught by the liveness poll, it is only ever paid in a genuine lost-edge case. Happy to make it env-configurable if you would prefer.

Both modes are tested. test_lost_barrier_edge_fails_loudly_instead_of_wedging_the_lock reproduces your abort pulse and asserts the lock is released; mutation-checked by reverting to the bare wait(), which makes it hang rather than fail — so it is testing the real thing. test_shutdown_fails_fast_without_waiting_out_the_timeout sets a 300s timeout and asserts failure in under 5s, so it would hang if the liveness check regressed.

action; same-batch requests fetched after the sentinel
are parked until the ``with`` block exits.

Mutually exclusive and not re-entrant: the two events are a broadcast

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

"Callers therefore serialise on _control_action_lock" claims more than holds for dist.rank != 0. Only rank 0 enqueues; other ranks just wait on the barrier that the broadcast sentinel sets. The lock gives local exclusion, but the order in which local callers on rank N acquire it isn't tied to rank 0's enqueue order, so two concurrent control actions can still pair different bodies with the same barrier cycle on different ranks. Worth one sentence saying the lock provides local mutual exclusion only, and that cross-rank ordering still depends on rank 0 being the single enqueue point (i.e. don't issue concurrent collective control actions).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Agreed — the docstring claimed more than the lock delivers. Fixed in aac7f28afa:

The lock provides local mutual exclusion only. It does not order control actions across ranks: only rank 0 enqueues, and the order in which callers on rank N acquire the lock is not tied to rank 0's enqueue order, so concurrent control actions could still pair different bodies with the same barrier cycle on different ranks. Cross-rank correctness relies on rank 0 being the single enqueue point - do not issue concurrent collective control actions.


def _make_executor() -> "PyExecutor":
"""Build a PyExecutor shell exercising only control_action()'s state.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

dist.rank = 1 plus executor_request_queue = None means the rank-0 path is never exercised, so the property the PR description leads with ("the check raises before enqueue_control_request(), so a refused nesting attempt leaves no orphaned control request") has no test. Moving the guard below the enqueue would still pass all five tests.

Cheap to close: add a variant with rank=0 and a recording stub queue (SimpleNamespace(enqueue_control_request=lambda **kw: calls.append(kw))), then assert one call for the outer action and none for the rejected nested one.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

You were right, and I confirmed it by mutation before fixing rather than taking it on faith: I hoisted enqueue_control_request() above the guard — introducing exactly the orphaned-sentinel bug the description claims to prevent — and all 6 tests still passed. The property was genuinely unverified.

Fixed in aac7f28afa with essentially your suggestion: _make_executor() now takes rank/queue, plus a _RecordingQueue, and test_rejected_nesting_enqueues_no_control_request runs at rank=0 and asserts the queue still holds exactly one call after the nested rejection.

Re-running that same mutation now fails exactly one test — the new one — and nothing else, so it closes the gap precisely without over-constraining the rest.

(Worth noting for anyone reading later: an earlier mutation I tried, moving the guard inside the lock, was not a faithful test of your point — nested calls then deadlock on the non-reentrant lock and fail for an unrelated reason.)

@tensorrt-cicd

Copy link
Copy Markdown
Collaborator

PR_Github #64367 [ run ] completed with state SUCCESS. Commit: 7e4d9b2
/LLM/main/L0_MergeRequest_PR pipeline #52259 completed with status: 'FAILURE'

CI Report

⚠️ Action Required:

  • Please check the failed tests and fix your PR
  • If you cannot view the failures, ask the CI triggerer to share details
  • Once fixed, request an NVIDIA team member to trigger CI again

CI Agent Failure Analysis

Link to invocation

@lowsfer

lowsfer commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

/bot run --disable-fail-fast

1 similar comment
@lowsfer

lowsfer commented Aug 6, 2026

Copy link
Copy Markdown
Member Author

/bot run --disable-fail-fast

@tensorrt-cicd

Copy link
Copy Markdown
Collaborator

PR_Github #64402 [ run ] triggered by Bot. Commit: 7e4d9b2 Link to invocation

@tensorrt-cicd

Copy link
Copy Markdown
Collaborator

PR_Github #64402 [ run ] completed with state FAILURE. Commit: 7e4d9b2
/LLM/main/L0_MergeRequest_PR pipeline #52289 completed with status: 'UNSTABLE'

CI Report

⚠️ Multi-GPU Label Required:
Multi-GPU tests require the ci: full pre-merge approved label on this PR. Ask a member of NVIDIA/trt-llm-ci-approvers to add the label, then re-trigger CI with the same bot command (no rebase needed).

⚠️ Action Required:

  • Please check the failed tests and fix your PR
  • If you cannot view the failures, ask the CI triggerer to share details
  • Once fixed, request an NVIDIA team member to trigger CI again

Link to invocation

lowsfer added a commit to lowsfer/TensorRT-LLM that referenced this pull request Aug 7, 2026
Review follow-ups from brnguyen2 on NVIDIA#17346.

The lock is held across control_request_barrier.wait(), which was
unbounded.  _handle_control_request pulses set(); clear() on the
aborted-control-request path without waiting for control_action_done, so
a caller that has not reached the wait yet misses the edge.  Previously
that stalled one caller; with the lock it wedged every later control
action silently.  Wait in bounded slices instead: shutdown or a dead
executor loop fails within one poll interval, and a lost edge fails
after _CONTROL_BARRIER_TIMEOUT_S.  The timeout is deliberately generous
because drain=True is unbounded by design - it is a wedge-breaker, not a
latency budget.

Also note in the docstring that the lock is local mutual exclusion only
and does not order control actions across ranks, and add a rank=0 test
with a recording queue asserting a rejected nested call enqueues nothing
- the previous tests all ran at rank=1, where the enqueue is skipped, so
moving the guard below it went undetected.

Signed-off-by: Yao Yao <lowsfer@users.noreply.github.com>

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

🤖 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/unittest/executor/test_control_action_reentrancy.py`:
- Around line 275-294: Extend
test_shutdown_fails_fast_without_waiting_out_the_timeout to stub the executor’s
worker_thread with an object whose is_alive() returns False while shutdown_event
remains clear. Keep the long barrier timeout and assert control_action() raises
before the timeout, then verify both _control_action_lock and its owner state
are released.
🪄 Autofix

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: Enterprise

Run ID: 0d20d368-0523-4225-a8d8-784d8e1c2ec8

📥 Commits

Reviewing files that changed from the base of the PR and between c1e01e6 and aac7f28.

📒 Files selected for processing (2)
  • tensorrt_llm/_torch/pyexecutor/py_executor.py
  • tests/unittest/executor/test_control_action_reentrancy.py

Comment thread tests/unittest/executor/test_control_action_reentrancy.py Outdated
lowsfer added a commit to lowsfer/TensorRT-LLM that referenced this pull request Aug 7, 2026
Review follow-ups from brnguyen2 on NVIDIA#17346.

The lock is held across control_request_barrier.wait(), which was
unbounded.  _handle_control_request pulses set(); clear() on the
aborted-control-request path without waiting for control_action_done, so
a caller that has not reached the wait yet misses the edge.  Previously
that stalled one caller; with the lock it wedged every later control
action silently.  Wait in bounded slices instead: shutdown or a dead
executor loop fails within one poll interval, and a lost edge fails
after _CONTROL_BARRIER_TIMEOUT_S.  The timeout is deliberately generous
because drain=True is unbounded by design - it is a wedge-breaker, not a
latency budget.

Also note in the docstring that the lock is local mutual exclusion only
and does not order control actions across ranks, and add a rank=0 test
with a recording queue asserting a rejected nested call enqueues nothing
- the previous tests all ran at rank=1, where the enqueue is skipped, so
moving the guard below it went undetected.

Signed-off-by: Yao Yao <lowsfer@users.noreply.github.com>
@lowsfer
lowsfer force-pushed the control-action-reentrancy-guard branch from aac7f28 to a2e45bb Compare August 7, 2026 02:59
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@lowsfer

lowsfer commented Aug 7, 2026

Copy link
Copy Markdown
Member Author

/bot run --disable-fail-fast

@tensorrt-cicd

Copy link
Copy Markdown
Collaborator

PR_Github #64472 [ run ] triggered by Bot. Commit: a2e45bb Link to invocation

@lowsfer

lowsfer commented Aug 7, 2026

Copy link
Copy Markdown
Member Author

/bot run --disable-fail-fast

@tensorrt-cicd

Copy link
Copy Markdown
Collaborator

PR_Github #64476 [ run ] triggered by Bot. Commit: a5f4f8a Link to invocation

@tensorrt-cicd

Copy link
Copy Markdown
Collaborator

PR_Github #64472 [ run ] completed with state ABORTED. Commit: a2e45bb

Link to invocation

@tensorrt-cicd

Copy link
Copy Markdown
Collaborator

PR_Github #64476 [ run ] completed with state FAILURE. Commit: a5f4f8a
/LLM/main/L0_MergeRequest_PR pipeline #52348 completed with status: 'FAILURE'

CI Report

⚠️ Action Required:

  • Please check the failed tests and fix your PR
  • If you cannot view the failures, ask the CI triggerer to share details
  • Once fixed, request an NVIDIA team member to trigger CI again

CI Agent Failure Analysis

Link to invocation

@Shixiaowei02

Shixiaowei02 commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

The 1800 second deadline raises after the sentinel is enqueued, so the barrier is never cleared and every rank parks in the untimed wait with no caller left, and the hang detector hard kills the job a few minutes later.

BTW, not from this PR: the drain gate ignores in-flight transfers, so it can report done while a context send is still reading the pool.

lowsfer added 6 commits August 7, 2026 11:01
…handshake

control_request_barrier and control_action_done are single-slot events shared
by every control action. A control action whose body opened another one would
have the inner exit clear the barrier and set done, releasing the executor loop
while the outer body was still running -- the drain it asked for silently no
longer holding.

Nothing nests today: update_weights() reaches the reuse state through the
undecorated self.engine.reset_prefix_cache(), the Ray worker's sleep()/wakeup()
call no decorated sibling, and the base-worker control blocks only touch CUDA
and memory. The guard is there so that stays true, and so a future caller that
wraps one @control_action_decorator method in another fails at the point of the
mistake rather than at whatever breaks later.

Raise before enqueue_control_request() so a refused nesting attempt leaves no
orphaned control request, and clear the flag in the existing finally so a body
that raises does not wedge the executor.

Register the new test file in l0_cpu.yml -- test-db lists files individually,
so an unregistered unit test never runs in CI.

Signed-off-by: Yao Yao <lowsfer@users.noreply.github.com>
Review of the first cut pointed out the flag was not atomic, and BowenFu found
the evidence that settles it: base_worker.py:715 already documents this exact
race and works around it by hand with _sleep_wakeup_lock -- "control_action()
uses an Event-based barrier, not a mutex, so two concurrent callers can both
pass the barrier". That workaround covers three call sites; the
@control_action_decorator path takes no lock at all, so the five decorated
methods can race each other or a sleep()/wakeup().

An Event is a broadcast, not a token: set() releases every waiter. So two
threads could both clear control_request_barrier and both set
control_action_done, releasing the executor loop while one body still ran --
the corruption the guard is supposed to prevent, reachable by the other of its
two routes. Replace the bool with a real mutex, which is the primitive the
handshake was missing.

Concurrent callers serialise rather than fail: that is what the existing
_sleep_wakeup_lock sites already rely on, and refusing them would report
contention as re-entrancy. Only a nested call from the thread already holding
the lock raises, since blocking there would deadlock; it is distinguished by
thread ident rather than by taking the lock again.

_sleep_wakeup_lock is now redundant but left in place -- removing it touches
multi-rank sleep/wakeup, which deserves its own change.

Signed-off-by: Yao Yao <lowsfer@users.noreply.github.com>
CODING_GUIDELINES.md requires a return annotation on every function.
Add -> None to the tests and their worker closures, and a
TYPE_CHECKING-quoted PyExecutor return type to _make_executor() so the
deliberately deferred runtime import stays inside the function body.

Signed-off-by: Yao Yao <lowsfer@users.noreply.github.com>
Review follow-ups from brnguyen2 on NVIDIA#17346.

The lock is held across control_request_barrier.wait(), which was
unbounded.  _handle_control_request pulses set(); clear() on the
aborted-control-request path without waiting for control_action_done, so
a caller that has not reached the wait yet misses the edge.  Previously
that stalled one caller; with the lock it wedged every later control
action silently.  Wait in bounded slices instead: shutdown or a dead
executor loop fails within one poll interval, and a lost edge fails
after _CONTROL_BARRIER_TIMEOUT_S.  The timeout is deliberately generous
because drain=True is unbounded by design - it is a wedge-breaker, not a
latency budget.

Also note in the docstring that the lock is local mutual exclusion only
and does not order control actions across ranks, and add a rank=0 test
with a recording queue asserting a rejected nested call enqueues nothing
- the previous tests all ran at rank=1, where the enqueue is skipped, so
moving the guard below it went undetected.

Signed-off-by: Yao Yao <lowsfer@users.noreply.github.com>
_wait_for_control_barrier fast-fails on two conditions: shutdown_event
being set, or worker_thread having died. Only the first was covered --
_make_executor never assigns worker_thread, so getattr(...) returned None
in every test and the liveness half of the disjunct always evaluated
False. Removing it entirely kept the suite green.

Add a test that stubs worker_thread with is_alive() -> False while
shutdown_event stays clear, so the branch is reachable, and assert the
call fails well inside the (monkeypatched, 300s) barrier timeout rather
than waiting it out. Also assert _control_action_owner is released in the
existing shutdown test, matching the other cleanup tests.

Signed-off-by: Yao Yao <lowsfer@users.noreply.github.com>
The deadline stranded the sentinel.  Rank 0 enqueues before waiting, so
raising while the executor loop is still alive left the loop to pop that
sentinel, set the barrier and block in the untimed control_action_done
wait with no caller to answer - hanging the executor until the hang
detector killed the job.  That is worse than the stall the deadline was
meant to avoid.

Keep polling, but only report a loop that cannot serve the request at
all.  Both surviving conditions are safe because each implies no
consumer is left to strand: shutdown_event is set in
_executor_loop_cleanup(), i.e. only once the loop has exited, and a dead
worker_thread cannot pop anything either.  An orphaned sentinel is inert
in both cases.

A lost barrier edge therefore still blocks, as it did before this PR.
Fixing that needs the abort routed back to the caller, not a timeout;
the docstring says so to stop a deadline being reinstated later.

Drop the test asserting the removed raise - without the deadline it
would hang rather than fail.  The two fast-fail tests now run off-thread
and join with a timeout so a regression fails instead of hanging CI, and
a new test pins the property that matters: with the loop alive the
caller keeps waiting instead of stranding the sentinel.

Signed-off-by: Yao Yao <lowsfer@users.noreply.github.com>
@lowsfer
lowsfer force-pushed the control-action-reentrancy-guard branch from a5f4f8a to a6b2aaf Compare August 7, 2026 11:04
@lowsfer

lowsfer commented Aug 7, 2026

Copy link
Copy Markdown
Member Author

/bot run --disable-fail-fast

1 similar comment
@lowsfer

lowsfer commented Aug 7, 2026

Copy link
Copy Markdown
Member Author

/bot run --disable-fail-fast

@tensorrt-cicd

Copy link
Copy Markdown
Collaborator

PR_Github #64596 [ run ] triggered by Bot. Commit: a6b2aaf Link to invocation

@lowsfer

lowsfer commented Aug 7, 2026

Copy link
Copy Markdown
Member Author

/bot run --disable-fail-fast

@tensorrt-cicd

Copy link
Copy Markdown
Collaborator

PR_Github #64596 [ run ] completed with state SUCCESS. Commit: a6b2aaf
/LLM/main/L0_MergeRequest_PR pipeline #52458 completed with status: 'FAILURE'

CI Report

⚠️ Action Required:

  • Please check the failed tests and fix your PR
  • If you cannot view the failures, ask the CI triggerer to share details
  • Once fixed, request an NVIDIA team member to trigger CI again

CI Agent Failure Analysis

Link to invocation

@tensorrt-cicd

Copy link
Copy Markdown
Collaborator

PR_Github #64659 [ run ] triggered by Bot. Commit: a6b2aaf Link to invocation

@tensorrt-cicd

Copy link
Copy Markdown
Collaborator

PR_Github #64659 [ run ] completed with state SUCCESS. Commit: a6b2aaf
/LLM/main/L0_MergeRequest_PR pipeline #52517 completed with status: 'UNSTABLE'

CI Report

⚠️ Multi-GPU Label Required:
Multi-GPU tests require the ci: full pre-merge approved label on this PR. Ask a member of NVIDIA/trt-llm-ci-approvers to add the label, then re-trigger CI with the same bot command (no rebase needed).

⚠️ Action Required:

  • Please check the failed tests and fix your PR
  • If you cannot view the failures, ask the CI triggerer to share details
  • Once fixed, request an NVIDIA team member to trigger CI again

Link to invocation

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.

6 participants