8000 Add close_output/2 for half-close WebSocket support by chgeuer · Pull Request #141 · dominicletz/websockex · GitHub
[go: up one dir, main page]

Skip to content

Add close_output/2 for half-close WebSocket support - #141

Closed
chgeuer wants to merge 5 commits into
dominicletz:masterfrom
chgeuer:master
Closed

chgeuer wants to merge 5 commits into
dominicletz:masterfrom
chgeuer:master

Conversation

@chgeuer
@chgeuer chgeuer commented Dec 19, 2025
Copy link
Copy Markdown
Contributor

Summary

This PR adds close_output/2 to support the WebSocket half-close pattern - sending a close frame while continuing to receive incoming frames until the remote side closes.

Motivation

The WebSocket protocol supports a "half-close" pattern where one side can signal "I'm done sending" while still receiving data. This is analogous to TCP's shutdown(SHUT_WR).

Use case: Azure Relay's HybridConnection protocol requires this pattern. When forwarding TCP connections through WebSocket-based relay:

  1. Local TCP client sends data, then calls shutdown(:write)
  2. Bridge forwards data to remote via WebSocket
  3. Bridge needs to signal "done sending" to remote while still receiving the response
  4. Remote sends response, then closes

Without half-close support, the bridge must either:

  • Close the WebSocket entirely (losing any pending response data), or
  • Keep the connection open (remote doesn't know we're done sending)

Changes

  • WebSockex.close_output/3 - Sends a close frame but enters output_closed_loop instead of close_loop
  • output_closed_loop/4 - Processes incoming frames normally, rejects outgoing frames with NotConnectedError{connection_state: :output_closed}
  • Error messages - Added :closing and :output_closed states to NotConnectedError

Usage

# Signal done sending, continue receiving
{:ok, :ok} = WebSockex.close_output(client)

# With custom close code/message
{:ok, :ok} = WebSockex.close_output(client, {1000, "Done sending"})

# After close_output, sends fail gracefully
{:error, %WebSockex.NotConnectedError{connection_state: :output_closed}} =
  WebSockex.send_frame(client, {:text, "fails"})

# Incoming frames still processed via callbacks until remote closes

Tests

Added 3 tests covering:

  • Receiving frames after close_output
  • send_frame returning appropriate error after close_output
  • Custom close code/message support

Closes #140

- Implement close_output/2 to send close frame while continuing to receive
- Add output_closed_loop to process incoming frames after output is closed
- Handle callback replies gracefully when output is closed (ignore sends)
- Add error messages for closing and output_closed connection states

Implements dominicletz#140
@gemini-code-assist
Copy link
Copy Markdown

Summary of Changes

Hello @chgeuer, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly enhances WebSockex by adding support for the WebSocket half-close pattern. This feature, analogous to TCP's shutdown(SHUT_WR), allows one endpoint to signal it has finished sending data while remaining open to receive data from the other side. This is particularly useful for protocols like Azure Relay's HybridConnection, which require this behavior for efficient TCP connection forwarding over WebSockets. The implementation includes a new public API function, a dedicated internal loop for the half-closed state, and robust error handling, all backed by new test cases.

Highlights

  • Half-Close WebSocket Support: Introduced WebSockex.close_output/2 to enable the WebSocket half-close pattern, allowing a client to stop sending data while continuing to receive.
  • New Connection State: Implemented an output_closed_loop to manage the connection state where outgoing frames are rejected, but incoming frames are still processed.
  • Enhanced Error Handling: Updated WebSockex.NotConnectedError to provide specific messages for :closing and :output_closed connection states.
  • Comprehensive Testing: Added new tests to cover the functionality of close_output, including error handling for subsequent send attempts and custom close codes.

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution. ↩

@gemini-code-assist gemini-code-assist Bot left a comment
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request introduces support for WebSocket half-close functionality by adding a close_output/2 function. The implementation is well-documented and includes relevant tests. The overall approach is sound, creating a new state loop (output_closed_loop) to handle receiving frames after the client has signaled it is done sending.

My review includes two main points. First, a high-severity issue regarding an unhandled error case that could cause the process to crash if close_output is called with an invalid close code. Second, a medium-severity suggestion to refactor the newly introduced code to reduce significant duplication between the new output_closed_loop and the existing websocket_loop, which would improve long-term maintainability.

Comment thread lib/websockex.ex Resolved
Comment thread lib/websockex.ex Resolved
Add catch-all {:error, reason} clause to handle errors like
FrameEncodeError when an invalid close code is provided.
This prevents process crashes and returns the error to the caller,
matching the pattern used in sync_send/5.
Reduce code duplication by using a single unified loop with an
output_closed_reason field in state to track the half-close state.
This removes ~160 lines of duplicated code including:
- output_closed_loop (duplicate of websocket_loop)
- handle_frame_output_closed (duplicate of handle_frame)
- handle_fragment_output_closed (duplicate of handle_fragment)
- common_handle_output_closed (duplicate of common_handle)

The unified websocket_loop now handles both normal and output_closed
states by checking the output_closed_reason field.
Add pin operator (^) to variables used in size() within bitst
8000
ring
pattern matches, as required by newer Elixir versions.
This enables WebSocket connections through HTTP CONNECT proxies, which is
essential for scenarios like:
- Corporate proxy environments
- MITM proxy debugging/inspection
- Network traffic analysis

Changes to Conn module:
- Add :proxy option to connection_option type (accepts URL string or map)
- New open_socket/1 clause for SSL connections with proxy:
  1. Connect to proxy via plain TCP
  2. Send HTTP CONNECT request to establish tunnel
  3. Wait for 200 OK response
  4. Upgrade TCP socket to SSL over the tunnel
- Add helper functions:
  - parse_proxy_option/1 - handles URL strings and maps
  - send_connect_request/2 - sends HTTP CONNECT to proxy
  - receive_connect_response/3 - parses proxy response
  - parse_connect_headers/2 - consumes response headers

Usage:
  WebSockex.start_link(url, module, state,
    proxy: "http://localhost:8080"
  )

Or with a map:
  WebSockex.start_link(url, module, state,
    proxy: %{host: "proxy.example.com", port: 8080}
  )
@dominicletz
Copy link
Copy Markdown
Owner

@cursor rebase this on master resolving the conflicts, and then explain this PR in detail and answer the question whether a) it's still needed, b) makes sense in the current form and c) recommend whether it should be merged yet.

@cursor
cursor Bot commented Oct 22, 2026 •
Copy link
Copy Markdown

I rebased #141 onto current master as #145. I would not merge it yet.

#141’s head is chgeuer:master, so I could not rewrite that branch. The rebased commits are on cursor/rebase-close-output-141-b57e: #145

What the rebase did

master had moved by three commits since #141: pin operators in the frame module (#142, #143) and TLS peer verification by default (#144).

  • The pin-operator commit on the PR was already upstream, so the rebase dropped it. frame.ex is unchanged against master.
  • The only conflict was in lib/websockex/conn.ex, from the proxy commit. Verify TLS peers by default #144 changed ssl_connection_options/1 to return {:ok, options} | {:error, reason}. I kept the proxy helpers and pointed both SSL open paths (direct and HTTP CONNECT) at that tuple. The direct path matches master.
  • I formatted two close_output clauses so mix format --check-formatted passes.

On Elixir 1.18.4 / OTP 27, compile with warnings as errors, format, Credo warnings, and Dialyzer pass. The three close_output tests pass. The full suite has one failure that also fails on current master under OTP 27: the hostname-mismatch test expects {:tls_alert, {:bad_certificate, _}}, and OTP 27 reports that failure as {:tls_alert, {:handshake_failure, _}}.

What this PR actually is

WebSockex already has a full close. A callback returning {:close, state}, or a cast of :close, sends a close frame and enters close_loop. That loop throws away further socket bytes until the TCP connection drops or a 5 second timer fires. Application frames that arrive after the local close are never delivered to handle_frame.

close_output is a different state. It sends one close frame, records output_closed_reason on the connection state, and stays in the normal read loop:

  • send_frame returns %WebSockex.NotConnectedError{connection_state: :output_closed}.
  • Incoming data, fragments, pings, and pongs still run callbacks.
  • A {:reply, frame, state} from a callback is discarded.
  • When a close frame or a TCP close arrives, the process goes through the existing close path.

That matches .NET ClientWebSocket.CloseOutputAsync: signal “I will not write any more,” then keep reading until the peer closes. Azure Relay Hybrid Connections use that for a relayed TCP shutdown(write) while the response is still coming back. #142, #143, and #144 do not add this. Master still cannot do it.

This is narrower than a TCP half-close of a session that stays open. RFC 6455 says a peer that receives a close frame must answer with its own close as soon as it has finished data it already intends to send. A conforming server will not keep chatting after that frame. The feature matters for peers that deliberately send more data frames after seeing the close, which is what the Relay service does.

Does this form make sense?

The loop shape does. After review, the duplicate output_closed_loop was folded into websocket_loop via output_closed_reason, and handle_close_output now has a catch-all for encode and send errors. That is the right structure.

The branch still has defects that make this the wrong revision to land.

The proxy commit does not belong here. It is not in the #141 description or in #140, and it has no tests. ws:// with a proxy connects directly and ignores the proxy. The option type has username and password, but the URL parser always stores nil and the CONNECT request never sends Proxy-Authorization. A failed CONNECT or TLS upgrade leaves the proxy TCP socket open. After #144, upgrading that socket with :ssl.connect/3 does not set server_name_indication, so default hostname verification will fail for wss:// through a proxy unless the caller passes custom ssl_options.

Half-close itself is wrong in a few places that the tests do not reach:

  • A peer close after close_output still goes through handle_remote_close/4, which sends a second close frame. The spec requires that reply only when this side has not already sent one. A callback that returns {:close, state} while output is already closed does the same, and handle_local_close/4 has no clause for any send error other than :closed and :einval, so that path can crash the process.
  • The default handle_ping replies with a pong, and common_handle drops every reply while output is closed. A half-close that outlives the peer’s ping interval gets no pong.
  • reconnect/3 clears the buffer and the fragment, and leaves output_closed_reason set. A {:reconnect, state} from handle_disconnect comes back unable to send.
  • The contract disagrees with itself. The spec and the moduledoc examples say close_output returns :ok. The process replies {:ok, :ok}, so the function returns {:ok, :ok}, which is what the tests assert. send_frame returns a bare :ok. The docs also call :timeout an option; it is a positional argument.
  • During connect, open_loop answers send_frame with connection_state: :opening. It does not match $websockex_close_output, so that call waits until the socket is up, and a timeout leaves the message queued to run later.
  • There is no receive deadline after close_output. close_loop force-closes after 5 seconds. For Relay, that wait can legitimately be long, so copying the 5 second timer would be wrong, but the caller also has no timeout argument and no way to abort other than killing the process.

The tests do not cover the behavior master is missing. They check that send_frame fails afterward, that a custom close tuple still makes send_frame fail (the server is never checked for code 1000 or "Done sending"), and that Cowboy’s terminate(:remote) fires. That last signal is the same one the existing WebSockex.cast(pid, :close) test already asserts. Nothing sends an application frame after the local close and asserts that handle_frame received it. Those three tests would pass if close_output were just the existing full close.

Recommendation

Keep the feature. Hold the merge until the proxy commit is split out, close_output stops sending a second close, reconnect clears the half-close flag, the public return value is :ok, and a test shows a data frame arriving after the local close frame.

Open in Web Open in Cursor 

@dominicletz
Copy link
Copy Markdown
Owner

I've split and merged this. Closing as done

@chgeuer
chgeuer commented Oct 30, 2026
Copy link
Copy Markdown
Contributor Author

Thanks mate

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.

Feature Request: Add close_output/2 for Half-Close WebSocket Support

2 participants

0