Skip to content

[AMQ-7452] Fix "Timer already cancelled" on new connections when shared inactivity timer dies#2206

Open
jbonofre wants to merge 1 commit into
apache:mainfrom
jbonofre:fix/inactivity-monitor-cancelled-timer
Open

[AMQ-7452] Fix "Timer already cancelled" on new connections when shared inactivity timer dies#2206
jbonofre wants to merge 1 commit into
apache:mainfrom
jbonofre:fix/inactivity-monitor-cancelled-timer

Conversation

@jbonofre

@jbonofre jbonofre commented Jul 9, 2026

Copy link
Copy Markdown
Member

The static READ_CHECK_TIMER and WRITE_CHECK_TIMER in AbstractInactivityMonitor are shared across all transport instances. Java's Timer permanently marks itself cancelled when its internal thread exits due to an unhandled Error thrown by a TimerTask. SchedulerTimerTask rethrows Error subclasses, so an OutOfMemoryError in any timer task kills the shared timer thread while the static field remains non-null. All subsequent schedule() calls on new connections then throw IllegalStateException ("Timer already cancelled"), which propagates to TransportConnector.onAcceptError() and is logged as WARN "Could not accept connection: Timer already cancelled."

Fix: wrap the schedule() calls in startConnectCheckTask() and startMonitorThreads() in try/catch(IllegalStateException). On catch, the dead timer is cancelled (to let its thread exit), replaced with a fresh instance, and the task is rescheduled. The guard condition in startConnectCheckTask() is also extended with || READ_CHECK_TIMER == null to cover the case where the field is unexpectedly null when CHECKER_COUNTER > 0.

Add three regression tests to InactivityMonitorTest covering:

  • READ_CHECK_TIMER cancelled with CHECKER_COUNTER == 0 (startConnectCheckTask path)
  • READ_CHECK_TIMER cancelled with CHECKER_COUNTER > 0 (production scenario)
  • WRITE_CHECK_TIMER cancelled (startMonitorThreads path)

Tests use Wait.waitFor() instead of Thread.sleep() for reliability on slow CI.

…ed inactivity timer dies

The static READ_CHECK_TIMER and WRITE_CHECK_TIMER in AbstractInactivityMonitor are
shared across all transport instances. Java's Timer permanently marks itself cancelled
when its internal thread exits due to an unhandled Error thrown by a TimerTask.
SchedulerTimerTask rethrows Error subclasses, so an OutOfMemoryError in any timer task
kills the shared timer thread while the static field remains non-null. All subsequent
schedule() calls on new connections then throw IllegalStateException ("Timer already
cancelled"), which propagates to TransportConnector.onAcceptError() and is logged as
WARN "Could not accept connection: Timer already cancelled."

Fix: wrap the schedule() calls in startConnectCheckTask() and startMonitorThreads()
in try/catch(IllegalStateException). On catch, the dead timer is cancelled (to let its
thread exit), replaced with a fresh instance, and the task is rescheduled. The guard
condition in startConnectCheckTask() is also extended with || READ_CHECK_TIMER == null
to cover the case where the field is unexpectedly null when CHECKER_COUNTER > 0.

Add three regression tests to InactivityMonitorTest covering:
- READ_CHECK_TIMER cancelled with CHECKER_COUNTER == 0 (startConnectCheckTask path)
- READ_CHECK_TIMER cancelled with CHECKER_COUNTER > 0 (production scenario)
- WRITE_CHECK_TIMER cancelled (startMonitorThreads path)

Tests use Wait.waitFor() instead of Thread.sleep() for reliability on slow CI.
@jbonofre jbonofre requested a review from cshannon July 9, 2026 14:32
@cshannon

cshannon commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

I think this is the wrong approach to the fix because we are trying to clean up something that shouldn't happen and should be fatal. Generally Errors like OOM should be considered fatal and we should be just terminating the JVM. If we don't handle it and re-throw it the broker is going to be in a really bad state so continuing to try and run after OOM doesn't make sense to me.

The SchedulerTimerTask already catches Errors, but then simply logs and rethrows them which is odd. My question is do we really want to keep trying to run if there was OOM? Probably not.

I don't see a good reason to continue running if we get an Error, so I feel like we could just default to exiting the JVM on errors like this: cshannon@bfcd1c0

We could narrow the scope to VirtualMachineError but generally any thrown Error is probably very bad and we should treat it as fatal.

Thoughts @mattrpav and @jbonofre ?

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