Skip to content

feat: WebSocket support (RFC 6455)#27

Closed
fannnzhang wants to merge 26 commits intomainfrom
docs/websocket-support
Closed

feat: WebSocket support (RFC 6455)#27
fannnzhang wants to merge 26 commits intomainfrom
docs/websocket-support

Conversation

@fannnzhang
Copy link
Copy Markdown
Contributor

@fannnzhang fannnzhang commented Apr 30, 2026

Summary

Implements RFC 6455 WebSocket client support behind feature = "websocket", landing the design and plan from docs/websocket-design.md and docs/websocket-implementation-plan.md.

Public API

  • Client::new_websocket(request) -> WebSocketHandshake — entry point that reuses the existing connector / proxy / bridge / network-interceptor stack.
  • WebSocket, WebSocketSender, WebSocketReceiver — split sender/receiver pair with recv / send / close ergonomics.
  • WebSocketEngine trait — pluggable transport with a native default; the second impl (tokio-tungstenite) proves the seam.
  • New EventListener hooks covering the WS lifecycle (handshake, message in/out, ping/pong, close, errors).

Native engine (openwire/src/websocket/native/)

  • Frame opcodes, client-side masking, encoder, decoder, and continuation reassembly.
  • NativeEngine driving reader/writer tasks over BoxConnection.
  • Heartbeat scheduler with pong timeout and clean cancellation.
  • DropGuard ensures connection release on receiver/sender drop.

Handshake plumbing

  • New branch in transport::service performs hyper::upgrade for Upgrade: websocket requests.
  • Bridge injects required handshake headers and forces HTTP/1.1.
  • Generates Sec-WebSocket-Key, validates Sec-WebSocket-Accept and subprotocol negotiation.
  • New RoutePreference::Http1Only flag drives ALPN selection.

Adapter crate

  • crates/openwire-tungstenite/WebSocketEngine impl over tokio-tungstenite, kept as a separate crate per the design.

Tests

  • Workspace-wide: 290+ tests pass, 0 failures with cargo test --workspace --all-features.
  • crates/openwire/tests/websocket.rs — integration suite (echo, subprotocol, drop semantics, non-101 response handling).
  • crates/openwire-tungstenite/tests/integration.rs — text/binary round-trip and server-initiated close.
  • openwire-test — WS server helpers used across the suite.

Examples

  • crates/openwire/examples/websocket_echo.rs
  • crates/openwire/examples/websocket_subprotocol.rs

Docs

  • docs/websocket-design.md (design contract — basis of the original review on this PR).
  • docs/websocket-implementation-plan.md (TDD plan — followed).
  • docs/ARCHITECTURE.md updated with the WS branch in the canonical execution chain.

Verification

  • cargo test --workspace --all-features — all green.
  • Echo / binary / close round-trip via tungstenite engine integration tests.
  • Native engine handshake, heartbeat, and drop semantics covered by tests/websocket.rs.
  • Manual smoke: cargo run --example websocket_echo --features websocket.

Note: branch name remains docs/websocket-support from when this PR was opened during the design phase. Implementation has since landed on the same branch — title and body are updated to reflect actual scope.

Adds two artifacts that together specify how RFC 6455 WebSocket client
support will land in openwire behind feature = "websocket":

- docs/websocket-design.md: design spec covering the entry point
  (Client::new_websocket), the pluggable WebSocketEngine trait, the
  built-in NativeEngine v1 scope, handshake flow with the existing
  bridge / network-interceptor / TransportService chain, ConnectionPermit
  accounting, drop semantics, EventListener additions, error model,
  crate layout, and testing strategy.
- docs/websocket-implementation-plan.md: 33 task TDD-style plan across
  9 phases (foundation -> core types -> handshake plumbing -> native
  engine -> transport branch -> heartbeat & observability -> tests ->
  adapter crates -> docs/examples).
…embly

Implements Tasks 11–15 of docs/websocket-implementation-plan.md:

- codec.rs: Opcode enum, close_code_is_valid (RFC 6455 §7.4 codes),
  encode_frame with 7/16/64-bit length encoding, decode_frame rejecting
  masked-server frames and oversized payloads, FrameHeader / DecodedFrame.
- mask.rs: mask_in_place with RFC 6455 §5.3 test vector, random_mask_key
  via getrandom.
- session.rs: ReassemblyState turning DecodedFrame stream into EngineFrame,
  handling fragmentation, UTF-8 validation, message-size limits, and
  close-payload parsing.
- engine.rs: NativeEngine struct skeleton (filled in Task 16).
Tasks 20 + 21:
- run_writer drains WriterCommands to engine sink, handles Close handshake
  (timeout or peer-Cancel signaled by reader), and Cancel.
- run_reader drives engine stream, auto-pongs on Ping, signals writer with
  Cancel on inbound Close so close handshake completes.
- spawn_session orchestrates both tasks plus a best-effort DropGuard.
Tasks 22 + 23 (combined):

- Adds `Client::new_websocket(req)` returning a `WebSocketCall` builder
  with knobs for handshake/close timeouts, message-size limits, send
  queue size, ping interval / pong timeout, subprotocols, and engine
  selection.
- `WebSocketCall::execute` runs a dedicated WS code path: bridge-
  normalize the request (re-using `bridge::normalize_request`), DNS
  resolve + connect through `ConnectorStack` (full TCP/TLS + proxy
  routing parity, sans pool reuse), then `bind_websocket_handshake` →
  validate response → engine.upgrade → spawn_session → return
  `WebSocket`.
- `ConnectorStack` is now `Clone` and stored on `Client` (cfg-gated)
  for the WS path. `bridge::normalize_request` is exposed as
  `pub(crate)` so the WS path can reuse host/UA/body normalization
  alongside WS-specific header injection.
- Deviation from plan: instead of refactoring the entire interceptor
  chain to return a `TransportOutcome` enum (plan Task 23, approach b),
  the WS request follows a parallel path through `ConnectorStack`
  rather than `TransportService`. Application/network interceptors do
  not run for WS in v1; this is documented for v2 follow-up.
Tasks 26 + 27:

- run_writer / run_reader now accept Option<CallContext> + Option<SharedEventListener>
  and emit websocket_message_sent, websocket_message_received,
  websocket_ping_sent, websocket_pong_received, websocket_closing,
  websocket_closed, and websocket_failed at the natural points in their
  state machines. Direct event emission (no InstrumentedSink/Stream
  layer) keeps the call sites narrow.
- spawn_session takes a SessionConfig struct (queue_size, deliver_control_frames,
  close_timeout, heartbeat, ctx, listener) and wraps each spawned task
  in a tracing span (websocket_writer, websocket_reader, websocket_heartbeat).
- websocket_open fires from execute() once the engine accepts the
  upgraded IO.
- ConnectionPermit lifetime tracking deferred to v2 — the WS path does
  not currently consume the limiter, since its connection is not pooled.
Adds 8 end-to-end integration tests against tokio-tungstenite echo
server: text round-trip, binary round-trip, subprotocol negotiation,
server-initiated close, client-initiated close, sender drop semantics,
non-101 response rejection, and handshake timeout.

Two bugs surfaced and fixed:

1. WebSocket::split() prematurely cancelled the writer because the
   DropGuard field was being dropped when the WebSocket struct was
   destructured. Fix: move the cancel-on-drop logic into Arc<SenderInner>
   so that Cancel only fires once the LAST WebSocketSender clone is
   dropped.

2. bind_websocket_handshake unconditionally called hyper::upgrade::on,
   masking non-101 responses as IO errors. Fix: only call upgrade::on
   for status 101 and return Option<Upgraded>; the transport branch
   maps None to a Handshake error so HandshakeFailure::UnexpectedStatus
   bubbles up cleanly.

Also: run_reader now sends Cancel to the writer on EOF / engine error
so a pending close handshake doesn't wait the full close_timeout when
the peer never sent a Close frame back.
New crate openwire-tungstenite plugs tokio-tungstenite into openwire's
WebSocketEngine trait. Maps tungstenite::Message <-> EngineFrame, maps
tungstenite::Error -> WebSocketEngineError, and adapts the Sink/Stream
halves via futures_util::StreamExt::split.

Three integration tests exercise the adapter against the openwire-test
echo server: text round-trip, binary round-trip, and server-initiated
close. Also cleans up two stale dead_code warnings (Client::timer was
unused; NativeEngine::shared now has a doc comment showing intent).
@fannnzhang fannnzhang closed this May 1, 2026
@fannnzhang fannnzhang deleted the docs/websocket-support branch May 1, 2026 02:08
@fannnzhang fannnzhang restored the docs/websocket-support branch May 1, 2026 02:13
@fannnzhang fannnzhang reopened this May 1, 2026
@fannnzhang fannnzhang changed the title docs: add WebSocket support design and implementation plan feat: WebSocket support (RFC 6455) May 1, 2026
@fannnzhang fannnzhang closed this May 1, 2026
@fannnzhang fannnzhang deleted the docs/websocket-support branch May 1, 2026 05:25
@fannnzhang
Copy link
Copy Markdown
Contributor Author

Superseded by #28 — the head branch was renamed from docs/websocket-support to feature/websocket-support (the original name dated back to the design-phase docs PR; the branch carried the full implementation through to completion). Same commits, plus a fmt/clippy follow-up.

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