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

pjfanning pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/pekko-connectors.git


The following commit(s) were added to refs/heads/main by this push:
     new fd74c9506 Unix Domain Socket: fix large-transfer stall from lost 
readiness events (#1880)
fd74c9506 is described below

commit fd74c9506992a4dc511fe4dd33c88a806b1e6ec2
Author: PJ Fanning <[email protected]>
AuthorDate: Tue Sep 22 10:58:19 2026 +0100

    Unix Domain Socket: fix large-transfer stall from lost readiness events 
(#1880)
    
    Motivation:
    UnixDomainSocketSpec's "send and receive more ten times the size of a
    buffer" test (disabled since 2019) stalls part way through the transfer:
    the echoing server never writes a byte, its receive queue then fills,
    and both directions deadlock.
    
    jnr-enxio's KQSelector registers kqueue filters edge-triggered
    (EV_CLEAR) and its poll() overwrites a key's readyOps for each kevent
    entry instead of OR-ing them. When EVFILT_READ and EVFILT_WRITE for the
    same channel arrive in one batch, the entry processed last clobbers the
    other's readiness, and the consumed edge never fires again. The event
    loop gated writes on key.isWritable and reads on key.isReadable, so a
    clobbered event stalled that direction forever.
    
    Modification:
    Attempt a pending write or read in the NIO event loop whenever the key
    is selected, rather than only when the (unreliable) ready ops report it.
    A non-blocking operation on a not-ready socket returns 0, which is now
    handled: an unfinished write keeps OP_WRITE registered and a 0-byte read
    is a no-op instead of being mistaken for EOF. This is self-healing: a
    readiness edge can only be lost to another event on the same key, and
    that event now triggers the retry. Re-enable the test.
    
    Result:
    Sustained transfers complete; the throughput test passes in ~250ms
    instead of timing out after a minute.
    
    Tests:
    - sbt "unix-domain-socket/testOnly docs.scaladsl.UnixDomainSocketSpec"
      (all 7 pass, stable over 5 consecutive runs; the re-enabled test
      reproduced the stall before the fix)
    
    References:
    Fixes #1858
---
 .../impl/UnixDomainSocketImpl.scala                | 29 ++++++++++++++++------
 .../scala/docs/scaladsl/UnixDomainSocketSpec.scala |  2 +-
 2 files changed, 23 insertions(+), 8 deletions(-)

diff --git 
a/unix-domain-socket/src/main/scala/org/apache/pekko/stream/connectors/unixdomainsocket/impl/UnixDomainSocketImpl.scala
 
b/unix-domain-socket/src/main/scala/org/apache/pekko/stream/connectors/unixdomainsocket/impl/UnixDomainSocketImpl.scala
index 3612ed6ee..789eaacde 100644
--- 
a/unix-domain-socket/src/main/scala/org/apache/pekko/stream/connectors/unixdomainsocket/impl/UnixDomainSocketImpl.scala
+++ 
b/unix-domain-socket/src/main/scala/org/apache/pekko/stream/connectors/unixdomainsocket/impl/UnixDomainSocketImpl.scala
@@ -200,7 +200,12 @@ private[unixdomainsocket] object UnixDomainSocketImpl {
               case null                                   =>
               case sendReceiveContext: SendReceiveContext =>
                 sendReceiveContext.send match {
-                  case SendRequested(buffer, sent) if keySelectable && 
key.isWritable =>
+                  // Attempt the write whenever the key is selected, not only 
when it reports
+                  // writable. The underlying jnr-enxio selectors register 
edge-triggered kqueue
+                  // filters and can lose a write-readiness event when a read 
event for the same
+                  // channel arrives in the same batch - a non-blocking write 
simply returns 0
+                  // when the socket has no space, so trying is always safe.
+                  case SendRequested(buffer, sent) if keySelectable =>
                     val channel = key.channel().asInstanceOf[UnixSocketChannel]
 
                     val written =
@@ -220,10 +225,14 @@ private[unixdomainsocket] object UnixDomainSocketImpl {
 
                     log.debug("written: {} remaining: {}", written, remaining)
 
-                    if (written >= 0 && remaining == 0) {
-                      sendReceiveContext.send = SendAvailable(buffer)
-                      key.interestOps(key.interestOps() & 
~SelectionKey.OP_WRITE)
-                      sent.success(Done)
+                    if (written >= 0) {
+                      if (remaining == 0) {
+                        sendReceiveContext.send = SendAvailable(buffer)
+                        key.interestOps(key.interestOps() & 
~SelectionKey.OP_WRITE)
+                        sent.success(Done)
+                      } else {
+                        key.interestOps(key.interestOps() | 
SelectionKey.OP_WRITE)
+                      }
                     }
                   case _: SendRequested =>
                     key.interestOps(key.interestOps() | SelectionKey.OP_WRITE)
@@ -253,7 +262,11 @@ private[unixdomainsocket] object UnixDomainSocketImpl {
                     } catch { case _: IOException => }
                 }
                 sendReceiveContext.receive match {
-                  case ReceiveAvailable(queue, buffer) if keySelectable && 
key.isReadable =>
+                  // As for writes above, attempt the read whenever the key is 
selected so that a
+                  // read-readiness event lost to the edge-triggered selector 
is recovered by the
+                  // event that clobbered it. A read of 0 bytes means "no data 
yet" and leaves the
+                  // registered read interest as it is.
+                  case ReceiveAvailable(queue, buffer) if keySelectable =>
                     buffer.clear()
 
                     val channel = key.channel.asInstanceOf[UnixSocketChannel]
@@ -268,12 +281,14 @@ private[unixdomainsocket] object UnixDomainSocketImpl {
 
                     log.debug("read: {}", read)
 
-                    if (read >= 0) {
+                    if (read > 0) {
                       buffer.flip()
                       val pendingResult = queue.offer(ByteString(buffer))
                       pendingResult.onComplete(_ => sel.wakeup())
                       sendReceiveContext.receive = PendingReceiveAck(queue, 
buffer, pendingResult)
                       key.interestOps(key.interestOps() & 
~SelectionKey.OP_READ)
+                    } else if (read == 0) {
+                      // No data available - the key was selected for another 
reason
                     } else {
                       queue.complete()
                       try {
diff --git 
a/unix-domain-socket/src/test/scala/docs/scaladsl/UnixDomainSocketSpec.scala 
b/unix-domain-socket/src/test/scala/docs/scaladsl/UnixDomainSocketSpec.scala
index 5435afd3c..8d062cde5 100644
--- a/unix-domain-socket/src/test/scala/docs/scaladsl/UnixDomainSocketSpec.scala
+++ b/unix-domain-socket/src/test/scala/docs/scaladsl/UnixDomainSocketSpec.scala
@@ -84,7 +84,7 @@ class UnixDomainSocketSpec
       binding.futureValue.unbind().futureValue should be(())
     }
 
-    "send and receive more ten times the size of a buffer" ignore {
+    "send and receive more ten times the size of a buffer" in {
       val BufferSizeBytes = 64 * 1024
 
       val path = dir.resolve("sock2")


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

Reply via email to