Skip to content

Commit dd5f00b

Browse files
adamwclaude
andauthored
Tolerate Vert.x 5 pause/resume on ended requests (fixes silent test hangs) (#5383)
## Problem The Vert.x 4 → 5 upgrade (80c5e37, 2026-06-10) changed `HttpServerRequestImpl.pause()`/`fetch()`/`resume()` semantics: they now `checkEnded()` and **throw** `IllegalStateException("Request has already been read")` once the request body END event has been delivered. In Vert.x 4 these calls were safe no-ops after end. Tapir's vertx streaming bridge invokes `pause()`/`resume()` from fire-and-forget fibers and Vert.x callbacks: - `server/vertx-server/cats/src/main/scala/sttp/tapir/server/vertx/cats/streams/fs2.scala` — back-pressure loop in a started-and-never-joined fiber - `server/vertx-server/zio/src/main/scala/sttp/tapir/server/vertx/zio/streams/zio.scala` — same pattern, `forkDaemon`-ed - `server/vertx-server/src/main/scala/sttp/tapir/server/vertx/streams/Pipe.scala` — drain handlers / pipe start - `server/vertx-server/src/main/scala/sttp/tapir/server/vertx/handlers/package.scala` — `streamPauseHandler` for every streaming/WS endpoint Under CI load, a `pause`/`resume` racing the END event now throws, silently killing the back-pressure fiber (or skipping cleanup such as `socket.close()`), leaving stream consumers waiting forever. This is the suspected root cause of the intermittent **silent hangs** in the vertx cats/zio suites seen on dependency PRs #5373 / #5362, where CI went quiet until the 15-minute retry timeout. ## Fix Restore Vert.x 4 semantics at tapir's call sites by ignoring the post-end `IllegalStateException`: after END there is nothing left to pause/resume, so dropping the call is semantically correct. No fibers/streams were restructured — only the pause/resume calls on the request/read-stream are guarded (`.attempt.void` in fs2, `.ignore` in zio, a small `ignoringReadEnded` helper in `Pipe.scala`, and a try/catch in `streamPauseHandler`). Companion PR: #5382 adds defensive timeouts + a thread-dump watchdog on the CI side; this PR is the root-cause side. ## Verification All variants compile (2.12 / 2.13 / 3): `vertxServer{,2_12,3}`, `vertxServerCats{,2_12,3}`, `vertxServerZio{,2_12,3}`. Test suites: - `vertxServerCats2_12/test`: Tests: succeeded 292, failed 0 — all passed - `vertxServerZio3/test`: Tests: succeeded 295, failed 0 — all passed - `vertxServer/test` (2.13): Tests: succeeded 554, failed 0 — all passed Stress run of the formerly racy path — `vertxServerCats2_12/testOnly sttp.tapir.server.vertx.cats.CatsVertxServerTest` 5 times in a loop: 5/5 passed (285 tests each). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent cbf3d49 commit dd5f00b

4 files changed

Lines changed: 18 additions & 8 deletions

File tree

  • server/vertx-server

server/vertx-server/cats/src/main/scala/sttp/tapir/server/vertx/cats/streams/fs2.scala

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -154,8 +154,9 @@ object fs2 {
154154
} yield result.map((_, ()))
155155
})
156156
.evalMap({
157-
case Pause => Sync[F].delay(readStream.pause())
158-
case Resume => Sync[F].delay(readStream.resume())
157+
// Vert.x 5 throws an IllegalStateException when pausing/resuming a fully-read request; it's safe to ignore (Vert.x 4 treated it as a no-op)
158+
case Pause => Sync[F].delay(readStream.pause()).attempt.void
159+
case Resume => Sync[F].delay(readStream.resume()).attempt.void
159160
})
160161
.compile
161162
.drain

server/vertx-server/src/main/scala/sttp/tapir/server/vertx/handlers/package.scala

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ package object handlers {
2121
}
2222

2323
private[vertx] lazy val streamPauseHandler: Handler[RoutingContext] = { rc =>
24-
rc.request.pause()
24+
// Vert.x 5 throws an IllegalStateException when pausing a fully-read request; it's safe to ignore (Vert.x 4 treated it as a no-op)
25+
try rc.request.pause()
26+
catch { case _: IllegalStateException => () }
2527
rc.next()
2628
}
2729

server/vertx-server/src/main/scala/sttp/tapir/server/vertx/streams/Pipe.scala

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ object Pipe {
1818
private case object Pause extends Command
1919
private case object Resume extends Command
2020

21+
// Vert.x 5 throws an IllegalStateException when pausing/resuming a fully-read request; it's safe to ignore (Vert.x 4 treated it as a no-op)
22+
private def ignoringReadEnded[T](f: => T): Unit = {
23+
try { val _ = f }
24+
catch { case _: IllegalStateException => () }
25+
}
26+
2127
@tailrec
2228
def modify[A, B](ref: AtomicReference[A], f: A => (A, B)): B = {
2329
val oldA = ref.get
@@ -68,7 +74,7 @@ object Pipe {
6874
case Stop =>
6975
()
7076
case act =>
71-
if (act == Resume) request.resume() else if (act == Pause) request.pause() else ()
77+
if (act == Resume) ignoringReadEnded(request.resume()) else if (act == Pause) ignoringReadEnded(request.pause()) else ()
7278
ref updateAndGet {
7379
case BackpressureState(true, i, commands) => BackpressureState(inProgress = false, i, commands)
7480
case unexpected => throw new Exception(s"Unexpected state $unexpected")
@@ -116,7 +122,7 @@ object Pipe {
116122
()
117123
}
118124

119-
request.resume()
125+
ignoringReadEnded(request.resume())
120126
()
121127
}
122128

@@ -170,7 +176,7 @@ object Pipe {
170176
()
171177
}
172178

173-
request.resume()
179+
ignoringReadEnded(request.resume())
174180
()
175181
}
176182
}

server/vertx-server/zio/src/main/scala/sttp/tapir/server/vertx/zio/streams/zio.scala

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,10 +144,11 @@ package object streams {
144144
} yield result.map((_, ()))
145145
})
146146
.mapZIO({
147+
// Vert.x 5 throws an IllegalStateException when pausing/resuming a fully-read request; it's safe to ignore (Vert.x 4 treated it as a no-op)
147148
case Pause =>
148-
ZIO.attempt(readStream.pause())
149+
ZIO.attempt(readStream.pause()).ignore
149150
case Resume =>
150-
ZIO.attempt(readStream.resume())
151+
ZIO.attempt(readStream.resume()).ignore
151152
})
152153
.runDrain
153154
.forkDaemon

0 commit comments

Comments
 (0)