[io.net] Fix shutdown hang: keep the shared thread pool alive until both clients are stopped - #5772
Conversation
…oth clients are stopped The shared QueuedThreadPool was handed unstarted to both the common HttpClient and the common WebSocketClient, so Jetty's addBean heuristic made it a managed bean of both containers and the first client's stop() tore the pool down while the second client's ManagedSelector was still running on it. The selector thread then died with ClosedSelectorException without draining its update queue, and the subsequent WebSocketClient.stop() waited forever on the latches in Jetty 9.4's timeout-less ManagedSelector.doStop() - freezing the whole framework shutdown until systemd's SIGKILL. Start the pool before handing it to the clients, as prescribed by the shared-bean rule in Jetty's ContainerLifeCycle javadoc (an already started bean is added unmanaged), and stop it explicitly as the last step of deactivate(). The setStopTimeout(0) mitigation from eclipse-archived/smarthome#6632 keeps covering the pool's own stop. Signed-off-by: Martin Littkovsky <2018turtle@proton.me>
wborn
left a comment
There was a problem hiding this comment.
This PR is being reviewed with AI first.
The reported issue looks valid and the proposed lifecycle fix is sound. Jetty's ContainerLifeCycle explicitly documents that a lifecycle bean shared by multiple containers should be started before being added, or explicitly made unmanaged.
With the current code, the common HttpClient receives the stopped thread pool first and therefore ends up managing it. The WebSocket client's internal HttpClient receives the already-running pool and only borrows it. Stopping the common HTTP client first can consequently shut down the shared executor while the WebSocket selector is still using it, which explains the observed ClosedSelectorException and subsequent shutdown hang.
Explicitly owning the shared thread pool in WebClientFactoryImpl, starting it before handing it to either client, and stopping it after both clients is cleaner than merely reversing the shutdown order because it removes the accidental lifecycle ownership relationship altogether.
I found two follow-ups:
WebClientFactoryImplTest.tearDown()already contains a workaround for essentially this exactClosedSelectorException/shutdown hang, but it only waits two seconds and does not assert that deactivation actually completed. It would be good to turn this into a regression test and verify that deactivation completes successfully with both shared clients initialized.- Now that the pool is started before being given to the HTTP client, the existing comment saying
setStopTimeout(0)must happen afterHttpClient.start()is no longer correct. Moving that setting directly after the explicit pool start would better match the new ownership model and also ensures it is applied if common-client initialization subsequently fails.
Apart from those points, the approach looks correct.
|
One additional test-related point that could not be attached as an inline review comment because
Could we turn this into a regression test for the fix? At minimum, I think we should assert that deactivation completes within the timeout after both common clients have been initialized. Ideally we could also verify the lifecycle ownership directly, i.e. that neither client's |
…on test Move setStopTimeout(0) directly after the explicit pool start - the old comment tied it to the HTTP client's start, which no longer starts the pool - so it also applies when common-client initialization fails later. Replace the 2-second continue-anyway workaround in the test's tearDown() with an assertion that deactivate() completes, and add a test pinning the ownership model: both clients share the pool unmanaged, and deactivate() itself stops it. Signed-off-by: Martin Littkovsky <2018turtle@proton.me>
|
Thanks for the thorough review — both points are addressed in a256cff (kept as a separate commit so the delta stays reviewable):
Bundle tests: 40 run, 0 failures. |
wborn
left a comment
There was a problem hiding this comment.
The previous review findings have been addressed in a256cff. The shared thread pool now owns its stop timeout independently of the HTTP client lifecycle, and the new regression coverage verifies that both clients share the same unmanaged pool and that WebClientFactoryImpl stops it explicitly.
The production-code lifecycle fix looks correct. Starting the shared pool before handing it to either Jetty client ensures that neither client owns its lifecycle, and stopping it only after both clients have been stopped removes the shutdown ordering problem that caused the original hang.
One test robustness issue remains. The timeout-protected deactivation in tearDown() can still leave a blocked non-daemon thread behind when the regression occurs, and testSharedThreadPoolIsUnmanagedAndStoppedByDeactivate() calls deactivate() directly without any timeout at all. A future shutdown regression could therefore cause the test itself to hang before tearDown() is reached.
Apart from that test concern, no further issues were found with the Jetty lifecycle fix.
Run every test deactivation through a shared helper that uses a daemon thread with a bounded join, so a future shutdown regression fails the assertion instead of hanging the test or leaking a non-daemon thread into subsequent tests and the surefire fork. Signed-off-by: Martin Littkovsky <2018turtle@proton.me>
|
Good catch on the remaining hang path — addressed in d7f75d1: both the tearDown() check and the explicit call in the ownership test now go through a shared |
wborn
left a comment
There was a problem hiding this comment.
The previous review concern has been mostly addressed in d7f75d1. Both the tearDown() path and the explicit deactivation in testSharedThreadPoolIsUnmanagedAndStoppedByDeactivate() now use the same timeout-protected helper, and making the deactivation thread a daemon ensures that a shutdown regression cannot keep the test JVM alive.
The production lifecycle fix looks correct. The shared thread pool is started before either Jetty client receives it, both clients therefore treat it as unmanaged, and WebClientFactoryImpl explicitly stops the pool only after both clients have been stopped.
One test robustness issue remains; see the inline comment.
Apart from that, no further issues were found.
The bounded deactivation helper made the thread a daemon so a blocked deactivation could not keep the test JVM alive, but the thread could still stay blocked alongside the tests that follow. It is now interrupted when the join times out. The verdict is captured before the interrupt. Interrupting is cleanup, not a second chance: if it unblocks the deactivation, the test must still report the timeout it observed, otherwise a genuine shutdown regression would pass. The daemon flag remains the final safeguard for a deactivation that does not react to the interrupt at all. Signed-off-by: Martin Littkovsky <2018turtle@proton.me> AI-assisted-by: Claude Code
wborn
left a comment
There was a problem hiding this comment.
The previous review concern has been addressed in 0c2b82d. The timeout result is captured before interruption, so the interrupt remains cleanup only and cannot turn a shutdown regression into a passing test.
The production lifecycle fix and regression coverage look correct, and CI is green on the current head.
No further issues were found. A human maintainer review is still needed.
wborn
left a comment
There was a problem hiding this comment.
Additional AI review on the current head:
The Jetty lifecycle fix remains sound, the previous findings are resolved, and the current CI/SAT run is green. The static-analysis report does not contain findings for either changed class.
One non-blocking maintainability comment remains inline regarding the amount of explanatory narration in the regression-test helper.
No blocking code issues were found. A human maintainer review is still needed before merge.
| } | ||
|
|
||
| private void deactivateWithTimeout() throws InterruptedException { | ||
| // Regression-safe: run deactivate() on a DAEMON thread so a blocked |
There was a problem hiding this comment.
This helper now has more commentary than the logic needs, and much of it narrates what setDaemon, join, interrupt, and the assertion already make clear. The non-obvious invariant worth preserving is that timedOut must be captured before interrupt() so cleanup cannot turn a timeout into a passing test. Could the surrounding comments be removed or condensed around that point? That would keep the regression rationale while making the test much easier to scan.
There was a problem hiding this comment.
Fair point — condensed in f2f2810. The helper is down to the one invariant that is genuinely not visible from the code:
// Capture the verdict before interrupting: the interrupt is cleanup, so an interrupt that
// happens to unblock the deactivation must not turn an observed timeout into a pass.Everything else was narration of setDaemon, join, interrupt and the assertion, and is gone. Bundle tests still 40 run, 0 failures.
The helper narrated what setDaemon, join, interrupt and the assertion already say. Only one invariant is not visible from the code: the timeout verdict has to be captured before the interrupt, or cleanup could turn a shutdown regression into a passing test. That is the comment that remains. Signed-off-by: Martin Littkovsky <2018turtle@proton.me> AI-assisted-by: Claude Code
Description
WebClientFactoryImplshares oneQueuedThreadPoolbetween the common JettyHttpClientand the commonWebSocketClient, but hands it over unstarted. Jetty'sContainerLifeCycle.addBean(Object)heuristic therefore makes the pool a MANAGED bean of the first client started with it (the commonHttpClient, which implicitly starts the pool during its own start), while theWebSocketClient— created after the pool is already running — merely borrows it as an UNMANAGED bean.deactivate()stops the owning HttpClient first, which tears the shared pool down while the WebSocketClient'sManagedSelectoris still running on it. The selector thread dies withjava.nio.channels.ClosedSelectorExceptionwithout draining its update queue, and the subsequentcommonWebSocketClient.stop()then waits forever on the latches in Jetty 9.4's timeout-lessManagedSelector.doStop()— freezing the entire framework shutdown until the service supervisor SIGKILLs the JVM.Observed on openHAB 5.2.1 (openHABian/RPi4, Jetty 9.4.58): intermittent shutdown hangs (5 of 8 stops on one day, identical stack),
TimeoutStopSec(600 s) exhausted, SIGKILL mid-deactivation (which once truncatedlog4j2.xml). The hang occurs with zero active WebSocket connections — it is the pool teardown, not connection activity. Full analysis with thread dumps and the timing chain available on request.Fingerprint during a hang:
The fix
Jetty's own
ContainerLifeCyclejavadoc prescribes it:So: start the pool in
initialize()before handing it to the clients (an already-started bean is added UNMANAGED, so neither client stops it anymore), and stop it explicitly as the last step ofdeactivate(). IfthreadPool.start()fails, the field is rolled back tonullso a later lazyinitialize()retry recreates the pool instead of reusing a dead one. ThesetStopTimeout(0)mitigation from eclipse-archived/smarthome#6632 keeps covering the pool's own stop, so deactivation cannot block there either.No runtime behavior changes: the pool ran from the first client start before; it still does.
Testing
mvn clean install -pl bundles/org.openhab.core.io.net: BUILD SUCCESS on this branch and on the 5.2.1 tag; 39 tests, 0 failures (4 skipped, pre-existing).Jetty shared http client stopped→Jetty shared web socket client stopped), noClosedSelectorException; in every hang the second line never appeared. Longer-term observation is running with a scoped DEBUG logger onorg.openhab.core.io.net.http.internalrecording the deactivation order of every future shutdown.Related: eclipse-archived/smarthome#6632 (2018 predecessor of this hang, mitigated with
setStopTimeout(0)), #3315 (Jetty 9.4 EOL umbrella — this is a minimal self-contained correction until the Jetty upgrade lands).