Skip to content

Commit 42cc21a

Browse files
committed
[kyo-net] Closed exceptions that name the failing connection, not the driver
## Problem A user reported that Kyo's shared HTTP client wedges under load ([full report](https://gist.github.com/baldram/5c21566eacb4a5a361b6a60ead5af76d)). Under sustained concurrent traffic with connection churn (16 threads each running 200 requests against a server that restarted every 25ms, on the NIO backend), requests started failing with `NioIoDriver[sel=...] ... is closed. finishConnect failed`. The IO driver is a process-wide singleton shared by every `HttpClient`, every failure in the run named the same selector, and the failures did not stop, so the report concluded the shared driver had gone permanently closed for the life of the process, one failure taking down every later call. That conclusion was wrong, and the error message is why. What happened during the churn is ordinary: an individual `finishConnect` lost its race against a server that had just restarted, and that one connection failed. But the failure was reported as `Closed` naming the driver, at a frame pointing into driver internals, so a routine per-connection failure read as the whole shared driver dying. The message turned a single connection failing into an apparent process-wide outage. ## Solution `Closed` now names the resource that actually closed and carries that resource's own creation frame: a `connection` at the site it was opened, a `listener` at the site it began listening. A driver-internal frame remains only for a genuine whole-driver shutdown, the one case where the driver is the resource. Failures that are not closures stop pretending to be. An OS-level read, connect, or accept error is now a typed `NetException` carrying the errno, which the transport still wraps into the public `NetConnectException` the caller already handles. Carrying the typed connect and accept errors meant widening an internal promise's error row from `Closed` to `Closed | NetException`, threaded through the real promise storage with no cast that narrows it back. The public exception types are unchanged. The change adds two cross-backend resilience suites that did not exist before, `TransportResilienceTest` and `HttpServerResilienceTest`. They drive NIO, the kqueue and epoll poller, and io_uring through the failure modes the report implicated: connection churn, in-flight cancellation, RST and FIN and half-close, and connection-pool integrity, each time asserting that a co-tenant connection on the same shared driver keeps working while another one fails. They were the reproduction that showed the driver never wedges, and they stay as the guard that a per-connection failure can never again read as a driver-wide outage. ## Notes - The reported permanent wedge does not exist. The resilience suites reproduce the report's load and never produce one: the driver stays live and keeps serving connections under the churn. The only defect the report actually surfaced is the misleading message, and that is what the repro validated. - A separate io_uring bug, unrelated to the report, is fixed here too. A single transient accept error (`EINTR`, `ECONNABORTED`, or momentary file-descriptor exhaustion) made the driver fail the accept promise, and the accept loop reads any accept failure as the listener closing, so one transient errno permanently wedged the listener. It now re-arms the accept instead of failing it. - That re-arm is deferred by a backoff for the two file-descriptor-exhaustion errnos. On `EMFILE`/`ENFILE` the kernel leaves the pending connection in the backlog, so re-arming immediately re-reaps the same errno at once and spins the reap carrier at full CPU. Those re-arm after a backoff instead, matching the poller, tunable through the new `-Dkyo.net.acceptResourceBackoff` flag (milliseconds, default 50), and guarded by a reproduction on a real io_uring ring. - Validated on a real io_uring ring in a Linux container, including the end-of-run file-descriptor leak check, plus the NIO, poller, and JS drivers on the host, across JVM, JS, and Native.
1 parent cda8136 commit 42cc21a

143 files changed

Lines changed: 2195 additions & 1074 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

build.sbt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2134,7 +2134,7 @@ lazy val `kyo-http` =
21342134
.crossType(CrossType.Full)
21352135
.in(file("kyo-http"))
21362136
.dependsOn(`kyo-core`, `kyo-config`, `kyo-schema-json`)
2137-
.dependsOn(`kyo-net`)
2137+
.dependsOn(`kyo-net` % "compile->compile;test->test")
21382138
.withKyoTest
21392139
.settings(
21402140
`kyo-settings`

kyo-http/jvm/src/test/scala/kyo/HttpServerResilienceTest.scala

Lines changed: 324 additions & 0 deletions
Large diffs are not rendered by default.

kyo-net/js-wasm/src/main/scala/kyo/net/internal/JsHandle.scala

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package kyo.net.internal
22

33
import kyo.*
4+
import kyo.net.NetConnectionIoException
45
import kyo.net.internal.transport.*
56
import kyo.net.internal.util.HandleId
67
import scala.scalajs.js
@@ -13,7 +14,7 @@ import scala.scalajs.js
1314
* Leftover bytes are stored when a `"data"` chunk arrives before `awaitRead` has been called (or when a chunk contains more data than the
1415
* pending read can consume). They are delivered on the next `awaitRead` call without resuming the socket.
1516
*/
16-
final private[kyo] class JsHandle private[kyo] (val socket: js.Dynamic, val id: HandleId):
17+
final private[kyo] class JsHandle private[kyo] (val socket: js.Dynamic, val id: HandleId, val createdAt: Frame):
1718
// Pending read promise (at most one). Absent when no read is pending.
1819
var pendingRead: Maybe[Promise.Unsafe[ReadOutcome, Abort[Closed]]] = Absent
1920

@@ -55,9 +56,9 @@ private[kyo] object JsHandle:
5556
private[kyo] case class Leftover(buf: Array[Byte], off: Int, len: Int)
5657

5758
/** Create a `JsHandle` from a connected, paused Node.js socket and attach permanent `data`, `end`, `close`, and `error` listeners. */
58-
def init(socket: js.Dynamic, driver: IoDriver[JsHandle])(using AllowUnsafe, Frame): JsHandle =
59+
def init(socket: js.Dynamic, driver: IoDriver[JsHandle], createdAt: Frame)(using AllowUnsafe): JsHandle =
5960
// JS has no file-descriptor concept; use 0 as the fd placeholder so HandleId.next produces a process-unique id.
60-
val handle = new JsHandle(socket, HandleId.next(0))
61+
val handle = new JsHandle(socket, HandleId.next(0), createdAt)
6162

6263
// Permanent "data" listener
6364
discard(socket.on(
@@ -91,11 +92,20 @@ private[kyo] object JsHandle:
9192
discard(socket.on("close", signalEof))
9293
discard(socket.on(
9394
"error",
94-
{ (_: js.Dynamic) =>
95+
{ (err: js.Dynamic) =>
9596
handle.pendingRead match
9697
case Present(pending) =>
9798
handle.clearPendingRead()
98-
pending.completeDiscard(Result.fail(Closed(driver.label, summon[Frame], "socket error")))
99+
// A Node "error" is a hard receive error on a live read: surface it as a typed receive failure on the read outcome (not a
100+
// closure), carrying the socket's own creation frame and the error's message as the cause.
101+
val cause = if js.typeOf(err.message) == "string" then err.message.toString else ""
102+
pending.completeDiscard(Result.succeed(ReadOutcome.Failed(
103+
NetConnectionIoException(
104+
s"connection socket#${handle.id}",
105+
NetConnectionIoException.Operation.Receive,
106+
cause
107+
)(using handle.createdAt)
108+
)))
99109
case Absent => ()
100110
}: js.Function1[js.Dynamic, Unit]
101111
))

kyo-net/js-wasm/src/main/scala/kyo/net/internal/JsIoDriver.scala

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package kyo.net.internal
22

33
import kyo.*
4+
import kyo.net.NetDriverUnsupportedException
5+
import kyo.net.NetException
46
import kyo.net.internal.transport.*
57
import kyo.scheduler.IOPromise
68
import scala.scalajs.js
@@ -64,7 +66,7 @@ final private[kyo] class JsIoDriver private (
6466
promise.completeDiscard(Result.succeed(ReadOutcome.PeerFin))
6567
else if handle.socket.destroyed.asInstanceOf[Boolean] then
6668
// (iii) Socket destroyed (RST, or a completed close) with nothing staged: fail Closed.
67-
promise.completeDiscard(Result.fail(Closed(label, summon[Frame], s"socket destroyed")))
69+
promise.completeDiscard(Result.fail(Closed(s"connection ${handleLabel(handle)}", handle.createdAt, "socket destroyed")))
6870
else
6971
// (iv) Request the next chunk: the permanent 'data' listener delivers it (or 'end'/'error' the EOF/failure).
7072
handle.pendingRead = Present(promise)
@@ -89,20 +91,20 @@ final private[kyo] class JsIoDriver private (
8991
end if
9092
end isPeerClosed
9193

92-
def awaitConnect(handle: JsHandle, promise: Promise.Unsafe[Unit, Abort[Closed]])(using AllowUnsafe, Frame): Unit =
94+
def awaitConnect(handle: JsHandle, promise: Promise.Unsafe[Unit, Abort[Closed | NetException]])(using AllowUnsafe, Frame): Unit =
9395
// JS connect is handled via Node.js 'connect' event callback, not via the driver
9496
promise.completeDiscard(Result.succeed(()))
9597

96-
def awaitAccept(handle: JsHandle, promise: Promise.Unsafe[Int, Abort[Closed]])(using AllowUnsafe, Frame): Unit =
98+
def awaitAccept(handle: JsHandle, promise: Promise.Unsafe[Int, Abort[Closed | NetException]])(using AllowUnsafe, Frame): Unit =
9799
// JS does not run the PosixTransport accept loop; fail fast so a caller that accidentally uses this path gets an immediate error.
98-
promise.completeDiscard(Result.fail(Closed(label, summon[Frame], s"awaitAccept not supported on JsIoDriver")))
100+
promise.completeDiscard(Result.Panic(NetDriverUnsupportedException(label, "awaitAccept")))
99101

100-
def awaitWritable(handle: JsHandle, promise: Promise.Unsafe[Unit, Abort[Closed]])(using AllowUnsafe, Frame): Unit =
102+
def awaitWritable(handle: JsHandle, promise: Promise.Unsafe[Unit, Abort[Closed | NetException]])(using AllowUnsafe, Frame): Unit =
101103
// Node's net.Socket#destroyed is a documented boolean property; js.Dynamic erases that to an untyped JS value, so recovering the typed
102104
// Boolean needs this narrowing cast. Safe per Node's documented property type; it cannot dissolve without a typed facade for Node's
103105
// net.Socket.
104106
if handle.socket.destroyed.asInstanceOf[Boolean] then
105-
promise.completeDiscard(Result.fail(Closed(label, summon[Frame], s"socket destroyed")))
107+
promise.completeDiscard(Result.fail(Closed(s"connection ${handleLabel(handle)}", handle.createdAt, "socket destroyed")))
106108
else
107109
// Register one-shot listeners for drain/close/error
108110
var drainFn: js.Function0[Unit] = null
@@ -125,7 +127,7 @@ final private[kyo] class JsIoDriver private (
125127

126128
def completeFailure(reason: String): Unit =
127129
removeAll()
128-
promise.completeDiscard(Result.fail(Closed(label, summon[Frame], reason)))
130+
promise.completeDiscard(Result.fail(Closed(s"connection ${handleLabel(handle)}", handle.createdAt, reason)))
129131

130132
drainFn = (() => completeSuccess()): js.Function0[Unit]
131133
closeFn = (() => completeFailure("socket closed before writable")): js.Function0[Unit]
@@ -156,7 +158,7 @@ final private[kyo] class JsIoDriver private (
156158

157159
def cancel(handle: JsHandle)(using AllowUnsafe, Frame): Unit =
158160
discard(handle.socket.pause())
159-
val closed = Closed(label, summon[Frame], s"${handleLabel(handle)} canceled")
161+
val closed = Closed(s"connection ${handleLabel(handle)}", handle.createdAt, "canceled")
160162
handle.pendingRead match
161163
case Present(pending) =>
162164
pending.completeDiscard(Result.fail(closed))

kyo-net/js-wasm/src/main/scala/kyo/net/internal/JsTransport.scala

Lines changed: 23 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -375,7 +375,7 @@ final private[kyo] class JsTransport private (
375375
host: String,
376376
port: Int,
377377
connectTimeout: Duration
378-
)(using AllowUnsafe, Frame): () => Unit =
378+
)(using allow: AllowUnsafe, frame: Frame): () => Unit =
379379
if connectTimeout.isFinite then
380380
// Disarming INTERRUPTS the timer, which completes it and fires the callback below, so that callback must distinguish "the deadline
381381
// elapsed" from "the deadline was called off". Harmless while the only disarm ran after `promise` had already settled (the
@@ -394,7 +394,7 @@ final private[kyo] class JsTransport private (
394394
def disarm(): Unit =
395395
// Set BEFORE the interrupt, so the callback the interrupt triggers observes it.
396396
disarmed.set(true)
397-
timer.interruptDiscard(Result.Panic(Closed("JsTransport", summon[Frame], "connect completed before deadline")))
397+
timer.interruptDiscard(Result.Panic(Interrupted(frame, "connect completed before deadline")))
398398
end disarm
399399
// Backstop for every way this connect can end before its TCP phase completes.
400400
promise.onComplete(_ => disarm())
@@ -412,8 +412,8 @@ final private[kyo] class JsTransport private (
412412
config: kyo.net.NetConfig,
413413
handshakeTimeout: Duration = Duration.Infinity
414414
)(using
415-
AllowUnsafe,
416-
Frame
415+
allow: AllowUnsafe,
416+
frame: Frame
417417
): Fiber.Unsafe[NetConnection, Abort[NetException]] =
418418
val promise = new IOPromise[NetException, Connection[JsHandle]]
419419
val driver = pool.next()
@@ -453,11 +453,7 @@ final private[kyo] class JsTransport private (
453453
discard(socket.destroy())
454454
}
455455
promise.onComplete { _ =>
456-
deadline.interruptDiscard(Result.Panic(Closed(
457-
"JsTransport",
458-
summon[Frame],
459-
"handshake settled before deadline"
460-
)))
456+
deadline.interruptDiscard(Result.Panic(Interrupted(frame, "handshake settled before deadline")))
461457
}
462458
end if
463459
}: js.Function0[Unit]
@@ -471,7 +467,7 @@ final private[kyo] class JsTransport private (
471467
connectEvent,
472468
{ () =>
473469
if tcpNoDelay then discard(socket.setNoDelay(true))
474-
val handle = JsHandle.init(socket, driver)
470+
val handle = JsHandle.init(socket, driver, frame)
475471
handle.peerCloseGrace = config.peerCloseGrace
476472
val connection = Connection.init(handle, driver, config.channelCapacity, config.peerCloseGrace)
477473
// Wire upgrade function so upgradeToTls dispatches to this transport.
@@ -550,7 +546,7 @@ final private[kyo] class JsTransport private (
550546
config: kyo.net.NetConfig,
551547
onClose: Maybe[() => Unit] = Absent,
552548
acceptHandshakeCount: Maybe[() => Int] = Absent
553-
)(using AllowUnsafe, Frame): Fiber.Unsafe[NetListener, Abort[NetException]] =
549+
)(using allow: AllowUnsafe, frame: Frame): Fiber.Unsafe[NetListener, Abort[NetException]] =
554550
val promise = new IOPromise[NetException, NetListener]
555551
rejectUnsupportedBuffers(config) match
556552
case Present(e) =>
@@ -559,7 +555,7 @@ final private[kyo] class JsTransport private (
559555
return promise.asInstanceOf[Fiber.Unsafe[NetListener, Abort[NetException]]]
560556
case Absent => ()
561557
end match
562-
val listener = new JsListener(server, NetAddress.Tcp(host, port))
558+
val listener = new JsListener(server, NetAddress.Tcp(host, port), frame)
563559
onClose.foreach(listener.onClose)
564560
acceptHandshakeCount.foreach(listener.acceptHandshakeCount)
565561

@@ -570,7 +566,7 @@ final private[kyo] class JsTransport private (
570566
if tcpNoDelay then discard(socket.setNoDelay(true))
571567

572568
val connDriver = pool.next()
573-
val handle = JsHandle.init(socket, connDriver)
569+
val handle = JsHandle.init(socket, connDriver, listener.createdAt)
574570
handle.peerCloseGrace = config.peerCloseGrace
575571
val connection = Connection.init(handle, connDriver, config.channelCapacity, config.peerCloseGrace)
576572
// Accepted connection: a STARTTLS upgrade through the public upgradeToTls runs in the TLS server role (upgradeToTls reads
@@ -630,8 +626,8 @@ final private[kyo] class JsTransport private (
630626
end listenServer
631627

632628
def connectUnix(path: String, connectTimeout: Duration, config: kyo.net.NetConfig)(using
633-
AllowUnsafe,
634-
Frame
629+
allow: AllowUnsafe,
630+
frame: Frame
635631
): Fiber.Unsafe[NetConnection, Abort[NetException]] =
636632
kyo.net.Transport.checkConnectTimeout(connectTimeout)
637633
val promise = new IOPromise[NetException, Connection[JsHandle]]
@@ -661,7 +657,7 @@ final private[kyo] class JsTransport private (
661657
"connect",
662658
{ () =>
663659
// Unix sockets do not support TCP_NODELAY: skip setNoDelay
664-
val handle = JsHandle.init(socket, driver)
660+
val handle = JsHandle.init(socket, driver, frame)
665661
handle.peerCloseGrace = config.peerCloseGrace
666662
val connection = Connection.init(handle, driver, config.channelCapacity, config.peerCloseGrace)
667663
// Wire upgrade function so upgradeToTls dispatches to this transport.
@@ -695,8 +691,8 @@ final private[kyo] class JsTransport private (
695691
end connectUnix
696692

697693
override def stdio(channelCapacity: Int, readChunkSize: Int)(using
698-
AllowUnsafe,
699-
Frame
694+
allow: AllowUnsafe,
695+
frame: Frame
700696
): Fiber.Unsafe[NetConnection, Abort[NetException]] =
701697
if !stdioClaimed.compareAndSet(false, true) then
702698
// Exactly one stdio per process: fds 0/1 are process-global, so double-ownership is rejected.
@@ -707,7 +703,7 @@ final private[kyo] class JsTransport private (
707703
// JsHandle/JsIoDriver expect a single socket-like object, so the shim presents one whose read events
708704
// come from stdin and whose write goes to stdout. destroy() is a no-op: the process owns fds 0/1.
709705
val shim = stdioShim()
710-
val handle = JsHandle.init(shim, driver)
706+
val handle = JsHandle.init(shim, driver, frame)
711707
// stdio keeps peerCloseGrace = Infinity: no TCP peer to reclaim against.
712708
val connection = Connection.init(handle, driver, channelCapacity)
713709
if connection.start() then
@@ -771,7 +767,7 @@ final private[kyo] class JsTransport private (
771767

772768
def listenUnix(path: String, backlog: Int, config: kyo.net.NetConfig)(
773769
handler: NetConnection => Unit
774-
)(using AllowUnsafe, Frame): Fiber.Unsafe[NetListener, Abort[NetException]] =
770+
)(using allow: AllowUnsafe, frame: Frame): Fiber.Unsafe[NetListener, Abort[NetException]] =
775771
val promise = new IOPromise[NetException, NetListener]
776772
rejectUnsupportedBuffers(config) match
777773
case Present(e) =>
@@ -783,7 +779,7 @@ final private[kyo] class JsTransport private (
783779
val net = js.Dynamic.global.require("net")
784780
val server = net.createServer()
785781

786-
val listener = new JsListener(server, NetAddress.Unix(path))
782+
val listener = new JsListener(server, NetAddress.Unix(path), frame)
787783

788784
discard(server.on(
789785
"connection",
@@ -792,7 +788,7 @@ final private[kyo] class JsTransport private (
792788
// Unix sockets do not support TCP_NODELAY: skip setNoDelay
793789

794790
val connDriver = pool.next()
795-
val handle = JsHandle.init(socket, connDriver)
791+
val handle = JsHandle.init(socket, connDriver, listener.createdAt)
796792
handle.peerCloseGrace = config.peerCloseGrace
797793
val connection = Connection.init(handle, connDriver, config.channelCapacity, config.peerCloseGrace)
798794
// Accepted connection: a STARTTLS upgrade through the public upgradeToTls runs in the TLS server role (upgradeToTls reads
@@ -846,7 +842,7 @@ final private[kyo] class JsTransport private (
846842
conn: NetConnection,
847843
tls: kyo.net.NetTlsConfig,
848844
channelCapacity: Int
849-
)(using AllowUnsafe, Frame): Fiber.Unsafe[NetConnection, Abort[NetException]] =
845+
)(using allow: AllowUnsafe, frame: Frame): Fiber.Unsafe[NetConnection, Abort[NetException]] =
850846
// The SNI host the upgrade engine verifies against; also the host reported by any handshake failure (an upgrade has no fresh port, so -1).
851847
val upgradeHost = tls.sniHostname.getOrElse("")
852848
// Honor a NetTlsConfig.tlsProvider pin: JS upgrades via Node's tls module, so a pin to any non-"node" provider fails closed.
@@ -1067,7 +1063,7 @@ final private[kyo] class JsTransport private (
10671063
discard(tlsSocket.destroy())
10681064
}
10691065
promise.onComplete { _ =>
1070-
deadline.interruptDiscard(Result.Panic(Closed("JsTransport", summon[Frame], "upgrade settled before deadline")))
1066+
deadline.interruptDiscard(Result.Panic(Interrupted(frame, "upgrade settled before deadline")))
10711067
}
10721068
end if
10731069

@@ -1077,7 +1073,7 @@ final private[kyo] class JsTransport private (
10771073
// Pause the TLS socket now that the handshake is done: kyo controls data flow.
10781074
// (We cannot pause before the handshake as that blocks TLS record delivery.)
10791075
discard(tlsSocket.pause())
1080-
val newHandle = JsHandle.init(tlsSocket, driver)
1076+
val newHandle = JsHandle.init(tlsSocket, driver, frame)
10811077
newHandle.peerCloseGrace = handle.peerCloseGrace // the upgraded connection inherits the original connection's reclaim grace
10821078
val newConn = Connection.init(newHandle, driver, channelCapacity, handle.peerCloseGrace)
10831079
// Preserve the upgrade role on the new TLS connection so a further upgrade does not silently flip client/server.
@@ -1147,7 +1143,8 @@ end JsTransport
11471143
*/
11481144
final private[net] class JsListener(
11491145
private val server: js.Dynamic,
1150-
private var _address: NetAddress
1146+
private var _address: NetAddress,
1147+
val createdAt: Frame
11511148
) extends NetListener:
11521149

11531150
/** Extra teardown the transport attaches, currently reclaiming the accepted sockets whose TLS handshake never settled. Written once at

kyo-net/js-wasm/src/test/scala/kyo/net/internal/JsIoDriverTest.scala

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ class JsIoDriverTest extends kyo.net.Test:
5757
given Frame = Frame.internal
5858
val driver = JsIoDriver.init()
5959
openPair().map { case (serverSock, clientSock) =>
60-
val handle = JsHandle.init(serverSock, driver)
60+
val handle = JsHandle.init(serverSock, driver, Frame.internal)
6161
// Backpressured: no awaitRead is armed. The peer writes 3 bytes then half-closes (FIN via end()).
6262
discard(clientSock.write(buffer(Array[Byte](10, 20, 30))))
6363
discard(clientSock.end())
@@ -77,7 +77,7 @@ class JsIoDriverTest extends kyo.net.Test:
7777
given Frame = Frame.internal
7878
val driver = JsIoDriver.init()
7979
openPair().map { case (serverSock, clientSock) =>
80-
val handle = JsHandle.init(serverSock, driver)
80+
val handle = JsHandle.init(serverSock, driver, Frame.internal)
8181
// The peer stays connected and sends nothing: resuming yields no data and no 'end', so readableEnded/destroyed stay false.
8282
assert(!driver.isPeerClosed(handle), "first isPeerClosed returns false")
8383
Async.sleep(300.millis).map { _ =>

0 commit comments

Comments
 (0)