Skip to content

CAMEL-25008, CAMEL-25007: camel-core - Supervising route controller: fix a deadlock and a lost restart task - #26867

Open
allthingssecurity wants to merge 2 commits into
apache:mainfrom
allthingssecurity:camel-supervising-restart-task-deadlock
Open

allthingssecurity wants to merge 2 commits into
apache:mainfrom
allthingssecurity:camel-supervising-restart-task-deadlock

Conversation

@allthingssecurity

Copy link
Copy Markdown
Contributor

Description

This PR fixes two bugs in the restart tasks of the supervising route controller, one commit each. The second fix would make the first bug easier to hit, so they go together.

  • CAMEL-25008: the controller deadlocks when a route is stopped or started while its restart task completes.
  • CAMEL-25007: the completion of an old restart task removes the route's new restart task, which then restarts the route unsupervised, even after stopRoute.

CAMEL-25008: deadlock between the task lock and the controller lock

The supervising route controller could deadlock when a route was stopped or started while its restart task was completing. Two locks were taken in opposite orders:

  • restart thread: BackOffTimerTask.run() -> complete() takes the task lock and, while holding it, calls the consumer registered by RouteManager.start, which takes the controller lock;
  • user thread: stopRoute/startRoute/suspendRoute/resumeRoute take the controller lock and call routeManager.release(route) -> task.cancel() -> complete(), which takes the task lock.

complete() runs when an attempt succeeds, when the attempts are exhausted, and on cancel. The restart thread waits for the controller lock whenever any route operation holds it, since the lock is shared by all routes. After the deadlock, every later route operation blocks, and the supervisor thread (a single thread by default) never restarts another route.

Thread dump from a reproduction with the real controller (routes broken, whose consumer cannot start, and other):

"operator":
    at org.apache.camel.util.backoff.BackOffTimerTask.complete(BackOffTimerTask.java:220)
    at org.apache.camel.util.backoff.BackOffTimerTask.cancel(BackOffTimerTask.java:144)
    at org.apache.camel.impl.engine.DefaultSupervisingRouteController$RouteManager.release(DefaultSupervisingRouteController.java:756)
    at org.apache.camel.impl.engine.DefaultSupervisingRouteController.doStopRoute(DefaultSupervisingRouteController.java:451)
"Camel (camel-1) thread #1 - SupervisingRouteController":
    at org.apache.camel.impl.engine.DefaultSupervisingRouteController$RouteManager.lambda$start$2(DefaultSupervisingRouteController.java:711)
    at org.apache.camel.util.backoff.BackOffTimerTask.complete(BackOffTimerTask.java:222)
    at org.apache.camel.util.backoff.BackOffTimerTask.run(BackOffTimerTask.java:183)

This change: BackOffTimerTask.complete() copies the consumers under the lock and calls them after releasing it. The lock only protects the list of consumers. The controller's consumer does its bookkeeping under the controller lock, and routes is a ConcurrentHashMap. Its final routes.remove(r) relied on the deadlock, though: once the restart thread no longer blocks a user operation, a completion of an old task can run after a failed startRoute registered a new task, and remove that task (CAMEL-25007). This is why the two commits are in one PR, CAMEL-25007 first.

Tests: new SimpleBackOffTimerTest.testCancelWhileCompletionCallbackWaitsForLock. It reproduces the same lock order without Camel: the completion callback takes an "owner" lock, and the owner cancels the task while it holds that lock, after the callback is queued on it (ReentrantLock.hasQueuedThread, no sleeps). Without the fix:

AssertionFailedError: Cancelling the task should not deadlock ==> Unexpected exception thrown: java.util.concurrent.TimeoutException

With the fix it passes. *BackOff*,*SupervisingRouteController*,*RouteController* in camel-util and camel-core, plus ManagedSupervisingRouteControllerTest in camel-management: all pass.

I also wrote a test against the real controller that stops a route from inside the controller lock while the route's restart task completes. It passes with the fix. Without the fix it deadlocks, and the forked JVM then hangs in CamelContext.stop(). So I left it out of this PR and kept the unit test, which fails cleanly.

Found with a TLA+ model of the supervising route controller and BackOffTimerTask (both locks, user operations, restart tasks), then reproduced against the real classes (3 of 3 runs deadlocked, with no thread held by the harness). With only this change, the model has no lock-order cycle, and every user operation terminates (up to 3 user operations and 3 restart tasks, 53k states).

CAMEL-25007: a completing old task removes the new restart task

BackOffTimerTask.complete() calls its consumers each time it is called. A task that is cancelled while its attempt runs is completed twice: once by cancel(), and again by run() when the attempt returns. The consumer that DefaultSupervisingRouteController.RouteManager.start registers ended with an unconditional routes.remove(r).

If the route got a new restart task in between, the second completion of the old task removed the new task. This happens, for example, when a manual startRoute fails, since that registers a new task. The new task kept running, but:

  • getRestartingRoutes() and getRestartingRouteState(id) no longer showed it;
  • hasUnhealthyRoutes() reported healthy while the route was down and being restarted;
  • stopRoute(id) could not cancel it (release finds nothing), so it started the route after the route was stopped.

This change: the consumer removes the route's entry only if it is still its own task, routes.remove(r, task). The second completion of a cancelled task then has no effect: its status is Inactive or Completed, so the exhausted bookkeeping is skipped too.

Tests: new DefaultSupervisingRouteControllerStartWhileRestartingTest. A route fails to start, and its first restart attempt is held in the RouteRestartingEvent notifier. The route is then started manually, which fails and registers a new restart task. The old attempt is then released and fails as well. The test waits for the old task's second completion (a whenComplete registered after the cancel, no sleeps) and checks that the new task is still the route's restart task and is reported as unhealthy. Without the fix:

AssertionFailedError: expected: <BackOffTimerTask[name=SupervisingRouteController, status=Active, currentAttempts=1, ...]> but was: <null>

With the fix it passes. *BackOff*,*Supervising*,*RouteController* in camel-util, camel-core and camel-management: all pass.

Found with a TLA+ model of the supervising route controller (restart tasks, user operations, both locks), then reproduced against the real classes. With only this change, "every restart task that can still run is the route's current task" holds with up to 3 user operations and 3 tasks (53k states). In the reproduction, the route now stays reported as restarting, and stopRoute cancels the new task, so the route stays stopped.

Neither commit touches RouteManager.release(), so this does not conflict with #26859 (CAMEL-25001).

Target

  • I checked that the commit is targeting the correct branch (Camel 4 uses the main branch)

Tracking

  • If this is a large change, bug fix, or code improvement, I checked there is a JIRA issue filed for the change (usually before you start working on it).

Apache Camel coding standards and style

  • I checked that each commit in the pull request has a meaningful subject line and body.
  • I have run mvn clean install -DskipTests locally from root folder and I have committed all auto-generated changes.
    (I built and tested the affected modules, including the formatter and import-sort plugins. I did not run the full root build.)

AI-assisted contributions

  • If this PR includes AI-generated code, commits have proper co-authorship attribution (e.g., Co-authored-by trailers) and the PR description identifies the AI tool used.
    This PR was prepared with Claude Code (Claude Opus 5.5). The commit carries a Co-Authored-By trailer.

Claude Code on behalf of allthingssecurity

🤖 Generated with Claude Code

allthingssecurity and others added 2 commits September 24, 2026 23:11
… the new restart task of a route when the old one completes

BackOffTimerTask.complete() calls its consumers each time it is called,
and a task that is cancelled while its attempt runs is completed twice:
by cancel(), and again by run() when the attempt returns. The consumer
registered by RouteManager.start ended with routes.remove(r). If the
route got a new restart task in between, for example because a manual
startRoute failed, the second completion of the old task removed the
new task from the routes being restarted. The new task kept running,
but getRestartingRoutes(), getRestartingRouteState(id) and
hasUnhealthyRoutes() no longer reported it, and stopRoute could not
cancel it, so it started the route after the route was stopped.

The consumer now removes the route's entry only if it is still its own
task (routes.remove(r, task)).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ackOffTimerTask while holding its lock

BackOffTimerTask.complete() called the registered consumers while it
held the task lock. The consumer registered by the supervising route
controller takes the controller lock, while stopRoute, startRoute,
suspendRoute and resumeRoute hold the controller lock and cancel the
route's restart task, and cancel() calls complete(), which takes the
task lock. When a route operation took the controller lock while the
restart task of the same route was completing (the attempt succeeded
or the attempts were exhausted), both threads waited for each other
forever. The route controller was then unusable, and no route was
restarted any more.

complete() now copies the consumers under the lock and calls them
after releasing it. The lock only protects the list of consumers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@oscerd

oscerd commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

Coordination heads-up: there are currently three open PRs reworking DefaultSupervisingRouteController, and they overlap:

#26859 and this PR edit adjacent lines in the same start(RouteHolder) / RouteManager region (its hunk is around line 750, this one around line 741), so they will conflict textually, and #26868 changes the restart-attempt path in the same class. #26859's description currently says "No outside PRs overlap" — that predates #26867/#26868, which do.

Since this PR restructures the task/controller locking that the other two depend on, it is the natural base: landing it first and rebasing #26868 and #26859 on top would let all three concurrency/health fixes coexist. Flagging so the three can be sequenced; I'll do the detailed review here once CI has run.

This note was generated with AI assistance and issued by the human operator. Claude Code on behalf of oscerd

@allthingssecurity

Copy link
Copy Markdown
Contributor Author

@oscerd thanks for mapping this out. Landing this one first sounds right to me. I checked the three together:

Whichever order they merge in, I'll rebase #26868 if needed.

Claude Code on behalf of allthingssecurity

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