zimmermq opened a new issue, #909:
URL: https://github.com/apache/mina-sshd/issues/909

   ### Version
   
   3.0.0-M4
   
   ### Bug description
   
   `KexOutputHandler` uses a `ReentrantReadWriteLock`: `writeOrEnqueue()` takes 
the **read** lock to decide-and-write a packet, while state changes and 
`shutdown()` take the **write** lock via `updateState()`.
   
   A single thread can deadlock itself:
   
   1. An outgoing high-level packet (e.g. `SSH_MSG_CHANNEL_CLOSE` produced 
while handling an inbound message) enters `KexOutputHandler.send()` → 
`writeOrEnqueue()`, which acquires the **read lock** and, KEX being done, 
performs the actual write via `filter.write()`.
   2. The nio2 transport can invoke the write-completion handler **inline on 
the calling thread** (`sun.nio.ch.Invoker.invokeDirect`). When that write 
**fails** (peer already gone), the failure handler closes the session: 
`Nio2Session.handleWriteCycleFailure` → `exceptionCaught` → 
`AbstractSession.preClose` → `SshTransportFilter.shutdown` → 
`KexFilter.shutdown()` → `KexOutputHandler.shutdown()`.
   3. `KexOutputHandler.shutdown()` calls `updateState()`, which tries to 
acquire the **write lock** — while the same thread still holds the **read 
lock** from step 1. `ReentrantReadWriteLock` does not support upgrading, so the 
thread parks forever holding its own read lock.
   
   Because each `KexOutputHandler` has its own per-session lock, 
`ThreadMXBean`/`jstack` reports **no deadlock**, but the nio2 worker is lost. 
On a server with a small nio2 pool this consumes one worker per occurrence; 
over time the pool is exhausted and the server accepts TCP connections but 
never completes any new SSH key exchange (clients time out during handshake). 
Other listeners (e.g. a separate FTP acceptor, an HTTP health endpoint) are 
unaffected, which can mask the outage from health checks.
   
   
   ### How to reproduce
   
   Deterministic unit test (JUnit 5 + Mockito) in package 
`org.apache.sshd.common.session.filters.kex`. It models the inline 
write-failure by having `filter.write()` invoke `output.shutdown()` on the 
calling thread — exactly what `KexFilter.shutdown()` does at the end of the 
real close chain (`output.shutdown()`), while `writeOrEnqueue()` still holds 
the read lock:
   
   ```java
   @Tag("NoIoTestCase")
   class KexOutputHandlerDeadlockTest {
   
       @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(). 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 reached; the thread deadlocks before returning
               }
           }, "kex-deadlock-worker");
           worker.setDaemon(true);
           worker.start();
   
           worker.join(TimeUnit.SECONDS.toMillis(5));
   
           if (worker.isAlive()) {
               String stack = Stream.of(worker.getStackTrace()).map(e -> "\tat 
" + e).reduce("", (a, b) -> a + '\n' + b);
               fail("KexOutputHandler.send() deadlocked (read-to-write lock 
upgrade):" + stack);
           }
       }
   }
   ```
   
   On current `dev_3.0` this test **hangs and fails** (worker parked in 
`ReentrantReadWriteLock$WriteLock.lock` via `shutdown()` → `updateState()` 
while holding the read lock taken in `writeOrEnqueue()`).
   
   
   ### Actual behavior
   
   The nio2 worker thread self-deadlocks: it holds the `KexOutputHandler` read 
lock (from `writeOrEnqueue()`) and blocks forever trying to acquire the write 
lock (from `shutdown()` → `updateState()`) on the same lock — an unsupported 
read-to-write upgrade. The session never finishes closing and the worker is 
never returned to the pool. With enough occurrences the server stops completing 
new key exchanges while still accepting TCP connections.
   
   ### Expected behavior
   
   A synchronous (inline) write failure during session teardown must not 
deadlock. `KexOutputHandler.shutdown()` should complete regardless of whether 
the calling thread already holds the read lock, and the nio2 worker should be 
released.
   
   ### Relevant log output
   
   ```Shell
   "sshd-SshServer[xxxxxxxx](port=9001)-nio2-thread-1" daemon prio=5 waiting on 
condition
      java.lang.Thread.State: WAITING (parking)
        at jdk.internal.misc.Unsafe.park([email protected]/Native Method)
        - parking to wait for  <0x...> (a 
java.util.concurrent.locks.ReentrantReadWriteLock$NonfairSync)
        at 
java.util.concurrent.locks.ReentrantReadWriteLock$WriteLock.lock([email protected]/ReentrantReadWriteLock.java:966)
        at 
org.apache.sshd.common.session.filters.kex.KexOutputHandler.updateState(KexOutputHandler.java:146)
        at 
org.apache.sshd.common.session.filters.kex.KexOutputHandler.shutdown(KexOutputHandler.java:193)
        at 
org.apache.sshd.common.session.filters.kex.KexFilter.shutdown(KexFilter.java:342)
        at 
org.apache.sshd.common.session.filters.SshTransportFilter.shutdown(SshTransportFilter.java:116)
        at 
org.apache.sshd.common.session.helpers.AbstractSession.preClose(AbstractSession.java:524)
        at 
org.apache.sshd.common.util.closeable.AbstractCloseable.close(AbstractCloseable.java:94)
        at 
org.apache.sshd.common.session.helpers.SessionHelper.exceptionCaught(SessionHelper.java:1029)
        at 
org.apache.sshd.common.session.helpers.AbstractSessionIoHandler.exceptionCaught(AbstractSessionIoHandler.java:65)
        at 
org.apache.sshd.common.io.nio2.Nio2Session.exceptionCaught(Nio2Session.java:208)
        at 
org.apache.sshd.common.io.nio2.Nio2Session.handleWriteCycleFailure(Nio2Session.java:598)
        at 
org.apache.sshd.common.io.nio2.Nio2Session$2.onFailed(Nio2Session.java:549)
        at 
org.apache.sshd.common.io.nio2.Nio2CompletionHandler.failed(Nio2CompletionHandler.java:42)
        at sun.nio.ch.Invoker.invokeUnchecked([email protected]/Invoker.java:123)
        at sun.nio.ch.Invoker.invokeDirect([email protected]/Invoker.java:140)
        at 
sun.nio.ch.UnixAsynchronousSocketChannelImpl.implWrite([email protected]/UnixAsynchronousSocketChannelImpl.java:753)
        at 
sun.nio.ch.AsynchronousSocketChannelImpl.write([email protected]/AsynchronousSocketChannelImpl.java:399)
        at 
org.apache.sshd.common.io.nio2.Nio2Session.doWriteCycle(Nio2Session.java:535)
        at 
org.apache.sshd.common.io.nio2.Nio2Session.startWriting(Nio2Session.java:516)
        at 
org.apache.sshd.common.io.nio2.Nio2Session.writeBuffer(Nio2Session.java:191)
        at 
org.apache.sshd.common.session.filters.CryptFilter$EncryptionHandler.send(CryptFilter.java:387)
        at 
org.apache.sshd.common.session.filters.CompressionFilter$Compressor.send(CompressionFilter.java:161)
        at 
org.apache.sshd.common.session.filters.kex.KexFilter$Sender.send(KexFilter.java:1300)
        at 
org.apache.sshd.common.session.filters.kex.KexFilter.write(KexFilter.java:1100)
        at 
org.apache.sshd.common.session.filters.kex.KexOutputHandler.writeOrEnqueue(KexOutputHandler.java:268)
   <-- read lock held here
        at 
org.apache.sshd.common.session.filters.kex.KexOutputHandler.send(KexOutputHandler.java:225)
        at 
org.apache.sshd.common.session.helpers.AbstractSession.writePacket(AbstractSession.java:565)
        at 
org.apache.sshd.common.channel.AbstractChannel$GracefulChannelCloseable.close(AbstractChannel.java:646)
        at 
org.apache.sshd.common.session.helpers.AbstractConnectionService.channelClose(AbstractConnectionService.java:896)
        at 
org.apache.sshd.common.session.helpers.AbstractConnectionService.process(AbstractConnectionService.java:735)
        at 
org.apache.sshd.common.session.helpers.AbstractSession.handleMessage(AbstractSession.java:430)
          ... (inbound message processing; SSH_MSG_CHANNEL_CLOSE being written 
back) ...
   
   
   (Line numbers are from the 3.0.0-M4 runtime; the same method chain exists on 
dev_3.0. `<0x...>` and the session id are redacted.)
   ```
   
   ### Other information
   
   
   **Root cause.** `writeOrEnqueue()` deliberately holds the read lock across 
`filter.write()` (see the comment there: it must decide-and-write atomically 
w.r.t. KEX state so a concurrent `KEX_INIT` cannot reorder a high-level message 
after our `KEX_INIT`). That is fine as long as the write cannot re-enter the 
same lock — but the nio2 layer can run the write-failure callback inline, and 
that callback's close path re-enters `KexOutputHandler.shutdown()` → 
`updateState()`, which needs the write lock. Hence the illegal upgrade.
   
   **Suggested fix (surgical).** In `shutdown()`, skip the write-lock 
acquisition when the current thread already holds the read lock — the state 
update is safe there because a held read lock already excludes all writers, and 
the values involved are atomics:
   
   ```java
   public void shutdown() {
       shutDown.set(true);
       Supplier<SimpleImmutableEntry<Integer, DefaultKeyExchangeFuture>> update 
= () -> {
           kexFlushed.set(true);
           return new SimpleImmutableEntry<>(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 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));
   }
   ```
   
   With this change the reproduction test above passes (the call returns in 
well under a second). `shutdown()` is reached only via the one-time session 
close (`AbstractCloseable.close()` uses `state.compareAndSet(Opened, ...)`), 
and `DefaultSshFuture.setValue()` is already "set once" and thread-safe, so the 
lock-free branch introduces no new race.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to