Skip to content

Surface request-stream defects in Zio client streaming#110

Merged
ghostdogpr merged 2 commits into
mainfrom
fix/zio-client-defect-hang
Jul 9, 2026
Merged

Surface request-stream defects in Zio client streaming#110
ghostdogpr merged 2 commits into
mainfrom
fix/zio-client-defect-hang

Conversation

@ghostdogpr

Copy link
Copy Markdown
Owner

Problem

In ZioClientBackend.clientStreamingRun, the request-sending effect is guarded with catchAll:

val send: UIO[Unit] = sendAll.catchAll { e =>
  ZIO.succeed(call.cancel("Error sending requests", e)) <* listener.promise.fail(e)
}

catchAll recovers only the typed error channel (E = StatusException). If the user's request stream raises a defect (Cause.Die) rather than a typed failure — a map/mapZIO that throws, ZStream.fromIterator over a throwing iterator, etc. — the handler never runs. So:

  • call.cancel(...) and call.halfClose() never execute → the server keeps waiting for messages and never sends onClose;
  • listener.promise.fail(e) never runs → promise.await is never completed;
  • the sender is forkDaemon'd, so its death is not propagated to the parent.

With CallOptions.DEFAULT (no deadline) the call hangs indefinitely, leaking the ClientCall and the awaiting fiber. The fs2 (handleErrorWith) and Kyo (Result.Panic) backends already handle this case; Zio was the outlier.

Fix

Use catchAllCause, which recovers both typed failures and defects. cause.squash yields a Throwable for call.cancel, and promise.failCause(cause) preserves the original cause (typed or defect). The normal typed-error path is unchanged.

Test

Added a regression test that runs a client-streaming call whose request stream dies with a defect. Pre-fix it hangs forever; post-fix it completes in ~200ms. The test uses @@ TestAspect.withLiveClock so the .timeout guard runs against real time rather than the frozen TestClock.

catchAll only recovers the typed StatusException channel, so a defect
(Cause.Die) in the request stream — e.g. a throwing map or iterator —
skipped call.cancel and listener.promise.fail. The forkDaemon'd sender
died silently while promise.await blocked forever, hanging the call and
leaking the ClientCall and fiber. Use catchAllCause so defects also
cancel the call and complete the promise, matching the fs2 and Kyo
backends.
catchAllCause also intercepts interruption, so on the early-response path
(the server closes before the request stream is drained) the interrupted
sender ran call.cancel and promise.failCause instead of terminating cleanly.
Re-raise interruption-only causes so only real failures and defects are
surfaced via the promise; the defect-handling fix is unchanged.
@ghostdogpr ghostdogpr merged commit 514028b into main Jul 9, 2026
3 checks passed
@ghostdogpr ghostdogpr deleted the fix/zio-client-defect-hang branch July 9, 2026 05:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant