This is an automated email from the ASF dual-hosted git repository.

tomaswolf pushed a commit to branch dev_3.0
in repository https://gitbox.apache.org/repos/asf/mina-sshd.git


The following commit(s) were added to refs/heads/dev_3.0 by this push:
     new d5f1b7343 GH-909: Fix KexOutputHandler self-deadlock on inline write 
failure during shutdown
d5f1b7343 is described below

commit d5f1b7343401f55c53d578aef944bda329053ed7
Author: zimmermq <[email protected]>
AuthorDate: Thu Jul 23 11:17:40 2026 +0200

    GH-909: Fix KexOutputHandler self-deadlock on inline write failure during 
shutdown
    
    When a write started under the read lock in writeOrEnqueue() fails inline, 
the session close re-enters KexOutputHandler.shutdown()→updateState(), which 
tries to take the write lock on the same thread — an illegal read-to-write 
upgrade that self-deadlocks.
    
    Fix: in shutdown(), when the current thread already holds the read lock 
(getReadHoldCount() > 0), update the state without acquiring the write lock. 
The read lock already excludes writers, so it stays consistent; KEX 
write-ordering is unchanged.
    
    Includes a deterministic regression test (hangs without the fix, passes 
with it).
---
 .../session/filters/kex/KexOutputHandler.java      |  10 +-
 .../filters/kex/KexOutputHandlerDeadlockTest.java  | 128 +++++++++++++++++++++
 2 files changed, 136 insertions(+), 2 deletions(-)

diff --git 
a/sshd-core/src/main/java/org/apache/sshd/common/session/filters/kex/KexOutputHandler.java
 
b/sshd-core/src/main/java/org/apache/sshd/common/session/filters/kex/KexOutputHandler.java
index f8006e271..23e0e7482 100644
--- 
a/sshd-core/src/main/java/org/apache/sshd/common/session/filters/kex/KexOutputHandler.java
+++ 
b/sshd-core/src/main/java/org/apache/sshd/common/session/filters/kex/KexOutputHandler.java
@@ -190,12 +190,18 @@ class KexOutputHandler implements OutputHandler {
      */
     public void shutdown() {
         shutDown.set(true);
-        SimpleImmutableEntry<Integer, DefaultKeyExchangeFuture> items = 
updateState(() -> {
+        Supplier<SimpleImmutableEntry<Integer, DefaultKeyExchangeFuture>> 
update = () -> {
             kexFlushed.set(true);
             return new SimpleImmutableEntry<>(
                     Integer.valueOf(pendingPackets.size()),
                     kexFlushedFuture.get());
-        });
+        };
+        // A synchronous write failure in writeOrEnqueue() can close the 
session - and thus call this
+        // shutdown() - inline on a thread that still holds the read lock. 
Acquiring the write lock then would
+        // be an illegal read-to-write upgrade and would self-deadlock. 
Holding the read lock already excludes
+        // writers, so in that reentrant case we update the state without 
acquiring the exclusive lock.
+        SimpleImmutableEntry<Integer, DefaultKeyExchangeFuture> items
+                = (lock.getReadHoldCount() > 0) ? update.get() : 
updateState(update);
         items.getValue().setValue(Boolean.valueOf(items.getKey().intValue() == 
0));
     }
 
diff --git 
a/sshd-core/src/test/java/org/apache/sshd/common/session/filters/kex/KexOutputHandlerDeadlockTest.java
 
b/sshd-core/src/test/java/org/apache/sshd/common/session/filters/kex/KexOutputHandlerDeadlockTest.java
new file mode 100644
index 000000000..6ddb6cd21
--- /dev/null
+++ 
b/sshd-core/src/test/java/org/apache/sshd/common/session/filters/kex/KexOutputHandlerDeadlockTest.java
@@ -0,0 +1,128 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.sshd.common.session.filters.kex;
+
+import java.io.IOException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.stream.Stream;
+
+import org.apache.sshd.common.SshConstants;
+import org.apache.sshd.common.kex.KexState;
+import org.apache.sshd.common.session.helpers.AbstractSession;
+import org.apache.sshd.common.util.buffer.Buffer;
+import org.apache.sshd.common.util.buffer.ByteArrayBuffer;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+import org.slf4j.LoggerFactory;
+
+import static org.junit.jupiter.api.Assertions.fail;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Reproduces a self-deadlock in {@link KexOutputHandler} caused by an illegal 
read-to-write lock upgrade on its
+ * {@link java.util.concurrent.locks.ReentrantReadWriteLock}.
+ * <p>
+ * The single-thread cycle (see the offending frames in production thread 
dumps):
+ * </p>
+ * <ol>
+ * <li>An outgoing high-level packet (e.g. {@code SSH_MSG_CHANNEL_CLOSE}) 
enters
+ * {@link KexOutputHandler#send(int, Buffer)} &rarr; {@code writeOrEnqueue()}, 
which acquires the <b>read lock</b>
+ * ({@code KexOutputHandler.java:259}) and, KEX being done, performs the 
actual write via {@code filter.write()}
+ * ({@code KexOutputHandler.java:268}).</li>
+ * <li>The nio2 transport can complete/fail that write <b>inline on the 
calling thread</b>
+ * ({@code sun.nio.ch.Invoker.invokeDirect}). On failure the session is 
closed, whose teardown runs
+ * {@code SshTransportFilter.shutdown} &rarr; {@link KexFilter#shutdown()} 
&rarr; {@link KexOutputHandler#shutdown()}
+ * ({@code KexFilter.java:342}).</li>
+ * <li>{@link KexOutputHandler#shutdown()} calls {@code updateState()} which 
acquires the <b>write lock</b>
+ * ({@code KexOutputHandler.java:146}) &mdash; while the same thread still 
holds the read lock from step&nbsp;1.
+ * {@code ReentrantReadWriteLock} does not allow upgrading, so the thread 
parks forever holding its own read lock.</li>
+ * </ol>
+ * <p>
+ * The test mocks {@link KexFilter} so that {@code filter.write(...)} performs 
the terminal action of that inline
+ * teardown chain &mdash; {@code output.shutdown()} (exactly what {@link 
KexFilter#shutdown()} does) &mdash; on the
+ * calling thread, faithfully recreating the reentrancy without a real socket.
+ * <p>
+ * This is a regression test: it currently <b>fails</b> (the worker deadlocks 
and never returns), and must <b>pass</b>
+ * once the upgrade is avoided.
+ */
+@Tag("NoIoTestCase")
+class KexOutputHandlerDeadlockTest {
+
+    KexOutputHandlerDeadlockTest() {
+        super();
+    }
+
+    @Test
+    @Timeout(value = 30, unit = TimeUnit.SECONDS)
+    void readToWriteLockUpgradeSelfDeadlockOnInlineWriteFailure() throws 
Exception {
+        KexFilter filter = mock(KexFilter.class);
+        AbstractSession session = mock(AbstractSession.class);
+        when(filter.getSession()).thenReturn(session);
+        // KEX finished: writeOrEnqueue() takes the direct-write branch 
through filter.write().
+        when(filter.getKexState()).thenReturn(new 
AtomicReference<>(KexState.DONE));
+
+        KexOutputHandler output = new KexOutputHandler(filter, 
LoggerFactory.getLogger(KexOutputHandlerDeadlockTest.class));
+
+        // Model the nio2 write-failure callback firing inline on the calling 
thread: the real chain is
+        // filter.write() -> ... -> Nio2Session.onFailed -> session close -> 
SshTransportFilter.shutdown ->
+        // KexFilter.shutdown() -> KexOutputHandler.shutdown(). We invoke the 
terminal call directly, on the
+        // same thread, while writeOrEnqueue() still holds the read lock.
+        when(filter.write(anyInt(), any(Buffer.class))).thenAnswer(invocation 
-> {
+            output.shutdown();
+            return null;
+        });
+
+        Thread worker = new Thread(() -> {
+            try {
+                Buffer buffer = new ByteArrayBuffer(new byte[] { (byte) 
SshConstants.SSH_MSG_CHANNEL_CLOSE });
+                output.send(SshConstants.SSH_MSG_CHANNEL_CLOSE, buffer);
+            } catch (IOException e) {
+                // Not expected on this path; the thread deadlocks before 
returning.
+            }
+        }, "kex-deadlock-worker");
+        worker.setDaemon(true);
+        worker.start();
+
+        worker.join(TimeUnit.SECONDS.toMillis(5));
+
+        if (worker.isAlive()) {
+            StackTraceElement[] stack = worker.getStackTrace();
+            String rendered = Stream.of(stack).map(e -> "\tat " + 
e).reduce("", (a, b) -> a + '\n' + b);
+            boolean parkedOnWriteLock = Stream.of(stack).anyMatch(
+                    e -> 
"java.util.concurrent.locks.ReentrantReadWriteLock$WriteLock".equals(e.getClassName())
+                            && "lock".equals(e.getMethodName()));
+            boolean viaShutdownUpdateState
+                    = Stream.of(stack).anyMatch(e -> 
e.toString().contains("KexOutputHandler.updateState"))
+                            && Stream.of(stack).anyMatch(e -> 
e.toString().contains("KexOutputHandler.shutdown"));
+            boolean readLockStillHeldViaWriteOrEnqueue = Stream.of(stack)
+                    .anyMatch(e -> 
e.toString().contains("KexOutputHandler.writeOrEnqueue"));
+            fail("KexOutputHandler.send() deadlocked: read-to-write lock 
upgrade in KexOutputHandler."
+                 + " parkedOnWriteLock=" + parkedOnWriteLock
+                 + ", shutdown()->updateState()=" + viaShutdownUpdateState
+                 + ", readLockHeldViaWriteOrEnqueue=" + 
readLockStillHeldViaWriteOrEnqueue
+                 + "\nWorker stack:" + rendered);
+        }
+        // Reaching here means send() returned: the upgrade no longer happens 
and the bug is fixed.
+    }
+}

Reply via email to