Skip to content

Conversation

@sjy982
Copy link
Contributor

@sjy982 sjy982 commented Aug 17, 2025

Motivation

  • Instead of asking each handler to wrap the inbound stream with StreamMessage.timeout(..., UNTIL_NEXT), expose it as an option on WebSocketServiceBuilder / WebSocketClientBuilder so both server and client can enable it consistently and easily.
  • When the inbound completes abnormally (cancel/abort/timeout, etc.), further outbound sends are meaningless. We should immediately tidy up outbound and the stream/channel. If the connection is still valid and the termination is initiated locally, prefer sending a WebSocket close frame (with an appropriate status + reason) over a transport reset, for better reason propagation, interoperability, and observability.

Modifications

  • Add WebSocketServiceBuilder#streamTimeout(Duration).

    • When set, wrap inbound in TimeoutStreamMessage with StreamTimeoutMode.UNTIL_NEXT inside DefaultWebSocketService#serve(...).
  • Update DefaultWebSocketService#serve(...):

    • If inbound completes with an error, call outbound.abort(mappedCause) so recoverAndResume sends a CloseWebSocketFrame.
    • Map CancelledSubscriptionException / AbortedStreamException to InboundCompleteException to avoid being skipped by the recovery logic.
  • Add WebSocketClientBuilder#streamTimeout(Duration).

    • When set, wrap inbound in TimeoutStreamMessage with StreamTimeoutMode.UNTIL_NEXT inside DefaultWebSocketClient#connect(...).
  • Update WebSocketSession#setOutbound(...):

    • Attach recoverAndResume(...) to outbound so that on send failure it emits a CloseWebSocketFrame and records the exception in RequestLog (endRequest(cause) / endResponse(cause)). For ClosedStreamException, do not send a close frame—just abort.
    • When inbound completes exceptionally, abort(...) outbound with the mapped cause so the recovery stream kicks in.
  • Add InboundCompleteException extends CancellationException:

    • Signals that inbound completed due to cancellation/abort.
  • Add a client-side newCloseWebSocketFrame(Throwable) in WebSocketUtil.

Result

  • WebSocketService / WebSocketClient can apply StreamMessage.timeout(..., UNTIL_NEXT) to inbound via the simple streamTimeout(Duration) option.
  • When inbound completes with an error:
    • If the channel is still usable, abort outbound with the mapped cause so a close frame is sent (skip sending for ClosedStreamException).
    • Record request/response causes in RequestLog.
    • On HTTP/1.x, close the channel; on HTTP/2, clean up the stream.

sjy982 added 7 commits August 11, 2025 18:09
…uestLog, ensure close frame on cancel/abort; add tests (H2C close-frame/log, H1 channel close)
…utbound, closed outbound when inbound ends, introduced InboundCompleteException, and added a client-side newCloseWebSocketFrame() in WebSocketUtil.
Copy link
Contributor

@jrhee17 jrhee17 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good overall, left some comments

@codecov
Copy link

codecov bot commented Aug 28, 2025

Codecov Report

❌ Patch coverage is 80.00000% with 19 lines in your changes missing coverage. Please review.
✅ Project coverage is 74.16%. Comparing base (8150425) to head (2184b0c).
⚠️ Report is 215 commits behind head on main.

Files with missing lines Patch % Lines
...rp/armeria/common/stream/TimeoutStreamMessage.java 73.68% 5 Missing ⚠️
...orp/armeria/client/websocket/WebSocketSession.java 77.77% 2 Missing and 2 partials ⚠️
...corp/armeria/common/logging/DefaultRequestLog.java 82.35% 1 Missing and 2 partials ⚠️
...ecorp/armeria/common/InboundCompleteException.java 50.00% 2 Missing ⚠️
...ommon/websocket/WebSocketIdleTimeoutException.java 50.00% 2 Missing ⚠️
...meria/client/websocket/WebSocketClientBuilder.java 80.00% 0 Missing and 1 partial ⚠️
.../linecorp/armeria/common/stream/StreamMessage.java 0.00% 0 Missing and 1 partial ⚠️
...eria/server/websocket/WebSocketServiceBuilder.java 80.00% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##               main    #6357      +/-   ##
============================================
- Coverage     74.46%   74.16%   -0.30%     
- Complexity    22234    23035     +801     
============================================
  Files          1963     2064     +101     
  Lines         82437    86188    +3751     
  Branches      10764    11317     +553     
============================================
+ Hits          61385    63922    +2537     
- Misses        15918    16859     +941     
- Partials       5134     5407     +273     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

fix: In WebSocketSession endRequest -> requestCause
@sjy982 sjy982 requested a review from ikhoon September 2, 2025 10:47
jrhee17 added a commit that referenced this pull request Sep 16, 2025
… frame (#6375)

Motivation:

Currently, once an `HttpRequest` and `HttpResponse` is completed, the
underlying HTTP2 stream is cancelled using a `RST_STREAM`.
This makes sense for normal HTTP constructs since it indicates that we
are no longer interested in the request, and we would like to release
resources associated with it.

However, some protocols such as WebSockets implement their own graceful
shutdown procedure.

In detail, Armeria's `HttpRequest`, `HttpResponse` implements WebSocket
graceful shutdown and the reactive stream implementation is closed when
a `CLOSE` frame is both sent and received.
However, although the websocket session is completed, the underlying
HTTP2 stream may not necessarily be complete.
Protocol-wise, there is an inherent discrepancy between websocket
session completion and HTTP2 stream completion.

The current implementation defaults to sending a `RST_STREAM`
immediately once the corresponding `HttpRequest` and `HttpResponse`. For
websockets, I propose that a delay is given so that the remote has a
chance to end the stream.

Assuming that we will tie the lifecycle of inbound `WebSocket`s with
outbound `WebSocket`s (so sending a close frame also closes the inbound)
in #6357 , this option can be thought of similar to netty's
`forceCloseTimeoutMillis` option. (which acts as a timeout since sending
the CLOSE frame)

Modifications:

- Added a `closeHttp2StreamDelayMillis` option to `ServiceConfig` and
relevant implementations
- `WebSocketService` sets a `closeHttp2StreamDelayMillis` of 10 seconds
by default
- `HttpServerHandler` decides when to send a `RST_STREAM` based on the
`closeHttp2StreamDelayMillis`
- Added `Http2StreamLifecycleHandler` which maintains the lifecycle of
reset futures. This ensures that scheduled futures aren't leaked for
servers with high throughput.
- Every time a request/response is closed but the corresponding stream
is alive, `maybeResetStream` is called.
- Every time a stream is closed, `notifyStreamClosed` is called to clean
up possibly scheduled futures.

Result:

- Users can set a timeout for closing a websocket session
- Fix a bug where closing a websocket session could send a `RST_STREAM`
frame

<!--
Visit this URL to learn more about how to write a pull request
description:

https://armeria.dev/community/developer-guide#how-to-write-pull-request-description
-->
@github-actions github-actions bot added the Stale label Oct 21, 2025
@ikhoon ikhoon removed the Stale label Oct 24, 2025
@sjy982 sjy982 requested a review from ikhoon October 28, 2025 07:51
@ikhoon ikhoon added this to the 1.34.0 milestone Oct 30, 2025
@sjy982 sjy982 requested a review from ikhoon October 31, 2025 09:00
Copy link
Contributor

@ikhoon ikhoon left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work, @sjy982! 🚀 🎉

Comment on lines +152 to +157
if (cause instanceof StreamTimeoutException) {
wrapped = new WebSocketIdleTimeoutException("WebSocket inbound idle-timeout exceeded",
cause);
} else {
wrapped = new InboundCompleteException("inbound stream was cancelled", cause);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question) Is it possible to just use InboundCompleteException to indicate the outbound stream was cancelled by the inbound stream? If users would like to differentiate the reason, they could just check InboundCompleteException#cause?

Suggested change
if (cause instanceof StreamTimeoutException) {
wrapped = new WebSocketIdleTimeoutException("WebSocket inbound idle-timeout exceeded",
cause);
} else {
wrapped = new InboundCompleteException("inbound stream was cancelled", cause);
}
wrapped = new WebSocketIdleTimeoutException("WebSocket inbound idle-timeout exceeded", cause);

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we apply the code you suggested, non-timeout exceptions would also be wrapped with WebSocketIdleTimeoutException.
Or were you suggesting that we should instead use only InboundCompleteException as the single wrapper and let callers distinguish the concrete exception via its cause?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or were you suggesting that we should instead use only InboundCompleteException as the single wrapper and let callers distinguish the concrete exception via its cause?

Right, I understood that the meaning of InboundCompleteException is that "the outbound was cancelled due to the inbound".

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason we use WebSocketIdleTimeoutException is that the existing StreamTimeoutException may not clearly convey its meaning to WebSocket users. That’s why it was introduced, and since it already makes the intention explicit, we didn’t wrap it again with InboundCompleteException.

Do you think it would be better to wrap it once more using InboundCompleteException and include it as the cause?

Copy link
Contributor

@jrhee17 jrhee17 Nov 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I realized that I copy-pasted the incorrect exception in the suggestion above, I meant to modify the above block to:

wrapped = new InboundCompleteException("inbound stream was cancelled", cause);

because WebSocketIdleTimeoutException seems like a very specific exception that likely won't be used anywhere else - less exception types users have to handle/worry about

If you prefer to introduce the above exception, feel free to leave as-is as this is a nit comment.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me elaborate on why WebSocketIdleTimeoutException was added.
Many WebSocket implementations disable request timeouts and instead detect and close idle sessions. Since this behavior is quite common for users working with WebSockets, I thought it would be better to introduce a more specific error type to convey the state clearly.

@sjy982 sjy982 requested a review from jrhee17 November 7, 2025 08:41
@jrhee17 jrhee17 modified the milestones: 1.34.0, 1.35.0 Nov 24, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants