diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 62f8f5cb..713fb592 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -208,11 +208,14 @@ jobs: - run: cargo bench --no-run -p tako-rs-core autobahn: - name: autobahn websocket suite + name: autobahn websocket suite (${{ matrix.example }}) runs-on: ubuntu-latest - # The suite is heavy (4-7 minutes) and depends on docker. Run it on - # PRs that touch streams + on main. PRs that don't touch the WS path - # opt out via path filter. + strategy: + fail-fast: false + matrix: + example: [autobahn-echo, autobahn-echo-compio] + # Heavy (4-7 minutes) and needs Docker: runs on pushes to main and on PRs + # labeled run-autobahn. if: | github.event_name == 'push' || (github.event_name == 'pull_request' && @@ -224,13 +227,13 @@ jobs: - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 with: - key: autobahn + key: autobahn-${{ matrix.example }} - name: Run Autobahn fuzzingclient - run: ci/autobahn/run.sh + run: ci/autobahn/run.sh ${{ matrix.example }} - uses: actions/upload-artifact@v4 if: always() with: - name: autobahn-report + name: autobahn-report-${{ matrix.example }} path: ci/autobahn/reports/ fuzz-build: @@ -268,6 +271,7 @@ jobs: - cookies - query_multi - sse_encoder + - ws_frame steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@nightly diff --git a/Cargo.lock b/Cargo.lock index 7ae39c02..3c15e1c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -350,7 +350,7 @@ dependencies = [ "futures-util", "log", "pin-project-lite", - "tungstenite 0.29.0", + "tungstenite", ] [[package]] @@ -1029,7 +1029,7 @@ dependencies = [ "compio-tls", "futures-util", "pin-project-lite", - "tungstenite 0.29.0", + "tungstenite", ] [[package]] @@ -2191,7 +2191,7 @@ dependencies = [ "http", "httpdate", "mime", - "sha1 0.10.7", + "sha1", ] [[package]] @@ -4485,17 +4485,6 @@ dependencies = [ "digest 0.10.7", ] -[[package]] -name = "sha1" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.1", - "digest 0.11.3", -] - [[package]] name = "sha2" version = "0.10.9" @@ -4961,7 +4950,7 @@ dependencies = [ "serde_bytes", "serde_json", "serde_urlencoded", - "sha1 0.10.7", + "sha1", "sha2 0.11.0", "simd-json", "smallvec", @@ -4971,7 +4960,6 @@ dependencies = [ "tokio", "tokio-rustls", "tokio-stream", - "tokio-tungstenite", "tokio-util", "tracing", "tracing-subscriber", @@ -5011,7 +4999,7 @@ dependencies = [ "serde_html_form", "serde_json", "serde_urlencoded", - "sha1 0.10.7", + "sha1", "sha2 0.11.0", "simd-json", "smallvec", @@ -5070,7 +5058,7 @@ dependencies = [ "send_wrapper", "serde", "serde_json", - "sha1 0.10.7", + "sha1", "sha2 0.11.0", "smallvec", "subtle", @@ -5146,7 +5134,6 @@ name = "tako-rs-streams" version = "2.4.2" dependencies = [ "anyhow", - "base64 0.22.1", "bytes", "compio", "futures-util", @@ -5163,14 +5150,11 @@ dependencies = [ "quinn", "rustls", "send_wrapper", - "sha1 0.10.7", "tako-rs", "tako-rs-core", "tokio", - "tokio-tungstenite", "tokio-util", "tracing", - "url", ] [[package]] @@ -5368,18 +5352,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-tungstenite" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71" -dependencies = [ - "futures-util", - "log", - "tokio", - "tungstenite 0.30.0", -] - [[package]] name = "tokio-util" version = "0.7.19" @@ -5606,23 +5578,7 @@ dependencies = [ "rand 0.9.5", "rustls", "rustls-pki-types", - "sha1 0.10.7", - "thiserror 2.0.21", -] - -[[package]] -name = "tungstenite" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" -dependencies = [ - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "rand 0.10.3", - "sha1 0.11.0", + "sha1", "thiserror 2.0.21", ] diff --git a/Cargo.toml b/Cargo.toml index 708f797c..e6ddab6c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ members = [ exclude = [ "examples/auth", "examples/autobahn-echo", + "examples/autobahn-echo-compio", "examples/extractors-multi", "examples/file-stream", "examples/graphql", @@ -44,7 +45,7 @@ exclude = [ "examples/upload-progress", "examples/websocket", "examples/websocket-compio", - "examples/websocket-http2", + "examples/websocket-tls", "examples/webtransport", "examples/with-state", "fuzz", @@ -147,7 +148,6 @@ tikv-jemallocator = "0.7.0" tokio = { version = "1.52.1", features = ["fs", "io-util", "macros", "net", "rt", "rt-multi-thread", "signal", "sync", "time"] } tokio-rustls = "0.26.2" tokio-stream = "0.1.17" -tokio-tungstenite = "0.30.0" tokio-util = { version = "0.7.15", features = ["compat"] } tokio-vsock = "0.7.2" tonic = { version = "0.14.6", default-features = false, features = ["channel"] } @@ -167,6 +167,9 @@ zstd = "0.14.0" lto = "fat" codegen-units = 1 +[workspace.lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ["cfg(fuzzing)"] } + [workspace.lints.clippy] pedantic = { level = "warn", priority = -1 } diff --git a/ci/autobahn/README.md b/ci/autobahn/README.md new file mode 100644 index 00000000..4e3dffbd --- /dev/null +++ b/ci/autobahn/README.md @@ -0,0 +1,18 @@ +# WebSocket conformance + +Run on Linux with Cargo and Docker available: + +```sh +ci/autobahn/run.sh +ci/autobahn/run.sh autobahn-echo-compio +AUTOBAHN_CASES='1.*,2.*' ci/autobahn/run.sh +``` + +The first command tests Tokio; the second tests Compio. `AUTOBAHN_CASES` +replaces the selected cases in `fuzzingclient.json`. The configured suite +excludes large-message/performance groups 9 and compression groups 12 and 13. + +Exit status 0 means every selected case is OK or NON-STRICT. Status 1 means +the runner or server failed, a report is missing, or a case reports FAILED, +INFORMATIONAL_FAILED, WRONG_CODE, or UNCLEAN. Reports are written under +`ci/autobahn/reports/`. diff --git a/ci/autobahn/run.sh b/ci/autobahn/run.sh index d99292c3..99156231 100755 --- a/ci/autobahn/run.sh +++ b/ci/autobahn/run.sh @@ -1,33 +1,21 @@ #!/usr/bin/env bash -# # Runs the Autobahn fuzzingclient suite against the bundled echo server. -# -# Required tools on PATH: -# - cargo (with the workspace already cloned) -# - docker -# -# Exit codes: -# 0 every case is OK or NON-STRICT (interpreted as pass per Autobahn). -# 1 the runner crashed, the server didn't come up, or any case is -# FAILED / INFORMATIONAL_FAILED / WRONG_CODE / UNCLEAN. -# -# Usage: -# ci/autobahn/run.sh # full case set per fuzzingclient.json -# AUTOBAHN_CASES='1.*,2.*' ci/autobahn/run.sh # restrict cases (override) +# See ci/autobahn/README.md for prerequisites, case selection, and exit codes. set -euo pipefail ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)" +EXAMPLE="${1:-autobahn-echo}" CONF_DIR="${ROOT_DIR}/ci/autobahn" REPORT_DIR="${ROOT_DIR}/ci/autobahn/reports" cd "${ROOT_DIR}" -echo "==> Building autobahn-echo example (release)" -cargo build --release --manifest-path examples/autobahn-echo/Cargo.toml +echo "==> Building ${EXAMPLE} example (release)" +cargo build --release --manifest-path "examples/${EXAMPLE}/Cargo.toml" -echo "==> Starting autobahn-echo on 127.0.0.1:9001" -"${ROOT_DIR}/examples/autobahn-echo/target/release/autobahn-echo" & +echo "==> Starting ${EXAMPLE} on 127.0.0.1:9001" +"${ROOT_DIR}/examples/${EXAMPLE}/target/release/${EXAMPLE}" & SERVER_PID=$! trap 'kill $SERVER_PID 2>/dev/null || true' EXIT @@ -40,7 +28,7 @@ for _ in $(seq 1 50); do sleep 0.1 done if ! (echo > /dev/tcp/127.0.0.1/9001) 2>/dev/null; then - echo "autobahn-echo failed to bind 127.0.0.1:9001" >&2 + echo "${EXAMPLE} failed to bind 127.0.0.1:9001" >&2 exit 1 fi echo "==> server up, running fuzzingclient" diff --git a/docs/superpowers/plans/2026-10-18-websocket-engine.md b/docs/superpowers/plans/2026-10-18-websocket-engine.md new file mode 100644 index 00000000..c152d2ba --- /dev/null +++ b/docs/superpowers/plans/2026-10-18-websocket-engine.md @@ -0,0 +1,340 @@ +# WebSocket engine Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace tokio-tungstenite, compio-ws and tungstenite with a tako-owned WebSocket engine in `tako_rs_core::ws`, exposed as `tako::ws` on Tokio and Compio and used by GraphQL subscriptions. + +**Architecture:** IO-free layers (`frame`, `mask`, `utf8`, `receiver`, `protocol`) decode and encode frames on `BytesMut`. `socket.rs` runs them over `hyper::upgrade::Upgraded` (poll-based `hyper::rt::Read`/`Write` on both runtimes). `driver.rs` runs the handler future, writes queued output once per handler wake-up and owns the close lifecycle. `runtime.rs` holds the only runtime-specific code. + +**Tech Stack:** Rust 2024, MSRV 1.95, hyper 1.x, bytes, futures-util, tokio (default runtime), compio (feature `compio`). + +**Spec:** `docs/superpowers/specs/2026-10-18-websocket-engine-design.md` + +Execution note: this plan pins files, interfaces, tests and algorithms. Implementation code is written once, directly into the files, test first. + +## Global Constraints + +- Edition 2024, MSRV 1.95 (`rust-version` in the workspace manifest). +- No WebSocket crate in the dependency tree of any workspace crate or example: `tokio-tungstenite`, `tungstenite`, compio's `ws` feature are removed. +- No new dependencies. Allowed in `tako-rs-core`: bytes, futures-util, http, hyper, pin-project-lite, sha1, base64, url, tokio, compio (feature `compio`). +- Public names: `tako::ws::{WebSocketUpgrade, WebSocket, Message, Utf8Bytes, CloseFrame, CloseCode, Error, ProtocolError}`. `ProtocolError` is `#[non_exhaustive]`. +- Defaults: `read_buffer_size` 4 KiB, `write_buffer_size` 64 KiB, `max_frame_size` 16 MiB, `max_message_size` 64 MiB, small-payload copy threshold 4 KiB, close timeout 5 s. +- Files at most 600 lines, target under 400. Comments: one or two lines, only for a non-obvious why. No banners. +- `cargo +nightly fmt --all -- --check`, `cargo clippy --workspace --no-deps --tests --examples [-- default | tokio-rich | --all-features] -- -D warnings` (pedantic is on workspace-wide). +- Tokio-only code and tests are gated `not(feature = "compio")`, Compio code `feature = "compio"`, matching the existing pattern. +- Commits end with `Claude-Session: https://claude.ai/code/session_01XWh5QR83AkTWuNCuh2Z47g`. Commit only when the user asks. + +## Review Focus + +1. A handler that calls `send` in a tight loop without any yielding await: memory must stay near `write_buffer_size` plus the socket buffer, and `send` must eventually return `Pending` against a peer that does not read. Test in Task 8. +2. A 10-byte frame header announcing 16 MiB, followed by nothing: the server must not allocate the announced size. Test in Task 5. +3. A Ping flood from a peer that never reads: at most one Pong may be pending. Test in Task 6. +4. A handler that moves the `WebSocket` into a spawned task (or splits it) and returns at once: the connection must stay open until the last handle drops. Test in Task 9. +5. A text message split into one-byte fragments, with multi-byte characters cut across fragments: valid input passes, an invalid byte fails in the fragment that carries it. Test in Task 5. + +--- + +### Task 1: Feature wiring, module skeleton, message and error types + +**Files:** +- Modify: `tako-rs-core/Cargo.toml` (feature `ws = ["tako-rs/ws", "hyper/http1", "hyper/server"]`, drop `dep:tokio-tungstenite` from it; keep the dependency entry for GraphQL until Task 11) +- Modify: `tako-rs-core/src/lib.rs` (`#[cfg(feature = "ws")] pub mod ws;`) +- Create: `tako-rs-core/src/ws.rs` (module root: `mod error; mod message;` plus later modules; `pub use` of public types) +- Create: `tako-rs-core/src/ws/message.rs`, `tako-rs-core/src/ws/error.rs` + +**Interfaces (produced):** +```rust +// message.rs +#[derive(Clone, PartialEq, Eq, Hash, Default)] +pub struct Utf8Bytes(Bytes); +impl Utf8Bytes { + pub const fn from_static(s: &'static str) -> Self; + pub fn as_str(&self) -> &str; + pub(crate) unsafe fn from_bytes_unchecked(bytes: Bytes) -> Self; +} +impl Deref, AsRef, AsRef<[u8]>, Display, Debug (as str), + From, From<&'static str>, TryFrom, + From for Bytes, PartialEq, PartialEq<&str> + +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub struct CloseCode(u16); +impl CloseCode { + pub const NORMAL: Self; // 1000 + pub const GOING_AWAY: Self; // 1001 + pub const PROTOCOL_ERROR: Self; // 1002 + pub const UNSUPPORTED: Self; // 1003 + pub const INVALID_PAYLOAD: Self; // 1007 + pub const POLICY: Self; // 1008 + pub const TOO_BIG: Self; // 1009 + pub const EXTENSION: Self; // 1010 + pub const INTERNAL: Self; // 1011 + pub const fn new(code: u16) -> Self; + pub const fn as_u16(self) -> u16; + /// Codes a peer may send: 1000-1003, 1007-1014, 3000-4999. + pub const fn is_allowed(self) -> bool; +} +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CloseFrame { pub code: CloseCode, pub reason: Utf8Bytes } + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Message { Text(Utf8Bytes), Binary(Bytes), Ping(Bytes), Pong(Bytes), Close(Option) } +impl Message { + pub fn text(text: impl Into) -> Self; + pub fn binary(data: impl Into) -> Self; + pub fn is_text(&self) -> bool; is_binary; is_ping; is_pong; is_close; + pub fn into_data(self) -> Bytes; // close: reason bytes, or empty +} +impl From, From<&'static str> (Text); From, From> (Binary) + +// error.rs +#[derive(Debug)] +pub enum Error { Io(std::io::Error), Protocol(ProtocolError), Closed } +impl Display, std::error::Error (source: Io, Protocol), From, From +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum ProtocolError { + ReservedBits, UnmaskedFrame, UnknownOpcode(u8), FragmentedControlFrame, ControlFrameTooLarge, + UnexpectedContinuation, ExpectedContinuation, InvalidUtf8, InvalidCloseFrame, + FrameTooLarge { size: u64, max: usize }, MessageTooLarge { size: usize, max: usize }, + ResetWithoutClosingHandshake, +} +impl ProtocolError { pub(crate) fn close_code(self) -> CloseCode; } // InvalidUtf8 -> 1007, *TooLarge -> 1009, rest -> 1002 +``` + +- [ ] Write tests in `message.rs`/`error.rs`: `from_static`/`TryFrom` round trip and rejection of `[0xff]`; `CloseCode::is_allowed` for 999, 1000, 1004, 1005, 1006, 1007, 1014, 1015, 2999, 3000, 4999, 5000; `Message::into_data` per variant; `close_code` mapping. +- [ ] Run `cargo test -p tako-rs-core --features ws --lib ws::` and watch them fail to compile. +- [ ] Implement, run again, all pass. `cargo clippy -p tako-rs-core --features ws --tests -- -D warnings` clean. + +### Task 2: Masking + +**Files:** Create `tako-rs-core/src/ws/mask.rs`. + +**Interfaces:** `pub(crate) fn unmask(data: &mut [u8], mask: [u8; 4])`. XOR in place; the body walks `chunks_exact_mut(8)` with `u64::from_ne_bytes`/`to_ne_bytes` and a `u64` built from the mask repeated twice, then finishes the remainder byte-wise with `mask[i & 3]`. + +- [ ] Test: for every length 0..=64 and every start offset 0..8 into a 72-byte buffer, compare against a naive `data[i] ^= mask[i % 4]`; applying the mask twice restores the input. +- [ ] Fail, implement, pass. + +### Task 3: Incremental UTF-8 + +**Files:** Create `tako-rs-core/src/ws/utf8.rs`. + +**Interfaces:** +```rust +#[derive(Default)] +pub(crate) struct Utf8Validator { tail: [u8; 4], tail_len: u8 } +impl Utf8Validator { + pub(crate) fn feed(&mut self, chunk: &[u8]) -> Result<(), ProtocolError>; // InvalidUtf8 + pub(crate) fn finish(&self) -> Result<(), ProtocolError>; // a leftover tail is InvalidUtf8 +} +``` +Algorithm: complete a carried tail first (sequence width from its lead byte: `0xC2..=0xDF` two, `0xE0..=0xEF` three, `0xF0..=0xF4` four; anything else is invalid), validating the joined bytes with `std::str::from_utf8`; a still-incomplete tail must be a valid prefix (`Utf8Error::error_len() == None`). Then `from_utf8(rest)`: `Ok` clears the tail, `Err(e)` with `error_len() == None` stores `rest[e.valid_up_to()..]` (at most 3 bytes), any other `Err` is invalid. + +- [ ] Tests: "hΓ©llo wΓΆrld β‚¬π„ž" fed one byte at a time and in every two-way split passes `finish`; `[0xE2, 0x82]` then `finish` fails; `[0xC0, 0x80]` fails on `feed`; `[0xED, 0xA0, 0x80]` (surrogate) fails on `feed` even when split after the first byte; `[0xF4, 0x90]` fails on `feed`. +- [ ] Fail, implement, pass. + +### Task 4: Frame header parse and encode + +**Files:** Create `tako-rs-core/src/ws/frame.rs`. + +**Interfaces:** +```rust +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum OpCode { Continuation, Text, Binary, Close, Ping, Pong } +impl OpCode { pub(crate) fn is_control(self) -> bool; } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Header { pub fin: bool, pub opcode: OpCode, pub mask: [u8; 4], pub len: u64, pub header_len: usize } +/// `Ok(None)` while `buf` holds less than a whole header. +pub(crate) fn parse_header(buf: &[u8], max_frame_size: usize) -> Result, ProtocolError>; +pub(crate) fn encode_header(out: &mut BytesMut, fin: bool, opcode: OpCode, len: usize); +``` +Rules in `parse_header`: RSV bits -> `ReservedBits`; opcodes 3-7 and 11-15 -> `UnknownOpcode(op)`; mask bit clear -> `UnmaskedFrame`; control with `!fin` -> `FragmentedControlFrame`, with len over 125 -> `ControlFrameTooLarge`; len over `max_frame_size` -> `FrameTooLarge`. Lengths: 0..=125 inline, 126 + u16, 127 + u64 (a set top bit is `FrameTooLarge`). `encode_header` writes server frames: no mask, minimal length encoding. + +- [ ] Tests: header lengths 0, 125, 126, 65535, 65536 (encode, then build a masked client frame from it and parse back); every truncated prefix of a valid header returns `Ok(None)`; each rule above returns its error; a header announcing `max_frame_size + 1` fails before any payload exists. +- [ ] Fail, implement, pass. + +### Task 5: Receiver (frame assembly) + +**Files:** Create `tako-rs-core/src/ws/receiver.rs`. + +**Interfaces:** +```rust +pub(crate) struct Limits { pub max_frame_size: usize, pub max_message_size: usize } +pub(crate) enum Frame { Message(Message), Ping(Bytes), Pong(Bytes), Close(Option) } +pub(crate) struct Receiver { /* limits, header: Option
, partial: Option */ } +impl Receiver { + pub(crate) fn new(limits: Limits) -> Self; + /// Next complete frame from `buf`, consuming its bytes. `Ok(None)` when more input is needed. + pub(crate) fn decode(&mut self, buf: &mut BytesMut) -> Result, ProtocolError>; + /// Bytes the pending frame still needs, for sizing the next read (0 when unknown). + pub(crate) fn wanted(&self, buf: &BytesMut) -> usize; +} +``` +Behavior: parse the header once and keep it until the payload is complete. Payloads up to 4 KiB: `Bytes::copy_from_slice` then `unmask`; larger: `buf.split_to(len)`, `unmask` in place, `freeze`. Fragments: `partial` holds the kind (text or binary), a `BytesMut` and a `Utf8Validator`; a continuation without `partial` is `UnexpectedContinuation`, a text or binary frame while `partial` exists is `ExpectedContinuation`; the running size over `max_message_size` is `MessageTooLarge`. Single-frame text uses `std::str::from_utf8`, fragments use `Utf8Validator::feed` per fragment and `finish` on the final one. Close payload: empty is `None`, one byte is `InvalidCloseFrame`, otherwise a code plus a UTF-8 reason (invalid UTF-8 is `InvalidUtf8`). Control frames may arrive between fragments. + +- [ ] Tests: single text, binary, ping, pong, close frames; 16 frames in one buffer decode one by one with nothing left; a frame split at every byte position decodes once complete; fragmented text "hΓ©llo" in one-byte fragments with a Ping in the middle; an invalid byte in fragment 2 of 3 fails at fragment 2; each fragment-order error; `MessageTooLarge` across fragments; **review focus 2:** a header announcing 16 MiB with 10 payload bytes leaves `buf.capacity()` under 128 KiB after `decode` returns `Ok(None)`; a one-byte close payload fails. +- [ ] Fail, implement, pass. + +### Task 6: Connection protocol (close states, control replies, encoding) + +**Files:** Create `tako-rs-core/src/ws/protocol.rs`. + +**Interfaces:** +```rust +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum State { Open, CloseSent, CloseReceived, Closed } +pub(crate) struct Protocol { /* receiver, state, pending_pong: Option, pending_close: Option> */ } +impl Protocol { + pub(crate) fn new(limits: Limits) -> Self; + pub(crate) fn state(&self) -> State; + /// Next message for the user; queues the automatic Pong and Close replies. + pub(crate) fn decode(&mut self, buf: &mut BytesMut) -> Result, ProtocolError>; + /// Encodes a user message; a Close moves the state to CloseSent (or Closed if one was received). + pub(crate) fn encode(&mut self, message: Message, out: &mut BytesMut) -> Result<(), Error>; + /// Appends pending control replies to `out`. + pub(crate) fn drain_control(&mut self, out: &mut BytesMut); + /// Queues Close with the error's code and marks the connection closed. + pub(crate) fn fail(&mut self, error: ProtocolError); + /// The peer closed the transport. + pub(crate) fn eof(&mut self) -> Result<(), ProtocolError>; + /// Queues Close(code) when the connection is still open; used by the driver. + pub(crate) fn close_if_open(&mut self, code: CloseCode); +} +``` +Rules: a Ping while `Open` replaces `pending_pong`; Pong is passed through; Close while `Open` queues a reply with the same code (1002 with reason "Protocol violation" for a code that `is_allowed` rejects) and moves to `CloseReceived`; Close while `CloseSent` moves to `Closed`; frames after `CloseReceived`/`Closed` are ignored. `encode` in `CloseReceived` or `Closed`, or after our Close, returns `Error::Closed` (a Close reply drained by `drain_control` is how we answer). `eof` is `Ok` in `CloseReceived`/`Closed`, otherwise `ResetWithoutClosingHandshake`. After `decode` yields a Close, the next `decode` returns `Ok(None)` and `state()` tells the socket to end the stream. + +- [ ] Tests: ping then pong reply bytes match `encode_header(Pong) + payload`; **review focus 3:** 1,000 pings before any drain leave exactly one Pong (the last payload); peer close echoes the code; peer close with 1005 on the wire replies 1002; our close then peer close reaches `Closed`; `encode` after close is `Error::Closed`; `fail(InvalidUtf8)` drains a 1007 Close; `eof` in `Open` errors and in `CloseReceived` does not. +- [ ] Fail, implement, pass. + +### Task 7: Handshake + +**Files:** +- Create `tako-rs-core/src/ws/handshake.rs` from `tako-rs-streams/src/ws_handshake.rs` (functions `accept` and `rejection`, tests) plus `negotiate_subprotocol(headers, &[&'static str]) -> Option<&'static str>` and `origin_allowed(headers, Option<&[String]>) -> bool` with `normalize_origin` and its tests from `tako-rs-streams/src/ws.rs`. + +**Interfaces:** +```rust +pub(crate) fn accept(request: &Request) -> Result; +pub(crate) fn rejection(status: StatusCode) -> Response; +pub(crate) fn negotiate_subprotocol(headers: &HeaderMap, protocols: &[&'static str]) -> Option<&'static str>; +pub(crate) fn origin_allowed(headers: &HeaderMap, allowed: Option<&[String]>) -> bool; +``` +- [ ] Move the existing tests with the code; they fail until the module is wired, then pass. + +### Task 8: Runtime shims and the socket + +**Files:** Create `tako-rs-core/src/ws/runtime.rs`, `tako-rs-core/src/ws/socket.rs`. + +**Interfaces:** +```rust +// runtime.rs, Tokio half (Compio half in Task 12) +pub(crate) type Shared = std::sync::Arc>; +pub(crate) fn lock(shared: &Shared) -> std::sync::MutexGuard<'_, T>; // poison -> into_inner +#[derive(Default)] +pub(crate) struct Owner(Option); +impl Owner { pub(crate) fn enter(&mut self); pub(crate) fn leave(&mut self); pub(crate) fn is_current(&self) -> bool; } +pub(crate) fn spawn(future: impl Future + Send + 'static); +pub(crate) type Sleep = Pin>; +pub(crate) fn sleep(duration: Duration) -> Sleep; + +// socket.rs +pub(crate) struct Config { pub limits: Limits, pub read_buffer_size: usize, pub write_buffer_size: usize } +pub(crate) struct Core { io: Upgraded, read_buf: BytesMut, write_buf: BytesMut, protocol: Protocol, + config: Config, owner: Owner, handles: usize, driver: Option, finished: bool } +impl Core { + pub(crate) fn new(io: Upgraded, config: Config) -> Self; + pub(crate) fn poll_write_out(&mut self, cx: &mut Context<'_>) -> Poll>; + pub(crate) fn poll_shutdown(&mut self, cx: &mut Context<'_>) -> Poll>; + pub(crate) fn poll_recv(&mut self, cx: &mut Context<'_>) -> Poll>>; +} +pub struct WebSocket { shared: Shared } +impl WebSocket { + pub(crate) fn new(shared: Shared) -> Self; // handles += 1 + pub async fn recv(&mut self) -> Option>; + pub async fn send(&mut self, message: Message) -> Result<(), Error>; + pub async fn flush(&mut self) -> Result<(), Error>; + pub async fn close(&mut self, frame: Option) -> Result<(), Error>; +} +impl Drop for WebSocket // handles -= 1; at 0 wake `driver` +impl Stream for WebSocket; impl Sink for WebSocket // poll_flush = poll_write_out +``` +Reads: `poll_recv` loops `protocol.decode(&mut read_buf)`; on `Ok(None)` it first writes queued output (owner or not), then reserves `max(read_buffer_size, min(wanted, 64 KiB))` of spare capacity and reads through `hyper::rt::ReadBuf::uninit(read_buf.spare_capacity_mut())` and `Upgraded::poll_read`, then `set_len`. Zero bytes calls `protocol.eof()`. A `ProtocolError` calls `protocol.fail`, yields `Some(Err(..))` once, then `None`. A decoded Close yields the message; the following call returns `None`. +Writes: `send` waits on readiness (`write_buf.len() < write_buffer_size`, else `poll_write_out`, `Pending` while still full), then `protocol.encode`; when `owner.is_current()` is false it awaits `poll_write_out`. `poll_write_out` first calls `protocol.drain_control(&mut write_buf)`, then loops `poll_write` and `advance`, then `poll_flush`. + +- [ ] Test helper in `socket.rs` tests: start `hyper::server::conn::http1` over one end of `tokio::io::duplex(1 << 20)` wrapped in a write-counting IO, answer the upgrade with 101, take `Upgraded` from `hyper::upgrade::on`, return it with the client end and the counter. The client writes masked frames by hand. +- [ ] Tests: 16 queued frames read by `recv` one by one with the read count at one; `send` from inside a task created by `runtime::spawn` with `Owner` entered for that task does not write until `flush`; `send` from another task writes at once; `flush` writes at once; **review focus 1:** 10,000 sends of 1 KiB to a client that never reads make `send` return `Pending` within a timeout while `write_buf.capacity()` stays under 256 KiB; a protocol error yields one `Err` then `None`; EOF without close yields the reset error. +- [ ] Fail, implement, pass. + +### Task 9: Driver + +**Files:** Create `tako-rs-core/src/ws/driver.rs`. + +**Interfaces:** +```rust +pub(crate) struct Driver { /* shared, handler: Option>>>>, lifetime: Option, phase */ } +impl> Driver { pub(crate) fn new(shared: Shared, handler: F, max_lifetime: Option) -> Self; } +impl> Future for Driver { type Output = (); } +``` +Phases: `Running` polls the handler between `owner.enter()` and `owner.leave()`, then `poll_write_out` (its `Pending` registers write interest). Handler `Ready(Ok)`: `Detached` while `handles > 0` (store the waker in `core.driver`), then `close_if_open(NORMAL)`. Handler panic: `close_if_open(INTERNAL)`. Lifetime elapsed: drop the handler, `close_if_open(GOING_AWAY)`, mark `finished` so remaining handles get `Error::Closed`. `Closing` writes out and shuts down, bounded by a 5 s `Sleep`. + +- [ ] Tests (time paused where timers matter): handler that returns sends Close(1000) and the client sees EOF; handler that panics sends Close(1011); `max_lifetime` 1 s sends Close(1001); the close bound ends the driver after 5 s against a client that never reads; a handler that sends and then awaits a 10 s sleep has its frame on the wire before the sleep ends; **review focus 4:** a handler that moves the socket into `runtime::spawn` and returns keeps the connection open, and the spawned task's echo still works until it drops the socket, after which Close(1000) arrives. +- [ ] Fail, implement, pass. + +### Task 10: `WebSocketUpgrade`, re-export, Tokio integration + +**Files:** +- Create `tako-rs-core/src/ws/upgrade.rs`. +- Modify `tako-rs/src/lib.rs`: `#[cfg(feature = "ws")] pub use tako_rs_core::ws;`, drop the `ws` and `ws_compio` re-exports from `tako_rs_streams`. +- Modify `tako-rs/Cargo.toml`: `ws = ["tako-rs-core/ws"]`, delete `compio-ws`. +- Delete `tako-rs-streams/src/ws.rs`, `tako-rs-streams/src/ws_handshake.rs`, their `mod` lines in `tako-rs-streams/src/lib.rs`, the streams features `ws`/`compio-ws` and the deps only they used (`tokio-tungstenite`, `sha1`, `base64` if unused elsewhere, `url` if unused elsewhere). +- Modify tests: `tako-rs/tests/websocket_handshake.rs`, `tako-rs/tests/websocket_connection_lease.rs` (new types, `recv`/`send`). +- Create `tako-rs/tests/websocket_echo.rs`. + +**Interfaces:** +```rust +pub struct WebSocketUpgrade { request, handler, protocols: Vec<&'static str>, allowed_origins: Option>, + config: Config, upgrade_timeout: Option, max_lifetime: Option, _fut: PhantomData Fut> } +impl WebSocketUpgrade { new, protocols, allowed_origins, max_frame_size, max_message_size, + read_buffer_size, write_buffer_size, upgrade_timeout, max_lifetime } +impl Responder for WebSocketUpgrade +where H: FnOnce(WebSocket) -> Fut + Send + 'static, Fut: Future + Send + 'static +``` +`into_response`: `handshake::accept`, origin check (403 "origin not allowed"), subprotocol, 101 response; spawn: await `OnUpgrade` (with `upgrade_timeout`), build `Core` and `WebSocket`, run `Driver::new(shared, handler(ws), max_lifetime)`. + +- [ ] Port the handshake and lease tests (they fail to compile against the new names first). +- [ ] `websocket_echo.rs` over real TCP on `Server::spawn_http` and on `spawn_per_thread`: text, binary, ping (expects Pong), 16 pipelined frames echoed in order, client Close answered with the same code and then EOF. +- [ ] `cargo test --workspace --features "ws,per-thread"` passes. + +### Task 11: GraphQL subscriptions + +**Files:** Modify `tako-rs-core/src/graphql/subscription.rs`, `tako-rs-core/src/graphql/websocket.rs`, `tako-rs-core/Cargo.toml` (remove `tokio-tungstenite` entirely). + +- `subscription.rs`: replace the inline SHA-1/base64 accept with `ws::handshake::accept`, keep the graphql-ws subprotocol selection, run the session through `Core`, `WebSocket` and `Driver`; outgoing messages go through `forward` into the sink half so batching applies. +- `websocket.rs`: bounds become `SinkT: Sink`, `StreamT: Stream>`. +- [ ] Test (`tako-rs/tests/graphql_ws.rs`, features `ws,async-graphql`): `connection_init` gets `connection_ack` over a real connection. + +### Task 12: Compio + +**Files:** Modify `tako-rs-core/src/ws/runtime.rs` (Compio half), `tako-rs-core/src/ws/upgrade.rs` (Compio `Responder` bounds without `Send`); delete `tako-rs-streams/src/ws_compio.rs`; modify `examples/websocket-compio`; create `examples/autobahn-echo-compio`. + +Compio half: `Shared = Rc>`, `lock` is `borrow_mut`, `Owner(bool)` set by `enter`/`leave`, `spawn` is `compio::runtime::spawn(..).detach()`, `Sleep` is `Pin>>` from `compio::time::sleep`. + +- [ ] Test (`tako-rs/tests/websocket_echo_compio.rs`, features `compio,ws`): the echo and close cases of Task 10 on `CompioServer`. + +### Task 13: Examples, CI, fuzz, dependency cleanup + +**Files:** examples `websocket`, `websocket-http2` renamed to `websocket-tls` (also in the workspace `exclude` list), `autobahn-echo`, `graphql-generic-ws`, `websocket-compio`, `autobahn-echo-compio`; `ci/autobahn/run.sh` (example name as an argument, default `autobahn-echo`); `.github/workflows/ci.yml` (Compio Autobahn job, example list); workspace `Cargo.toml` (remove `tokio-tungstenite`); `fuzz/fuzz_targets/ws_frame.rs` and `fuzz/Cargo.toml`. + +- [ ] `rg -n "tungstenite|TakoWs|ws_compio|compio-ws" --glob '!target' --glob '!*.lock'` returns nothing outside the changelog and migration text. +- [ ] Every example builds with `cargo check --manifest-path examples//Cargo.toml`. +- [ ] `cargo +nightly fuzz build ws_frame` builds. + +### Task 14: Docs and migration + +**Files:** `website/content/docs/transports/websocket.mdx`, `streams.mdx`, `transports/index.mdx`, `tutorials/realtime-chat.mdx`, `getting-started/coming-from-axum.mdx`, `reference/migration.mdx` (2.5 section: before and after, `TakoWs` to `WebSocketUpgrade`, imports, `send` semantics, `compio-ws` to `compio` + `ws`). + +- [ ] Every code block in those pages compiles against the new API (copy into a scratch example and `cargo check`). + +### Task 15: Verification against 2.4.2 + +- [ ] Autobahn (container harness): Tokio and Compio 247/247, no case worse than the 2.4.2 report. +- [ ] Linux VM benchmark, 2.4.2 against 2.5.0, three rounds: pipelined at least 5x, echo and short-lived not slower, RSS not higher. +- [ ] Full gates: nightly fmt, clippy (default, tokio-rich, all-features, Linux target with vsock), `cargo test --workspace` (default, rich, http3, all-features), examples, `cargo deny --all-features check`. diff --git a/docs/superpowers/specs/2026-10-18-websocket-engine-design.md b/docs/superpowers/specs/2026-10-18-websocket-engine-design.md new file mode 100644 index 00000000..9fc0d114 --- /dev/null +++ b/docs/superpowers/specs/2026-10-18-websocket-engine-design.md @@ -0,0 +1,254 @@ +# tako 2.5.0 WebSocket engine: design + +Status: approved section by section in conversation on 2026-10-17/18. + +## Goal + +Replace `tokio-tungstenite`, `compio-ws` and `tungstenite` with a tako-owned WebSocket +implementation shared by `tako::ws` (Tokio and Compio) and the GraphQL subscription +responder. + +- Performance: batch writes per handler wake-up, read into uninitialized buffer capacity, + drop the `AllowStd` and waker-proxy layers. Target: the HttpArena WebSocket board in + standard mode, where the handler may not batch by itself. +- Clean API and no legacy: 2.5.0 is a breaking release for WebSocket users (accepted + explicitly, despite the "2.5.0 is additive" note in the 2.5.0 TODO). No shims, no + deprecated aliases; everything the new engine makes unnecessary is deleted. + +## Non-goals + +- permessage-deflate (tungstenite did not support it either). +- WebSocket over HTTP/2 (RFC 8441). +- The client role. +- Downcasting `hyper::upgrade::Upgraded` to the concrete socket. Possible later + optimization if measurements justify it. +- UTF-8 fail-fast inside a single frame. Autobahn 6.4.3 and 6.4.4 stay NON-STRICT, as today. + +## Public API + +Everything lives in `tako::ws` (re-exported from `tako_rs_core::ws`), with the same names on +both runtimes. The `ws` feature enables it; with `compio` the Compio implementation is used. + +```rust +pub struct WebSocketUpgrade; // Responder +impl WebSocketUpgrade +where + H: FnOnce(WebSocket) -> Fut + 'static, // + Send on Tokio + Fut: Future + 'static, // + Send on Tokio +{ + pub fn new(request: Request, handler: H) -> Self; + pub fn protocols(self, list: I) -> Self; + pub fn allowed_origins(self, origins: I) -> Self; + pub fn max_frame_size(self, n: usize) -> Self; // default 16 MiB + pub fn max_message_size(self, n: usize) -> Self; // default 64 MiB + pub fn read_buffer_size(self, n: usize) -> Self; // default 4 KiB + pub fn write_buffer_size(self, n: usize) -> Self; // default 64 KiB, see Writes + pub fn upgrade_timeout(self, d: Duration) -> Self; + pub fn max_lifetime(self, d: Duration) -> Self; +} + +pub struct WebSocket; // Send on Tokio, !Send on Compio +impl WebSocket { + pub async fn recv(&mut self) -> Option>; // None after a clean close + pub async fn send(&mut self, message: Message) -> Result<(), Error>; // queues, see Writes + pub async fn flush(&mut self) -> Result<(), Error>; // writes everything now + pub async fn close(&mut self, frame: Option) -> Result<(), Error>; +} +impl Stream for WebSocket { type Item = Result; } +impl Sink for WebSocket { type Error = Error; } // poll_flush really writes + +pub enum Message { + Text(Utf8Bytes), + Binary(Bytes), + Ping(Bytes), + Pong(Bytes), + Close(Option), +} +pub struct Utf8Bytes; // Bytes known to be UTF-8: as_str, Deref, From, + // From<&'static str>, TryFrom, Display +pub struct CloseFrame { pub code: CloseCode, pub reason: Utf8Bytes } +pub struct CloseCode(u16); // associated consts NORMAL, GOING_AWAY, PROTOCOL_ERROR, + // UNSUPPORTED, INVALID_PAYLOAD, POLICY, TOO_BIG, INTERNAL, ... + +pub enum Error { Io(io::Error), Protocol(ProtocolError), Closed } +#[non_exhaustive] +pub enum ProtocolError { /* reserved bits, unmasked frame, unknown opcode, fragmented or + oversized control frame, unexpected or missing continuation, invalid UTF-8, invalid close + frame, frame or message too large, reset without closing handshake, send after close */ } +``` + +Inherent `recv`/`send`/`flush`/`close` need no trait import. `Stream` and `Sink` stay for +generic code: `split`, `forward`, `select!` over streams, combinators and the GraphQL driver. +Rust method resolution picks the inherent `send` even when `SinkExt` is imported. + +## Architecture + +`tako-rs-core/src/ws/`, compiled with the `ws` feature. Every file stays under 400 lines. + +| File | Responsibility | +| --- | --- | +| `mod.rs` | Module routing and re-exports | +| `message.rs` | `Message`, `Utf8Bytes`, `CloseFrame`, `CloseCode` | +| `error.rs` | `Error`, `ProtocolError` | +| `frame.rs` | Frame header parse and encode, no IO | +| `mask.rs` | Unmasking in `u64` words | +| `utf8.rs` | Incremental UTF-8 validation across fragments | +| `protocol.rs` | Receive state machine: message assembly, control frames, close states, limits | +| `handshake.rs` | RFC 6455 accept and rejection, subprotocol and origin checks | +| `socket.rs` | `WebSocket` over `hyper::upgrade::Upgraded` | +| `driver.rs` | Future that runs the handler and owns batching and shutdown | +| `runtime.rs` | Runtime shims: shared core type, owner check, spawn, sleep | +| `upgrade.rs` | `WebSocketUpgrade` responder and builder | + +`hyper::upgrade::Upgraded` implements the poll-based `hyper::rt::Read` and `hyper::rt::Write` +on both runtimes (the Compio server runs hyper through `cyper-core`), so `socket.rs` and +`driver.rs` are runtime-independent. Only `runtime.rs` differs: + +| | Tokio | Compio | +| --- | --- | --- | +| Shared core | `Arc>` (std) | `Rc>` | +| Owner check | `tokio::task::try_id() == owner` | the driver's "polling the handler" flag | +| Spawn | `tokio::spawn` | `compio::runtime::spawn(..).detach()` | +| Sleep | `tokio::time::sleep` | `compio::time::sleep` | + +The GraphQL subscription responder (`graphql/subscription.rs`) uses `handshake`, `socket` and +`driver` directly. Its duplicated SHA-1 and base64 handshake is deleted. The generic +`GraphQLWebSocket` driver switches its bounds to `tako::ws::{Message, Error}`. + +## Reads + +- `read_buf: BytesMut`, initial capacity `read_buffer_size` (4 KiB). Reads go through + `hyper::rt::ReadBuf::uninit` into the spare capacity, so nothing is zeroed. +- A frame header announcing a larger payload does not reserve the whole frame up front: the + buffer grows with the bytes that actually arrive (at most 64 KiB past what is buffered per + read), so a 10-byte header cannot make the server allocate `max_frame_size`. +- `recv`/`poll_next` decode every complete frame already buffered before touching the socket, + so 16 pipelined frames cost one read. +- Payloads of up to 4 KiB are copied out and unmasked in one pass, so a stored small message + never pins the read buffer and the buffer stays reusable. Larger payloads are split off + without copying and unmasked in place. + +Protocol rules follow RFC 6455 and tungstenite's observable behavior: + +| Case | Behavior | +| --- | --- | +| RSV bit set, unmasked client frame, unknown opcode | protocol error | +| Control frame | FIN required, payload at most 125 bytes | +| Continuation without a message, new message inside a fragmented one | protocol error | +| `max_frame_size` | checked on the header, before the payload is read | +| `max_message_size` | checked while fragments accumulate | +| Text | single frame validated at once; fragments validated incrementally, carrying a truncated trailing sequence | +| Ping | queue one Pong (a newer Ping replaces a pending one) and yield `Message::Ping` | +| Pong | yield `Message::Pong` | +| Close | reply with the same code (1002 for a code that may not appear on the wire), yield `Message::Close`; the next `recv` returns `None`. A reply to our own Close completes the handshake | +| EOF | `None` after a closing handshake, `ProtocolError::ResetWithoutClosingHandshake` otherwise | + +A protocol error queues a Close frame with its code (1002 protocol, 1007 invalid payload, +1009 too big) before the connection ends. If Autobahn reports WRONG_CODE for any case, fall +back to tungstenite's behavior of ending without a Close frame. + +## Writes and batching + +`send` encodes the frame (unmasked, server role) into `write_buf` and returns. Frames go out +in send order. Automatic Pong and Close replies are appended to the same buffer in arrival +order. + +`write_buf` reaches the socket at these points: + +1. After every poll of the handler future, the driver writes everything in one call. +2. `recv`, before returning `Pending` because no input is buffered, writes what is queued. +3. When `write_buf` grows past `write_buffer_size` (64 KiB), `send` writes through. +4. `flush` and `close` write immediately and wait until the buffer is empty. + +Queuing applies only inside the driver's own task (owner check in `runtime.rs`). Used from +another task, `send` writes through. + +The `Sink` impl keeps the `Sink` contract: `poll_flush` always writes to the socket. Only the +inherent `send` defers. Generic `Sink` code therefore gets one real flush per call; `forward` +still batches because it flushes only when its input is pending. + +Backpressure: past `write_buffer_size` with the socket full, `send` and `poll_ready` return +`Pending`. + +## Driver lifecycle + +The driver future owns the handler future and an `Rc`/`Arc` handle to the shared core. + +| Event | Driver action | +| --- | --- | +| Handler returns, no `WebSocket` handle left | queue Close(1000) if the connection is open | +| Handler returns, a handle still lives (moved into another task, split halves) | wait until the last handle drops (its `Drop` wakes the driver), then as above | +| Handler panics (caught with `catch_unwind`) | queue Close(1011) | +| `max_lifetime` expires | drop the handler, queue Close(1001); remaining handles get `Error::Closed` | +| Protocol error during a read | Close with the error's code is already queued | + +After any of these, the driver writes out `write_buf` for at most 5 seconds (a peer that does +not read cannot hold the task), calls `poll_shutdown` and finishes. Data queued by `send` is +never silently dropped while the socket accepts writes. + +## Defaults + +| Setting | Default | +| --- | --- | +| `read_buffer_size` | 4 KiB | +| `write_buffer_size` | 64 KiB | +| `max_frame_size` | 16 MiB | +| `max_message_size` | 64 MiB | +| Copy threshold for small payloads | 4 KiB | +| Close timeout | 5 s | + +## Deletions + +- `tako-rs-streams/src/ws.rs`, `ws_compio.rs`, `ws_handshake.rs`, and the streams features + `ws` and `compio-ws`. +- The `tako-rs` feature `compio-ws` (`ws` together with `compio` selects the Compio + implementation) and the `tako::ws_compio` module. +- `TakoWs`, `TakoWsCompio`, `CompioWebSocket`, `UpgradedStream`, `WsKeepAlive` and + `TakoWs::keep_alive` (closes TODO item M03 for WebSocket). +- Dependencies: `tokio-tungstenite` (workspace, core, streams, examples) and compio's `ws` + feature. +- `examples/websocket-http2` is renamed to `examples/websocket-tls`: it serves wss over + HTTP/1.1, and tako does not implement RFC 8441. + +## Testing + +- Unit, no IO: frame header lengths (7, 16, 64 bit), masked and unmasked frames, truncated + headers, `max_frame_size` on the header, RSV and opcode rules, encode round trip; mask + against a byte-wise reference for lengths 0 to 64 and every alignment; UTF-8 splits at every + boundary, invalid sequences found in the fragment that makes them invalid, truncated end; + protocol fragmentation, control frames between fragments, Ping/Pong replacement, both close + directions, invalid close code (1002), one-byte close payload, invalid close reason (1007), + message size across fragments, the queued Close code for each protocol error. +- Socket and driver, in-memory IO with a write counter: 16 pipelined frames produce one write; + `flush` writes at once; `send` from another task writes through; `write_buffer_size` forces + a write; output queued before a timer await is written before the timer fires; handler end, + panic and `max_lifetime` send 1000, 1011 and 1001; the 5 s close bound with a peer that does + not read (`tokio::time::pause`); `poll_ready` returns `Pending` when the socket is full. +- Integration (`tako-rs/tests`): handshake and lease tests on the new types; TCP echo on the + per-thread and multi-thread servers (text, binary, ping, close, pipelined); a GraphQL + subscription smoke test (`connection_init` to `connection_ack`); the same under Compio. +- Autobahn: `ci/autobahn` for Tokio and a new `examples/autobahn-echo-compio` for Compio, + `run.sh` parameterized by example and a Compio CI job. Pass criterion: 247/247 with no case + behaving worse than 2.4.2. +- Fuzz: a `ws_frame` target in `fuzz/` feeding arbitrary bytes to the protocol layer: no + panics, memory bounded by the limits. +- Benchmark (Linux VM harness, 2.4.2 against 2.5.0): pipelined at 512 and 4,096 connections at + least 5 times faster; echo at 512, 4,096 and 16,384 and short-lived at 512 and 4,096 not + slower; resident memory not higher. + +## Migration and docs + +- 2.5.0 release notes get a "Breaking: WebSocket" section with a before and after example. +- `website/content/docs/reference/migration.mdx` gets a 2.5 section. `transports/websocket.mdx`, + `streams.mdx`, `transports/index.mdx`, `tutorials/realtime-chat.mdx` and + `getting-started/coming-from-axum.mdx` move to the new API. +- Examples `websocket`, `websocket-compio`, `websocket-tls`, `autobahn-echo` and + `graphql-generic-ws` use the new API with no tungstenite imports. + +## Phases + +1. Core types, IO-free layers, Tokio socket and driver, `WebSocketUpgrade`, GraphQL. +2. Compio runtime shims and tests. +3. Examples, docs, CI (Autobahn for both runtimes, fuzz target), deletions. +4. Benchmarks and Autobahn against 2.4.2. +5. Release. diff --git a/examples/autobahn-echo-compio/Cargo.lock b/examples/autobahn-echo-compio/Cargo.lock new file mode 100644 index 00000000..8a76e44f --- /dev/null +++ b/examples/autobahn-echo-compio/Cargo.lock @@ -0,0 +1,2676 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arc-swap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "380962b3d93b99642e50b9febf72c071e97e8b32d86eae9cf4ef582bb960ae6e" +dependencies = [ + "rustversion", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "async-tungstenite" +version = "0.34.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8447f02eaa65412035e2d3eeaa3fc82bbb8d7137c84c5976b4af685136012ee9" +dependencies = [ + "atomic-waker", + "futures-core", + "futures-io", + "futures-task", + "futures-util", + "log", + "pin-project-lite", + "tungstenite", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autobahn-echo-compio" +version = "0.0.0" +dependencies = [ + "compio", + "tako-rs", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "compio" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c19d81c636b0f47aa50041d25b02d9d8289a3319727a8051209f71bea10e89d6" +dependencies = [ + "compio-buf", + "compio-driver", + "compio-fs", + "compio-io", + "compio-log", + "compio-macros", + "compio-net", + "compio-runtime", + "compio-tls", + "compio-ws", +] + +[[package]] +name = "compio-buf" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b51a2c35873865376ed4cdb6cfeb602b6cf569815f017ddd7bd8f86ad49b34c7" +dependencies = [ + "arrayvec", + "bytes", + "libc", +] + +[[package]] +name = "compio-driver" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "293e8086a35f52b5002402937cf4e69b5e414917e511c29c5e7ba2cebe6ef7b3" +dependencies = [ + "bitflags", + "cfg_aliases", + "compio-buf", + "compio-log", + "compio-send-wrapper", + "crossbeam-queue", + "flume", + "futures-util", + "io-uring", + "libc", + "linux-raw-sys", + "mod_use", + "once_cell", + "pastey", + "polling", + "rustix", + "smallvec", + "socket2", + "synchrony", + "thin-cell", + "windows-sys 0.61.2", +] + +[[package]] +name = "compio-executor" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94961c5908b02bbb082e046968b8ee3d18995845a5c261376f0b0b5c95c87c2b" +dependencies = [ + "compio-log", + "compio-send-wrapper", + "crossbeam-queue", + "loom", + "slotmap", +] + +[[package]] +name = "compio-fs" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a374c5a03bdf7ecb42894a49c3a40c22f743f8d7dc39cc21a5dd3258d8219c7" +dependencies = [ + "cfg_aliases", + "compio-buf", + "compio-driver", + "compio-io", + "compio-runtime", + "futures-util", + "libc", + "pin-project-lite", + "rustix", + "widestring", + "windows-sys 0.61.2", +] + +[[package]] +name = "compio-io" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3a354e085c4046dd8d2d9d514cf8c15c615eb8adf6c5f41dbf7899420457e9b" +dependencies = [ + "compio-buf", + "futures-util", + "libc", + "pastey", + "pin-project-lite", + "rustix", + "synchrony", + "windows-sys 0.61.2", +] + +[[package]] +name = "compio-log" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd867f29de59e4eff577dfeee3744cb0a264249f3faaf11945e8be79a3c3c62" +dependencies = [ + "tracing", +] + +[[package]] +name = "compio-macros" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1819c20de8e80fbcfc81ca891d206bf64b67db207bb7224d36a54b955bec75f3" +dependencies = [ + "darling", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "compio-net" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31737ec57865d2b46284def673cd21fe478c592ca05c1e040e10d457aac6c2a8" +dependencies = [ + "compio-buf", + "compio-driver", + "compio-io", + "compio-runtime", + "either", + "futures-util", + "libc", + "once_cell", + "pin-project-lite", + "socket2", + "synchrony", + "widestring", + "windows-sys 0.61.2", +] + +[[package]] +name = "compio-runtime" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20406f0a4e69cdd0c0a58c4a7cf51bcc965a543fb5da2fe931e970354e291ded" +dependencies = [ + "compio-buf", + "compio-driver", + "compio-executor", + "compio-io", + "compio-log", + "compio-send-wrapper", + "futures-util", + "libc", + "pin-project-lite", + "scoped-tls", + "synchrony", + "windows-sys 0.61.2", +] + +[[package]] +name = "compio-send-wrapper" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4a63ae089032966c7eb5411267dd14524fb249e6833818b2d6d9d9548bf504" +dependencies = [ + "cfg-if", + "loom", +] + +[[package]] +name = "compio-tls" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0177c2684878d0712ccfcf22113a198f5ddf74f0bf323801488dbb47f801a471" +dependencies = [ + "compio-buf", + "compio-io", + "futures-rustls", + "futures-util", + "rustls", +] + +[[package]] +name = "compio-ws" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0a9e5cbacce3ff2594cfd7321fd946d5f7afcf2dbc7537f6de7b42d798efb3a" +dependencies = [ + "async-tungstenite", + "compio-buf", + "compio-io", + "compio-log", + "compio-net", + "compio-tls", + "futures-util", + "pin-project-lite", + "tungstenite", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "aes-gcm", + "base64", + "hmac 0.12.1", + "rand 0.8.8", + "sha2 0.10.9", + "subtle", + "time", + "version_check", +] + +[[package]] +name = "core_affinity" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a034b3a7b624016c6e13f5df875747cc25f884156aad2abd12b6c46797971342" +dependencies = [ + "libc", + "num_cpus", + "winapi", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "ctutils" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" +dependencies = [ + "cmov", +] + +[[package]] +name = "cyper-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c8847069e286c64987119637d5f08cdb71e12e85be0294fed649dc8007d32e" +dependencies = [ + "compio", + "futures-util", + "hyper", + "send_wrapper", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.9", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "envy" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f47e0157f2cb54f5ae1bd371b30a2ae4311e1c028f575cd4e81de7353215965" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "spin", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-rustls" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f2f12607f92c69b12ed746fabf9ca4f5c482cba46679c1a75b874ed7c26adb" +dependencies = [ + "futures-io", + "rustls", + "rustls-pki-types", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generator" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1be81b31c9ce4caf7f3178eeb90662bb1864d3ad363ec6bdada3201adff19c37" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac 0.13.0", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "io-uring" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3bd0ecfbb87805f538bb7b32e5239ca0763890c623e349860ecba69469f2bb" +dependencies = [ + "bitflags", + "cfg-if", + "libc", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "linkme" +version = "0.3.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3045e122bd98aef8ec3ad58ce84f0791f64e70163d1a02710af4aa11a4d54cc5" +dependencies = [ + "linkme-impl", +] + +[[package]] +name = "linkme-impl" +version = "0.3.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77060ebe535362c3da75682cd17b0431017b6e7c5661e714fc69a7ad017d1301" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "local-event" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23ab4b951e96ffb2da6e25cec3d038d0e2930f4c406d1ec194b5120ae41ec6d3" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "pin-utils", + "scoped-tls", + "serde", + "serde_json", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8863b587001c1b9a8a4e36008cebc6b3612cb1226fe2de94858e06092687b608" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mod_use" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21909324aa58f5c284d91cac514c6d081210901dc372d7c8ea6a9d7e0406097a" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13bee6c73da26345c729282832b60b0363cf3dd9f4bfd81d8551b7a1c889a113" + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "log", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "saa" +version = "5.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd85992a680de788217d9c70b74aa7685b108cf1aba02f728fb27f1b27199ad1" + +[[package]] +name = "scc" +version = "3.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d5f8ca67c4f6208caf824037a6a9df0211b4e0937fccc4f543441f294d04511" +dependencies = [ + "saa", + "sdd", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sdd" +version = "4.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31877eee03686b905845993b5d9809a220c9dd5a10e9d6021d7ca67384c9070" +dependencies = [ + "saa", +] + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" +dependencies = [ + "futures-core", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_html_form" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2f2d7ff8a2140333718bb329f5c40fc5f0865b84c426183ce14c97d2ab8154f" +dependencies = [ + "form_urlencoded", + "indexmap", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synchrony" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d6d5fbc4583cf3e5eee953506f13853a42ee6b4a21a983dffffb10c765cb80" +dependencies = [ + "event-listener", + "futures-util", + "local-event", + "loom", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tako-rs" +version = "2.4.2" +dependencies = [ + "linkme", + "tako-rs-core", + "tako-rs-extractors", + "tako-rs-macros", + "tako-rs-plugins", + "tako-rs-server", + "tako-rs-server-pt", + "tako-rs-streams", +] + +[[package]] +name = "tako-rs-core" +version = "2.4.2" +dependencies = [ + "anyhow", + "arc-swap", + "async-trait", + "base64", + "bytes", + "compio", + "cookie", + "cyper-core", + "envy", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "linkme", + "matchit", + "mime", + "mime_guess", + "parking_lot", + "pin-project-lite", + "scc", + "send_wrapper", + "serde", + "serde_bytes", + "serde_json", + "serde_urlencoded", + "sha1", + "sha2 0.11.1", + "smallvec", + "thread_local", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "url", + "urlencoding", +] + +[[package]] +name = "tako-rs-extractors" +version = "2.4.2" +dependencies = [ + "anyhow", + "async-trait", + "base64", + "bytes", + "cookie", + "futures-util", + "hkdf", + "http", + "http-body", + "http-body-util", + "ipnet", + "mime", + "mime_guess", + "parking_lot", + "serde", + "serde_bytes", + "serde_html_form", + "serde_json", + "serde_urlencoded", + "sha1", + "sha2 0.11.1", + "smallvec", + "tako-rs-core", + "tokio", + "tracing", + "url", + "urlencoding", +] + +[[package]] +name = "tako-rs-macros" +version = "2.4.2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tako-rs-plugins" +version = "2.4.2" +dependencies = [ + "anyhow", + "async-trait", + "base64", + "bytes", + "compio", + "cookie", + "futures-util", + "http", + "http-body", + "http-body-util", + "httpdate", + "mime", + "parking_lot", + "pin-project-lite", + "scc", + "send_wrapper", + "serde", + "serde_json", + "sha1", + "sha2 0.11.1", + "smallvec", + "subtle", + "tako-rs-core", + "tako-rs-extractors", + "tokio", + "tokio-stream", + "tracing", + "url", + "urlencoding", + "uuid", +] + +[[package]] +name = "tako-rs-server" +version = "2.4.2" +dependencies = [ + "anyhow", + "arc-swap", + "bytes", + "compio", + "cyper-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "pin-project-lite", + "send_wrapper", + "tako-rs-core", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", +] + +[[package]] +name = "tako-rs-server-pt" +version = "2.4.2" +dependencies = [ + "compio", + "core_affinity", + "cyper-core", + "futures-util", + "hyper", + "hyper-util", + "send_wrapper", + "socket2", + "tako-rs-core", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "tako-rs-streams" +version = "2.4.2" +dependencies = [ + "anyhow", + "bytes", + "compio", + "futures-util", + "http", + "http-body", + "http-body-util", + "httpdate", + "hyper", + "hyper-util", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "send_wrapper", + "tako-rs-core", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "thin-cell" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d3c47fea6e344ef1ac01be266c27945ec30ae8049c3292c897f0878de5c784" +dependencies = [ + "synchrony", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-io", + "futures-sink", + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/examples/autobahn-echo-compio/Cargo.toml b/examples/autobahn-echo-compio/Cargo.toml new file mode 100644 index 00000000..0142c300 --- /dev/null +++ b/examples/autobahn-echo-compio/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "autobahn-echo-compio" +version = "0.0.0" +publish = false +edition = "2024" + +[dependencies] +compio = { workspace = true } +tako-rs = { workspace = true } + + +[workspace] + +[workspace.dependencies] +compio = { version = "0.19.2", features = ["macros"] } +tako-rs = { path = "../../tako-rs", features = ["compio", "ws"] } diff --git a/examples/autobahn-echo-compio/src/main.rs b/examples/autobahn-echo-compio/src/main.rs new file mode 100644 index 00000000..a4bc28a8 --- /dev/null +++ b/examples/autobahn-echo-compio/src/main.rs @@ -0,0 +1,35 @@ +//! Echo server for the Autobahn `fuzzingclient` suite on the compio runtime. +//! +//! Listens on `127.0.0.1:9001` and echoes every text and binary message. + +use tako::Method; +use tako::responder::Responder; +use tako::types::Request; +use tako::ws::WebSocketUpgrade; + +async fn echo(req: Request) -> impl Responder { + WebSocketUpgrade::new(req, |mut ws| async move { + while let Some(Ok(message)) = ws.recv().await { + if (message.is_text() || message.is_binary()) && ws.send(message).await.is_err() { + break; + } + } + }) +} + +#[compio::main] +async fn main() { + let listener = compio::net::TcpListener::bind("127.0.0.1:9001") + .await + .expect("bind 127.0.0.1:9001"); + let mut router = tako::router::Router::new(); + router.route(Method::GET, "/", echo); + + println!("autobahn-echo-compio listening on ws://127.0.0.1:9001"); + tako::CompioServer::builder() + .build() + .spawn_http(listener, router) + .result() + .await + .expect("HTTP server failed"); +} diff --git a/examples/autobahn-echo/Cargo.lock b/examples/autobahn-echo/Cargo.lock index a700b7f2..fcb34852 100644 --- a/examples/autobahn-echo/Cargo.lock +++ b/examples/autobahn-echo/Cargo.lock @@ -73,10 +73,8 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" name = "autobahn-echo" version = "0.0.0" dependencies = [ - "futures-util", "tako-rs", "tokio", - "tokio-tungstenite", ] [[package]] @@ -127,17 +125,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "chacha20" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.1", - "rand_core 0.10.1", -] - [[package]] name = "cipher" version = "0.4.4" @@ -169,7 +156,7 @@ dependencies = [ "aes-gcm", "base64", "hmac 0.12.1", - "rand 0.8.6", + "rand", "sha2 0.10.9", "subtle", "time", @@ -201,7 +188,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", - "rand_core 0.6.4", + "rand_core", "typenum", ] @@ -232,12 +219,6 @@ dependencies = [ "cmov", ] -[[package]] -name = "data-encoding" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" - [[package]] name = "deranged" version = "0.5.8" @@ -409,7 +390,6 @@ dependencies = [ "cfg-if", "libc", "r-efi", - "rand_core 0.10.1", "wasip2", "wasip3", ] @@ -937,18 +917,7 @@ checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", "rand_chacha", - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" -dependencies = [ - "chacha20", - "getrandom 0.4.2", - "rand_core 0.10.1", + "rand_core", ] [[package]] @@ -958,7 +927,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core 0.6.4", + "rand_core", ] [[package]] @@ -970,12 +939,6 @@ dependencies = [ "getrandom 0.2.17", ] -[[package]] -name = "rand_core" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" - [[package]] name = "redox_syscall" version = "0.5.18" @@ -1123,17 +1086,6 @@ dependencies = [ "digest 0.10.7", ] -[[package]] -name = "sha1" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.1", - "digest 0.11.3", -] - [[package]] name = "sha2" version = "0.10.9" @@ -1263,13 +1215,12 @@ dependencies = [ "serde_bytes", "serde_json", "serde_urlencoded", - "sha1 0.10.6", + "sha1", "sha2 0.11.0", "smallvec", "thread_local", "tokio", "tokio-stream", - "tokio-tungstenite", "tokio-util", "tracing", "url", @@ -1299,7 +1250,7 @@ dependencies = [ "serde_html_form", "serde_json", "serde_urlencoded", - "sha1 0.10.6", + "sha1", "sha2 0.11.0", "smallvec", "tako-rs-core", @@ -1338,7 +1289,7 @@ dependencies = [ "scc", "serde", "serde_json", - "sha1 0.10.6", + "sha1", "sha2 0.11.0", "smallvec", "subtle", @@ -1378,7 +1329,6 @@ name = "tako-rs-streams" version = "2.4.2" dependencies = [ "anyhow", - "base64", "bytes", "futures-util", "http", @@ -1391,33 +1341,10 @@ dependencies = [ "mime_guess", "percent-encoding", "pin-project-lite", - "sha1 0.10.6", "tako-rs-core", "tokio", - "tokio-tungstenite", "tokio-util", "tracing", - "url", -] - -[[package]] -name = "thiserror" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" -dependencies = [ - "proc-macro2", - "quote", - "syn", ] [[package]] @@ -1509,18 +1436,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-tungstenite" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71" -dependencies = [ - "futures-util", - "log", - "tokio", - "tungstenite", -] - [[package]] name = "tokio-util" version = "0.7.18" @@ -1566,22 +1481,6 @@ dependencies = [ "once_cell", ] -[[package]] -name = "tungstenite" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" -dependencies = [ - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "rand 0.10.3", - "sha1 0.11.0", - "thiserror", -] - [[package]] name = "typenum" version = "1.20.0" diff --git a/examples/autobahn-echo/Cargo.toml b/examples/autobahn-echo/Cargo.toml index bd62cc0b..e3aa9e2c 100644 --- a/examples/autobahn-echo/Cargo.toml +++ b/examples/autobahn-echo/Cargo.toml @@ -5,16 +5,12 @@ publish = false edition = "2024" [dependencies] -futures-util = { workspace = true } tako-rs = { workspace = true } tokio = { workspace = true } -tokio-tungstenite = { workspace = true } [workspace] [workspace.dependencies] -futures-util = "0.3" tako-rs = { path = "../../tako-rs", features = ["ws"] } tokio = { version = "1", features = ["full"] } -tokio-tungstenite = "0.30" diff --git a/examples/autobahn-echo/src/main.rs b/examples/autobahn-echo/src/main.rs index 4f2a2f99..6a531f30 100644 --- a/examples/autobahn-echo/src/main.rs +++ b/examples/autobahn-echo/src/main.rs @@ -1,36 +1,17 @@ -//! Plain echo server used by the Autobahn `fuzzingclient` test suite. +//! Echo server for the Autobahn `fuzzingclient` suite. //! -//! Listens on `127.0.0.1:9001`. Every text and binary frame is echoed back -//! verbatim; ping is replied to with pong; close is mirrored before the -//! task exits. No subprotocol, no compression, no auth β€” the suite varies -//! all those itself. +//! Listens on `127.0.0.1:9001` and echoes every text and binary message. -use futures_util::SinkExt; -use futures_util::StreamExt; use tako::Method; use tako::responder::Responder; use tako::types::Request; -use tako::ws::TakoWs; -use tokio_tungstenite::tungstenite::Message; +use tako::ws::WebSocketUpgrade; async fn echo(req: Request) -> impl Responder { - TakoWs::new(req, |mut ws| async move { - while let Some(Ok(msg)) = ws.next().await { - match msg { - Message::Text(t) => { - let _ = ws.send(Message::Text(t)).await; - } - Message::Binary(b) => { - let _ = ws.send(Message::Binary(b)).await; - } - Message::Ping(p) => { - let _ = ws.send(Message::Pong(p)).await; - } - Message::Close(c) => { - let _ = ws.send(Message::Close(c)).await; - break; - } - _ => {} + WebSocketUpgrade::new(req, |mut ws| async move { + while let Some(Ok(message)) = ws.recv().await { + if (message.is_text() || message.is_binary()) && ws.send(message).await.is_err() { + break; } } }) diff --git a/examples/graphql-generic-ws/Cargo.lock b/examples/graphql-generic-ws/Cargo.lock index 3aa4ac09..327befae 100644 --- a/examples/graphql-generic-ws/Cargo.lock +++ b/examples/graphql-generic-ws/Cargo.lock @@ -249,17 +249,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "chacha20" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.1", - "rand_core 0.10.1", -] - [[package]] name = "cipher" version = "0.4.4" @@ -300,7 +289,7 @@ dependencies = [ "aes-gcm", "base64", "hmac 0.12.1", - "rand 0.8.5", + "rand", "sha2 0.10.9", "subtle", "time", @@ -338,7 +327,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", - "rand_core 0.6.4", + "rand_core", "typenum", ] @@ -438,12 +427,6 @@ dependencies = [ "syn", ] -[[package]] -name = "data-encoding" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" - [[package]] name = "deranged" version = "0.5.8" @@ -688,7 +671,6 @@ dependencies = [ "cfg-if", "libc", "r-efi", - "rand_core 0.10.1", "wasip2", "wasip3", ] @@ -709,34 +691,9 @@ version = "0.1.0" dependencies = [ "anyhow", "async-graphql", - "base64", - "futures-util", "http", - "hyper", - "hyper-util", - "sha1 0.10.6", "tako-rs", "tokio", - "tokio-tungstenite", -] - -[[package]] -name = "h2" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", ] [[package]] @@ -873,7 +830,6 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2", "http", "http-body", "httparse", @@ -883,7 +839,6 @@ dependencies = [ "pin-utils", "smallvec", "tokio", - "want", ] [[package]] @@ -1407,18 +1362,7 @@ checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", "rand_chacha", - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" -dependencies = [ - "chacha20", - "getrandom 0.4.2", - "rand_core 0.10.1", + "rand_core", ] [[package]] @@ -1428,7 +1372,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core 0.6.4", + "rand_core", ] [[package]] @@ -1440,12 +1384,6 @@ dependencies = [ "getrandom 0.2.17", ] -[[package]] -name = "rand_core" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" - [[package]] name = "redox_syscall" version = "0.5.18" @@ -1632,17 +1570,6 @@ dependencies = [ "digest 0.10.7", ] -[[package]] -name = "sha1" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.1", - "digest 0.11.3", -] - [[package]] name = "sha2" version = "0.10.9" @@ -1812,13 +1739,12 @@ dependencies = [ "serde_bytes", "serde_json", "serde_urlencoded", - "sha1 0.10.6", + "sha1", "sha2 0.11.0", "smallvec", "thread_local", "tokio", "tokio-stream", - "tokio-tungstenite", "tokio-util", "tracing", "url", @@ -1848,7 +1774,7 @@ dependencies = [ "serde_html_form", "serde_json", "serde_urlencoded", - "sha1 0.10.6", + "sha1", "sha2 0.11.0", "smallvec", "tako-rs-core", @@ -1887,7 +1813,7 @@ dependencies = [ "scc", "serde", "serde_json", - "sha1 0.10.6", + "sha1", "sha2 0.11.0", "smallvec", "subtle", @@ -1927,7 +1853,6 @@ name = "tako-rs-streams" version = "2.4.2" dependencies = [ "anyhow", - "base64", "bytes", "futures-util", "http", @@ -1940,13 +1865,10 @@ dependencies = [ "mime_guess", "percent-encoding", "pin-project-lite", - "sha1 0.10.6", "tako-rs-core", "tokio", - "tokio-tungstenite", "tokio-util", "tracing", - "url", ] [[package]] @@ -2071,18 +1993,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-tungstenite" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71" -dependencies = [ - "futures-util", - "log", - "tokio", - "tungstenite", -] - [[package]] name = "tokio-util" version = "0.7.18" @@ -2158,28 +2068,6 @@ dependencies = [ "once_cell", ] -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - -[[package]] -name = "tungstenite" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" -dependencies = [ - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "rand 0.10.3", - "sha1 0.11.0", - "thiserror", -] - [[package]] name = "typenum" version = "1.19.0" @@ -2261,15 +2149,6 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" diff --git a/examples/graphql-generic-ws/Cargo.toml b/examples/graphql-generic-ws/Cargo.toml index e780951c..2532d38a 100644 --- a/examples/graphql-generic-ws/Cargo.toml +++ b/examples/graphql-generic-ws/Cargo.toml @@ -6,15 +6,9 @@ edition = "2024" [dependencies] anyhow = { workspace = true } async-graphql = { workspace = true } -base64 = { workspace = true } -futures-util = { workspace = true } http = { workspace = true } -hyper = { workspace = true } -hyper-util = { workspace = true } -sha1 = { workspace = true } tako-rs = { workspace = true } tokio = { workspace = true } -tokio-tungstenite = { workspace = true } [workspace] @@ -22,12 +16,6 @@ tokio-tungstenite = { workspace = true } [workspace.dependencies] anyhow = "1.0.98" async-graphql = "7.2.1" -base64 = "0.22" -futures-util = "0.3" http = "1" -hyper = { version = "1", features = ["full"] } -hyper-util = { version = "0.1", features = ["tokio"] } -sha1 = "0.10" tako-rs = { path = "../../tako-rs", features = ["async-graphql", "ws", "graphiql"] } tokio = { version = "1.45.1", features = ["full"] } -tokio-tungstenite = "0.30" diff --git a/examples/graphql-generic-ws/src/main.rs b/examples/graphql-generic-ws/src/main.rs index b74c5993..f846e6a5 100644 --- a/examples/graphql-generic-ws/src/main.rs +++ b/examples/graphql-generic-ws/src/main.rs @@ -8,13 +8,11 @@ use async_graphql::Schema; use async_graphql::Subscription; use async_graphql::futures_util::Stream; use async_graphql::futures_util::stream; -use http::HeaderValue; use http::StatusCode; -use http::header; -use hyper_util::rt::TokioIo; use tako::Method; use tako::extractors::FromRequest; use tako::extractors::state::State; +use tako::graphql::GraphQLProtocol; use tako::graphql::GraphQLRequest; use tako::graphql::GraphQLResponse; use tako::graphql::GraphQLWebSocket; @@ -22,9 +20,8 @@ use tako::responder::Responder; use tako::router::Router; use tako::types::Request as TakoRequest; use tako::types::Response as TakoResponse; +use tako::ws::WebSocketUpgrade; use tokio::net::TcpListener; -use tokio_tungstenite::WebSocketStream; -use tokio_tungstenite::tungstenite::protocol::Role; struct QueryRoot; @@ -57,62 +54,19 @@ async fn graphql_http( } async fn ws_generic(State(schema): State, mut req: TakoRequest) -> TakoResponse { - // Extract protocol using the extractor internally - let tako::graphql::GraphQLProtocol(protocol) = - match tako::graphql::GraphQLProtocol::from_request(&mut req).await { - Ok(p) => p, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - "Missing or invalid Sec-WebSocket-Protocol", - ) - .into_response(); - } - }; - // Compute Sec-WebSocket-Accept (RFC 6455) - let key = match req.headers().get("Sec-WebSocket-Key") { - Some(k) => k, - None => return (StatusCode::BAD_REQUEST, "Missing Sec-WebSocket-Key").into_response(), - }; - let accept = { - use base64::Engine as _; - use base64::engine::general_purpose::STANDARD; - use sha1::Digest; - use sha1::Sha1; - let mut sha1 = Sha1::new(); - sha1.update(key.as_bytes()); - sha1.update(b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11"); - STANDARD.encode(sha1.finalize()) - }; - - // Build upgrade response echoing the chosen subprotocol - let response = http::Response::builder() - .status(StatusCode::SWITCHING_PROTOCOLS) - .header(header::UPGRADE, "websocket") - .header(header::CONNECTION, "Upgrade") - .header("Sec-WebSocket-Accept", accept) - .header( - header::SEC_WEBSOCKET_PROTOCOL, - HeaderValue::from_static(protocol.sec_websocket_protocol()), + let Ok(GraphQLProtocol(protocol)) = GraphQLProtocol::from_request(&mut req).await else { + return ( + StatusCode::BAD_REQUEST, + "Missing or invalid Sec-WebSocket-Protocol", ) - .body(tako::body::TakoBody::empty()) - .unwrap(); - - // Spawn the WS task using the generic GraphQLWebSocket driver - if let Some(on_upgrade) = req.extensions().get::().cloned() { - tokio::spawn(async move { - if let Ok(upgraded) = on_upgrade.await { - let upgraded = TokioIo::new(upgraded); - let ws: WebSocketStream> = - WebSocketStream::from_raw_socket(upgraded, Role::Server, None).await; - - let driver = GraphQLWebSocket::new(ws, schema.as_ref().clone(), protocol); - driver.serve().await; - } - }); - } - - response + .into_response(); + }; + let schema = schema.as_ref().clone(); + WebSocketUpgrade::new(req, move |ws| { + GraphQLWebSocket::new(ws, schema, protocol).serve() + }) + .protocols([protocol.sec_websocket_protocol()]) + .into_response() } #[tokio::main] diff --git a/examples/websocket-compio/Cargo.lock b/examples/websocket-compio/Cargo.lock index b8622076..111a96f1 100644 --- a/examples/websocket-compio/Cargo.lock +++ b/examples/websocket-compio/Cargo.lock @@ -2072,7 +2072,6 @@ name = "tako-rs-streams" version = "2.4.2" dependencies = [ "anyhow", - "base64", "bytes", "compio", "futures-util", @@ -2087,12 +2086,10 @@ dependencies = [ "percent-encoding", "pin-project-lite", "send_wrapper", - "sha1", "tako-rs-core", "tokio", "tokio-util", "tracing", - "url", ] [[package]] diff --git a/examples/websocket-compio/Cargo.toml b/examples/websocket-compio/Cargo.toml index 143cfa62..828c52c4 100644 --- a/examples/websocket-compio/Cargo.toml +++ b/examples/websocket-compio/Cargo.toml @@ -12,6 +12,6 @@ tracing = { workspace = true } [workspace] [workspace.dependencies] -compio = { version = "0.19.2", features = ["macros", "time", "io", "ws"] } -tako-rs = { path = "../../tako-rs", features = ["compio-ws"] } +compio = { version = "0.19.2", features = ["macros", "time"] } +tako-rs = { path = "../../tako-rs", features = ["compio", "ws"] } tracing = "0.1.41" diff --git a/examples/websocket-compio/src/main.rs b/examples/websocket-compio/src/main.rs index 61d2da87..a80a9efe 100644 --- a/examples/websocket-compio/src/main.rs +++ b/examples/websocket-compio/src/main.rs @@ -1,80 +1,45 @@ -//! WebSocket example using compio runtime. +//! WebSocket echo and counter on the compio runtime. //! -//! Run with: cargo run --example websocket-compio --features compio-ws +//! Run with: cargo run --manifest-path examples/websocket-compio/Cargo.toml use std::time::Duration; -use compio::ws::tungstenite::Message; use tako::Method; use tako::responder::Responder; use tako::types::Request; -use tako::ws_compio::CompioWebSocket; -use tako::ws_compio::TakoWsCompio; -use tako::ws_compio::UpgradedStream; +use tako::ws::Message; +use tako::ws::WebSocketUpgrade; pub async fn ws_echo(req: Request) -> impl Responder { - TakoWsCompio::new(req, |mut ws: CompioWebSocket| async move { - if let Err(e) = ws - .send(Message::Text("Welcome to Tako WS (compio)!".into())) + WebSocketUpgrade::new(req, |mut ws| async move { + if ws + .send(Message::text("Welcome to Tako WS (compio)!")) .await + .is_err() { - tracing::error!("Failed to send welcome message: {}", e); return; } - - loop { - match ws.read().await { - Ok(Message::Text(txt)) => { - let response = format!("Echo: {}", txt); - if let Err(e) = ws.send(Message::Text(response.into())).await { - tracing::error!("Failed to send echo: {}", e); - break; - } - } - Ok(Message::Binary(bin)) => { - if let Err(e) = ws.send(Message::Binary(bin)).await { - tracing::error!("Failed to send binary: {}", e); - break; - } - } - Ok(Message::Ping(p)) => { - if let Err(e) = ws.send(Message::Pong(p)).await { - tracing::error!("Failed to send pong: {}", e); - break; - } - } - Ok(Message::Close(_)) => { - let _ = ws.close(None).await; - break; - } - Ok(_) => {} - Err(e) => { - tracing::error!("WebSocket error: {}", e); - break; - } + while let Some(Ok(message)) = ws.recv().await { + let reply = match message { + Message::Text(text) => Message::text(format!("Echo: {text}")), + Message::Binary(data) => Message::Binary(data), + _ => continue, + }; + if let Err(error) = ws.send(reply).await { + tracing::error!("Failed to send echo: {error}"); + break; } } }) } pub async fn ws_count(req: Request) -> impl Responder { - TakoWsCompio::new(req, |mut ws: CompioWebSocket| async move { - let mut count: u64 = 0; - - loop { - // Send current count - let msg = format!("count: {}", count); - if let Err(e) = ws.send(Message::Text(msg.into())).await { - tracing::error!("Failed to send count: {}", e); + WebSocketUpgrade::new(req, |mut ws| async move { + for count in 0u64.. { + if ws.send(Message::text(format!("count: {count}"))).await.is_err() { break; } - count += 1; - - // Wait 1 second compio::time::sleep(Duration::from_secs(1)).await; - - // Check for close message (non-blocking read attempt) - // For simplicity, we just keep counting until connection drops } }) } diff --git a/examples/websocket-http2/Cargo.toml b/examples/websocket-http2/Cargo.toml deleted file mode 100644 index f96915ee..00000000 --- a/examples/websocket-http2/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -[package] -name = "websocket-http2" -version = "0.1.0" -edition = "2024" - -[dependencies] -futures-util = { workspace = true } -tako-rs = { workspace = true } -tokio = { workspace = true } -tokio-stream = { workspace = true } -tokio-tungstenite = { workspace = true } - - -[workspace] - -[workspace.dependencies] -futures-util = "0.3.31" -tako-rs = { path = "../../tako-rs", features = ["ws", "http2", "tls"] } -tokio = { version = "1.45.1", features = ["full"] } -tokio-stream = "0.1.17" -tokio-tungstenite = "0.30" diff --git a/examples/websocket-http2/src/main.rs b/examples/websocket-http2/src/main.rs deleted file mode 100644 index 356c52ab..00000000 --- a/examples/websocket-http2/src/main.rs +++ /dev/null @@ -1,78 +0,0 @@ -use std::time::Duration; - -use futures_util::SinkExt; -use futures_util::StreamExt; -use tako::Method; -use tako::responder::Responder; -use tako::types::Request; -use tako::ws::TakoWs; -use tokio_stream::wrappers::IntervalStream; -use tokio_tungstenite::tungstenite::Message; -use tokio_tungstenite::tungstenite::Utf8Bytes; - -pub async fn ws_echo(req: Request) -> impl Responder { - TakoWs::new(req, |mut ws| async move { - let _ = ws.send(Message::Text("Welcome to Tako WS!".into())).await; - - while let Some(Ok(msg)) = ws.next().await { - match msg { - Message::Text(txt) => { - let _ = ws - .send(Message::Text(Utf8Bytes::from(format!("Echo: {txt}")))) - .await; - } - Message::Binary(bin) => { - let _ = ws.send(Message::Binary(bin)).await; - } - Message::Ping(p) => { - let _ = ws.send(Message::Pong(p)).await; - } - Message::Close(_) => { - let _ = ws.send(Message::Close(None)).await; - break; - } - _ => {} - } - } - }) -} - -pub async fn ws_tick(req: Request) -> impl Responder { - TakoWs::new(req, |mut ws| async move { - let mut ticker = IntervalStream::new(tokio::time::interval(Duration::from_secs(1))).enumerate(); - - loop { - tokio::select! { - msg = ws.next() => { - match msg { - Some(Ok(Message::Close(_))) | None => break, - _ => {} - } - } - - Some((i, _)) = ticker.next() => { - let _ = ws.send(Message::Text(Utf8Bytes::from(format!("tick #{i}")))).await; - } - } - } - }) -} - -#[tokio::main] -async fn main() { - let listener = tokio::net::TcpListener::bind("127.0.0.1:8080") - .await - .unwrap(); - let mut router = tako::router::Router::new(); - - router.route(Method::GET, "/ws/echo", ws_echo); - router.route(Method::GET, "/ws/tick", ws_tick); - - tako::Server::builder() - .tls(tako::TlsCert::pem_paths("cert.pem", "key.pem")) - .build() - .spawn_tls(listener, router) - .result() - .await - .expect("TLS server failed"); -} diff --git a/examples/websocket-http2/Cargo.lock b/examples/websocket-tls/Cargo.lock similarity index 94% rename from examples/websocket-http2/Cargo.lock rename to examples/websocket-tls/Cargo.lock index cc5adabd..d8ea4c9e 100644 --- a/examples/websocket-http2/Cargo.lock +++ b/examples/websocket-tls/Cargo.lock @@ -152,17 +152,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "chacha20" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.1", - "rand_core 0.10.1", -] - [[package]] name = "cipher" version = "0.4.4" @@ -203,7 +192,7 @@ dependencies = [ "aes-gcm", "base64", "hmac 0.12.1", - "rand 0.8.5", + "rand", "sha2 0.10.9", "subtle", "time", @@ -235,7 +224,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", - "rand_core 0.6.4", + "rand_core", "typenum", ] @@ -266,12 +255,6 @@ dependencies = [ "cmov", ] -[[package]] -name = "data-encoding" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" - [[package]] name = "deranged" version = "0.5.8" @@ -352,12 +335,6 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - [[package]] name = "foldhash" version = "0.1.5" @@ -479,7 +456,6 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", - "rand_core 0.10.1", "wasip2", "wasip3", ] @@ -494,25 +470,6 @@ dependencies = [ "polyval", ] -[[package]] -name = "h2" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "hashbrown" version = "0.15.5" @@ -625,7 +582,6 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2", "http", "http-body", "httparse", @@ -1055,18 +1011,7 @@ checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", "rand_chacha", - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" -dependencies = [ - "chacha20", - "getrandom 0.4.2", - "rand_core 0.10.1", + "rand_core", ] [[package]] @@ -1076,7 +1021,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core 0.6.4", + "rand_core", ] [[package]] @@ -1088,12 +1033,6 @@ dependencies = [ "getrandom 0.2.17", ] -[[package]] -name = "rand_core" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" - [[package]] name = "redox_syscall" version = "0.5.18" @@ -1288,17 +1227,6 @@ dependencies = [ "digest 0.10.7", ] -[[package]] -name = "sha1" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.1", - "digest 0.11.3", -] - [[package]] name = "sha2" version = "0.10.9" @@ -1435,14 +1363,13 @@ dependencies = [ "serde_bytes", "serde_json", "serde_urlencoded", - "sha1 0.10.6", + "sha1", "sha2 0.11.0", "smallvec", "thread_local", "tokio", "tokio-rustls", "tokio-stream", - "tokio-tungstenite", "tokio-util", "tracing", "url", @@ -1472,7 +1399,7 @@ dependencies = [ "serde_html_form", "serde_json", "serde_urlencoded", - "sha1 0.10.6", + "sha1", "sha2 0.11.0", "smallvec", "tako-rs-core", @@ -1511,7 +1438,7 @@ dependencies = [ "scc", "serde", "serde_json", - "sha1 0.10.6", + "sha1", "sha2 0.11.0", "smallvec", "subtle", @@ -1553,7 +1480,6 @@ name = "tako-rs-streams" version = "2.4.2" dependencies = [ "anyhow", - "base64", "bytes", "futures-util", "http", @@ -1566,33 +1492,10 @@ dependencies = [ "mime_guess", "percent-encoding", "pin-project-lite", - "sha1 0.10.6", "tako-rs-core", "tokio", - "tokio-tungstenite", "tokio-util", "tracing", - "url", -] - -[[package]] -name = "thiserror" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" -dependencies = [ - "proc-macro2", - "quote", - "syn", ] [[package]] @@ -1694,18 +1597,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-tungstenite" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71" -dependencies = [ - "futures-util", - "log", - "tokio", - "tungstenite", -] - [[package]] name = "tokio-util" version = "0.7.18" @@ -1751,22 +1642,6 @@ dependencies = [ "once_cell", ] -[[package]] -name = "tungstenite" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" -dependencies = [ - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "rand 0.10.3", - "sha1 0.11.0", - "thiserror", -] - [[package]] name = "typenum" version = "1.19.0" @@ -1952,14 +1827,11 @@ dependencies = [ ] [[package]] -name = "websocket-http2" +name = "websocket-tls" version = "0.1.0" dependencies = [ - "futures-util", "tako-rs", "tokio", - "tokio-stream", - "tokio-tungstenite", ] [[package]] diff --git a/examples/websocket-tls/Cargo.toml b/examples/websocket-tls/Cargo.toml new file mode 100644 index 00000000..751cc21d --- /dev/null +++ b/examples/websocket-tls/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "websocket-tls" +version = "0.1.0" +edition = "2024" + +[dependencies] +tako-rs = { workspace = true } +tokio = { workspace = true } + + +[workspace] + +[workspace.dependencies] +tako-rs = { path = "../../tako-rs", features = ["ws", "tls"] } +tokio = { version = "1.45.1", features = ["full"] } diff --git a/examples/websocket-tls/src/main.rs b/examples/websocket-tls/src/main.rs new file mode 100644 index 00000000..a9ffa003 --- /dev/null +++ b/examples/websocket-tls/src/main.rs @@ -0,0 +1,67 @@ +//! WebSocket echo and ticker over TLS (wss). + +use std::time::Duration; + +use tako::Method; +use tako::responder::Responder; +use tako::types::Request; +use tako::ws::Message; +use tako::ws::WebSocketUpgrade; + +pub async fn ws_echo(req: Request) -> impl Responder { + WebSocketUpgrade::new(req, |mut ws| async move { + if ws.send(Message::text("Welcome to Tako WS!")).await.is_err() { + return; + } + while let Some(Ok(message)) = ws.recv().await { + let reply = match message { + Message::Text(text) => Message::text(format!("Echo: {text}")), + Message::Binary(data) => Message::Binary(data), + _ => continue, + }; + if ws.send(reply).await.is_err() { + break; + } + } + }) +} + +pub async fn ws_tick(req: Request) -> impl Responder { + WebSocketUpgrade::new(req, |mut ws| async move { + let mut ticker = tokio::time::interval(Duration::from_secs(1)); + let mut tick = 0u64; + loop { + tokio::select! { + message = ws.recv() => match message { + Some(Ok(_)) => {} + _ => break, + }, + _ = ticker.tick() => { + if ws.send(Message::text(format!("tick #{tick}"))).await.is_err() { + break; + } + tick += 1; + } + } + } + }) +} + +#[tokio::main] +async fn main() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:8080") + .await + .unwrap(); + let mut router = tako::router::Router::new(); + + router.route(Method::GET, "/ws/echo", ws_echo); + router.route(Method::GET, "/ws/tick", ws_tick); + + tako::Server::builder() + .tls(tako::TlsCert::pem_paths("cert.pem", "key.pem")) + .build() + .spawn_tls(listener, router) + .result() + .await + .expect("TLS server failed"); +} diff --git a/examples/websocket/Cargo.lock b/examples/websocket/Cargo.lock index ae9502fe..1308dd7c 100644 --- a/examples/websocket/Cargo.lock +++ b/examples/websocket/Cargo.lock @@ -117,17 +117,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "chacha20" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.1", - "rand_core 0.10.1", -] - [[package]] name = "cipher" version = "0.4.4" @@ -159,7 +148,7 @@ dependencies = [ "aes-gcm", "base64", "hmac 0.12.1", - "rand 0.8.5", + "rand", "sha2 0.10.9", "subtle", "time", @@ -191,7 +180,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", - "rand_core 0.6.4", + "rand_core", "typenum", ] @@ -222,12 +211,6 @@ dependencies = [ "cmov", ] -[[package]] -name = "data-encoding" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" - [[package]] name = "deranged" version = "0.5.8" @@ -399,7 +382,6 @@ dependencies = [ "cfg-if", "libc", "r-efi", - "rand_core 0.10.1", "wasip2", "wasip3", ] @@ -933,18 +915,7 @@ checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", "rand_chacha", - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" -dependencies = [ - "chacha20", - "getrandom 0.4.2", - "rand_core 0.10.1", + "rand_core", ] [[package]] @@ -954,7 +925,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core 0.6.4", + "rand_core", ] [[package]] @@ -966,12 +937,6 @@ dependencies = [ "getrandom 0.2.17", ] -[[package]] -name = "rand_core" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" - [[package]] name = "redox_syscall" version = "0.5.18" @@ -1116,17 +1081,6 @@ dependencies = [ "digest 0.10.7", ] -[[package]] -name = "sha1" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.1", - "digest 0.11.3", -] - [[package]] name = "sha2" version = "0.10.9" @@ -1256,13 +1210,12 @@ dependencies = [ "serde_bytes", "serde_json", "serde_urlencoded", - "sha1 0.10.6", + "sha1", "sha2 0.11.0", "smallvec", "thread_local", "tokio", "tokio-stream", - "tokio-tungstenite", "tokio-util", "tracing", "url", @@ -1292,7 +1245,7 @@ dependencies = [ "serde_html_form", "serde_json", "serde_urlencoded", - "sha1 0.10.6", + "sha1", "sha2 0.11.0", "smallvec", "tako-rs-core", @@ -1331,7 +1284,7 @@ dependencies = [ "scc", "serde", "serde_json", - "sha1 0.10.6", + "sha1", "sha2 0.11.0", "smallvec", "subtle", @@ -1371,7 +1324,6 @@ name = "tako-rs-streams" version = "2.4.2" dependencies = [ "anyhow", - "base64", "bytes", "futures-util", "http", @@ -1384,33 +1336,10 @@ dependencies = [ "mime_guess", "percent-encoding", "pin-project-lite", - "sha1 0.10.6", "tako-rs-core", "tokio", - "tokio-tungstenite", "tokio-util", "tracing", - "url", -] - -[[package]] -name = "thiserror" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" -dependencies = [ - "proc-macro2", - "quote", - "syn", ] [[package]] @@ -1502,18 +1431,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-tungstenite" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71" -dependencies = [ - "futures-util", - "log", - "tokio", - "tungstenite", -] - [[package]] name = "tokio-util" version = "0.7.18" @@ -1559,22 +1476,6 @@ dependencies = [ "once_cell", ] -[[package]] -name = "tungstenite" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" -dependencies = [ - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "rand 0.10.3", - "sha1 0.11.0", - "thiserror", -] - [[package]] name = "typenum" version = "1.19.0" @@ -1757,11 +1658,8 @@ dependencies = [ name = "websocket" version = "0.1.0" dependencies = [ - "futures-util", "tako-rs", "tokio", - "tokio-stream", - "tokio-tungstenite", ] [[package]] diff --git a/examples/websocket/Cargo.toml b/examples/websocket/Cargo.toml index 174a952e..1541d8f7 100644 --- a/examples/websocket/Cargo.toml +++ b/examples/websocket/Cargo.toml @@ -4,18 +4,12 @@ version = "0.1.0" edition = "2024" [dependencies] -futures-util = { workspace = true } tako-rs = { workspace = true } tokio = { workspace = true } -tokio-stream = { workspace = true } -tokio-tungstenite = { workspace = true } [workspace] [workspace.dependencies] -futures-util = "0.3.31" tako-rs = { path = "../../tako-rs", features = ["ws"] } tokio = { version = "1.45.1", features = ["full"] } -tokio-stream = "0.1.17" -tokio-tungstenite = "0.30" diff --git a/examples/websocket/src/main.rs b/examples/websocket/src/main.rs index 7a7a3e79..240de92a 100644 --- a/examples/websocket/src/main.rs +++ b/examples/websocket/src/main.rs @@ -1,58 +1,45 @@ use std::time::Duration; -use futures_util::SinkExt; -use futures_util::StreamExt; use tako::Method; use tako::responder::Responder; use tako::types::Request; -use tako::ws::TakoWs; -use tokio_stream::wrappers::IntervalStream; -use tokio_tungstenite::tungstenite::Message; -use tokio_tungstenite::tungstenite::Utf8Bytes; +use tako::ws::Message; +use tako::ws::WebSocketUpgrade; pub async fn ws_echo(req: Request) -> impl Responder { - TakoWs::new(req, |mut ws| async move { - let _ = ws.send(Message::Text("Welcome to Tako WS!".into())).await; - - while let Some(Ok(msg)) = ws.next().await { - match msg { - Message::Text(txt) => { - let _ = ws - .send(Message::Text(Utf8Bytes::from(format!("Echo: {txt}")))) - .await; - } - Message::Binary(bin) => { - let _ = ws.send(Message::Binary(bin)).await; - } - Message::Ping(p) => { - let _ = ws.send(Message::Pong(p)).await; - } - Message::Close(_) => { - let _ = ws.send(Message::Close(None)).await; - break; - } - _ => {} + WebSocketUpgrade::new(req, |mut ws| async move { + if ws.send(Message::text("Welcome to Tako WS!")).await.is_err() { + return; + } + while let Some(Ok(message)) = ws.recv().await { + let reply = match message { + Message::Text(text) => Message::text(format!("Echo: {text}")), + Message::Binary(data) => Message::Binary(data), + _ => continue, + }; + if ws.send(reply).await.is_err() { + break; } } }) } pub async fn ws_tick(req: Request) -> impl Responder { - TakoWs::new(req, |mut ws| async move { - let mut ticker = IntervalStream::new(tokio::time::interval(Duration::from_secs(1))).enumerate(); - + WebSocketUpgrade::new(req, |mut ws| async move { + let mut ticker = tokio::time::interval(Duration::from_secs(1)); + let mut tick = 0u64; loop { tokio::select! { - msg = ws.next() => { - match msg { - Some(Ok(Message::Close(_))) | None => break, - _ => {} - } - } - - Some((i, _)) = ticker.next() => { - let _ = ws.send(Message::Text(Utf8Bytes::from(format!("tick #{i}")))).await; + message = ws.recv() => match message { + Some(Ok(_)) => {} + _ => break, + }, + _ = ticker.tick() => { + if ws.send(Message::text(format!("tick #{tick}"))).await.is_err() { + break; } + tick += 1; + } } } }) diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 02d99a88..15459084 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -458,6 +458,7 @@ checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", "futures-macro", + "futures-sink", "futures-task", "pin-project-lite", "slab", @@ -1533,7 +1534,6 @@ dependencies = [ "tokio", "tokio-util", "tracing", - "url", ] [[package]] diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index cbae30b6..f9c302ae 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -16,7 +16,7 @@ http-body-util = "0.1" libfuzzer-sys = "0.4" serde = { version = "1", features = ["derive"] } smallvec = "1" -tako-rs-core = { path = "../tako-rs-core", features = ["grpc", "plugins"] } +tako-rs-core = { path = "../tako-rs-core", features = ["grpc", "plugins", "ws"] } tako-rs-extractors = { path = "../tako-rs-extractors", features = ["multipart"] } tako-rs-plugins = { path = "../tako-rs-plugins", features = ["plugins"] } tako-rs-server = { path = "../tako-rs-server", features = ["proxy-protocol"] } @@ -108,3 +108,11 @@ path = "fuzz_targets/sse_encoder.rs" test = false doc = false bench = false + + +[[bin]] +name = "ws_frame" +path = "fuzz_targets/ws_frame.rs" +test = false +doc = false +bench = false diff --git a/fuzz/fuzz_targets/ws_frame.rs b/fuzz/fuzz_targets/ws_frame.rs new file mode 100644 index 00000000..e6f6eb8f --- /dev/null +++ b/fuzz/fuzz_targets/ws_frame.rs @@ -0,0 +1,7 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + tako_rs_core::ws::fuzz_decode(data); +}); diff --git a/tako-rs-core/Cargo.toml b/tako-rs-core/Cargo.toml index 2e29dedb..161718c0 100644 --- a/tako-rs-core/Cargo.toml +++ b/tako-rs-core/Cargo.toml @@ -68,7 +68,6 @@ thread_local = { workspace = true } tokio = { workspace = true } tokio-rustls = { workspace = true, optional = true } tokio-stream = { workspace = true } -tokio-tungstenite = { workspace = true, optional = true } tokio-util = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true, optional = true } @@ -89,12 +88,11 @@ jwt-simple = { workspace = true, optional = true, features = ["default"] } async-graphql = ["tako-rs/async-graphql", "dep:async-graphql"] compio = ["tako-rs/compio", "dep:compio", "dep:cyper-core", "dep:send_wrapper"] compio-tls = ["tako-rs/compio-tls", "compio", "tls", "dep:rustls"] -compio-ws = ["tako-rs/compio-ws", "compio", "compio/io", "compio/ws"] metrics-prometheus = ["tako-rs/metrics-prometheus", "dep:prometheus", "plugins", "signals"] metrics-opentelemetry = ["tako-rs/metrics-opentelemetry", "dep:opentelemetry", "dep:opentelemetry_sdk", "dep:opentelemetry-otlp", "plugins", "signals"] graphiql = ["tako-rs/graphiql", "dep:async-graphql", "async-graphql/graphiql"] default = [] -ws = ["tako-rs/ws", "dep:tokio-tungstenite", "hyper/http1", "hyper/server"] +ws = ["tako-rs/ws", "futures-util/sink", "hyper/http1", "hyper/server"] file-stream = ["tako-rs/file-stream"] http2 = ["tako-rs/http2", "hyper/http2", "hyper-util/http2"] jemalloc = ["tako-rs/jemalloc"] diff --git a/tako-rs-core/src/graphql/subscription.rs b/tako-rs-core/src/graphql/subscription.rs index d3cc85d1..f51f83f1 100644 --- a/tako-rs-core/src/graphql/subscription.rs +++ b/tako-rs-core/src/graphql/subscription.rs @@ -10,31 +10,20 @@ use async_graphql::Executor; use async_graphql::Result as GqlResult; use async_graphql::http::DefaultOnConnInitType; use async_graphql::http::DefaultOnPingType; -use async_graphql::http::WebSocket as GqlWebSocket; use async_graphql::http::WebSocketProtocols; -use async_graphql::http::WsMessage; use async_graphql::http::default_on_connection_init; use async_graphql::http::default_on_ping; -use futures_util::SinkExt as _; -use futures_util::StreamExt as _; use http::HeaderValue; use http::StatusCode; use http::header; -use hyper_util::rt::TokioIo; -use tokio_tungstenite::WebSocketStream; -use tokio_tungstenite::tungstenite::protocol::Role; -use tokio_tungstenite::tungstenite::protocol::WebSocketConfig; +use hyper::upgrade::OnUpgrade; +use super::GraphQLWebSocket; use crate::body::TakoBody; use crate::responder::Responder; use crate::types::Request; use crate::types::Response; - -// tungstenite zero-fills the spare read buffer before every socket read, so its -// 128 KiB default costs a large memset per message. -fn websocket_config() -> WebSocketConfig { - WebSocketConfig::default().read_buffer_size(4 * 1024) -} +use crate::ws; /// `GraphQL` WebSocket subscription responder (`GraphQL` over WebSocket). /// @@ -134,118 +123,56 @@ where OnPingFut: Future>> + Send + 'static, { fn into_response(self) -> Response { - // Rebuild so we can grab OnUpgrade - let (parts, body) = self.request.into_parts(); - let req = http::Request::from_parts(parts, body); - - let selected_protocol = req + let accept = match ws::accept(&self.request) { + Ok(accept) => accept, + Err(status) => return ws::rejection(status), + }; + let Some(protocol) = self + .request .headers() .get(header::SEC_WEBSOCKET_PROTOCOL) - .and_then(|v| v.to_str().ok()) - .and_then(|protocols| { - protocols + .and_then(|value| value.to_str().ok()) + .and_then(|offered| { + offered .split(',') - .find_map(|p| WebSocketProtocols::from_str(p.trim()).ok()) - }); - - let Some(protocol) = selected_protocol else { + .find_map(|name| WebSocketProtocols::from_str(name.trim()).ok()) + }) + else { return ( StatusCode::BAD_REQUEST, "Missing or invalid Sec-WebSocket-Protocol", ) .into_response(); }; - - let Some(key) = req.headers().get("Sec-WebSocket-Key") else { - return ( - StatusCode::BAD_REQUEST, - "Missing Sec-WebSocket-Key for WebSocket upgrade", - ) - .into_response(); - }; - - let accept = { - use base64::Engine as _; - use base64::engine::general_purpose::STANDARD; - use sha1::Digest; - use sha1::Sha1; - let mut sha1 = Sha1::new(); - sha1.update(key.as_bytes()); - sha1.update(b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11"); - STANDARD.encode(sha1.finalize()) - }; - - let builder = http::Response::builder() + let response = http::Response::builder() .status(StatusCode::SWITCHING_PROTOCOLS) .header(header::UPGRADE, "websocket") .header(header::CONNECTION, "Upgrade") - .header("Sec-WebSocket-Accept", accept) + .header(header::SEC_WEBSOCKET_ACCEPT, accept) .header( header::SEC_WEBSOCKET_PROTOCOL, HeaderValue::from_static(protocol.sec_websocket_protocol()), - ); - - let response = builder.body(TakoBody::empty()).unwrap(); - - if let Some(on_upgrade) = req.extensions().get::().cloned() { - let executor = self.executor.clone(); - let data = self.data; - let on_conn_init = self.on_connection_init; - let on_ping = self.on_ping; - let keepalive = self.keepalive_timeout; - - tokio::spawn(async move { - if let Ok(upgraded) = on_upgrade.await { - let upgraded = TokioIo::new(upgraded); - let ws = - WebSocketStream::from_raw_socket(upgraded, Role::Server, Some(websocket_config())) - .await; - let (mut sink, stream) = ws.split(); - - let input = stream - .take_while(|res| futures_util::future::ready(res.is_ok())) - .map(Result::unwrap) - .filter_map(|msg| match msg { - tokio_tungstenite::tungstenite::Message::Text(_) - | tokio_tungstenite::tungstenite::Message::Binary(_) => { - futures_util::future::ready(Some(msg)) - } - _ => futures_util::future::ready(None), - }) - .map(tokio_tungstenite::tungstenite::Message::into_data); - - let mut stream = GqlWebSocket::new(executor, input, protocol) - .connection_data(data) - .on_connection_init(on_conn_init) - .on_ping(on_ping.clone()) - .keepalive_timeout(keepalive) - .map(|msg| match msg { - WsMessage::Text(text) => tokio_tungstenite::tungstenite::Message::Text(text.into()), - WsMessage::Close(_code, _status) => { - // tungstenite CloseFrame conversion requires CloseCode; close without reason - tokio_tungstenite::tungstenite::Message::Close(None) - } - }); - - while let Some(item) = stream.next().await { - if sink.send(item).await.is_err() { - break; - } - } - } + ) + .body(TakoBody::empty()) + .expect("valid upgrade response"); + let Self { + mut request, + executor, + data, + on_connection_init, + on_ping, + keepalive_timeout, + } = self; + if let Some(on_upgrade) = request.extensions_mut().remove::() { + ws::spawn_session(on_upgrade, move |socket| { + GraphQLWebSocket::new(socket, executor, protocol) + .with_data(data) + .on_connection_init(on_connection_init) + .on_ping(on_ping) + .keepalive_timeout(keepalive_timeout) + .serve() }); } - response } } - -#[cfg(test)] -mod tests { - use super::websocket_config; - - #[test] - fn subscriptions_read_into_a_small_buffer() { - assert_eq!(websocket_config().read_buffer_size, 4 * 1024); - } -} diff --git a/tako-rs-core/src/graphql/websocket.rs b/tako-rs-core/src/graphql/websocket.rs index abec4f18..3f504ff1 100644 --- a/tako-rs-core/src/graphql/websocket.rs +++ b/tako-rs-core/src/graphql/websocket.rs @@ -1,5 +1,4 @@ -//! Generic `GraphQL`-over-WebSocket driver over an arbitrary tungstenite -//! `Sink`/`Stream` pair, reusing async-graphql's WebSocket state machine. +//! Generic `GraphQL`-over-WebSocket driver over any `Sink`/`Stream` pair of WebSocket messages. use std::future::Future; use std::time::Duration; @@ -15,14 +14,15 @@ use async_graphql::http::WsMessage; use async_graphql::http::default_on_connection_init; use async_graphql::http::default_on_ping; use futures_util::Sink; -use futures_util::SinkExt as _; use futures_util::Stream; use futures_util::StreamExt as _; -/// A generic `GraphQL` WebSocket driver using an arbitrary `Sink`/`Stream` of tungstenite Messages. -/// -/// This is a generic API so you can integrate custom websocket -/// transports while reusing Tako's mapping to async-graphql's WebSocket state machine. +use crate::ws::CloseCode; +use crate::ws::CloseFrame; +use crate::ws::Error; +use crate::ws::Message; + +/// Runs async-graphql's WebSocket protocol over any `Sink`/`Stream` of WebSocket messages. #[cfg(not(feature = "compio"))] pub struct GraphQLWebSocket where @@ -41,16 +41,14 @@ where #[cfg(not(feature = "compio"))] impl GraphQLWebSocket< - futures_util::stream::SplitSink, + futures_util::stream::SplitSink, futures_util::stream::SplitStream, E, DefaultOnConnInitType, DefaultOnPingType, > where - S: Stream< - Item = Result, - > + Sink, + S: Stream> + Sink, E: Executor, { /// Create a `GraphQLWebSocket` from a combined websocket stream implementing `Sink`+`Stream`. @@ -64,10 +62,8 @@ where impl GraphQLWebSocket where - SinkT: Sink, - StreamT: Stream< - Item = Result, - >, + SinkT: Sink, + StreamT: Stream>, E: Executor, { /// Create a `GraphQLWebSocket` from separate sink and stream. @@ -93,10 +89,8 @@ where #[cfg(not(feature = "compio"))] impl GraphQLWebSocket where - SinkT: Sink, - StreamT: Stream< - Item = Result, - >, + SinkT: Sink, + StreamT: Stream>, E: Executor, { pub fn with_data(self, data: Data) -> Self { @@ -115,10 +109,8 @@ where impl GraphQLWebSocket where - SinkT: Sink + Unpin, - StreamT: Stream< - Item = Result, - > + Unpin, + SinkT: Sink + Unpin, + StreamT: Stream> + Unpin, E: Executor, OnConnInit: FnOnce(serde_json::Value) -> OnConnInitFut + Send + 'static, OnConnInitFut: Future> + Send + 'static, @@ -162,35 +154,40 @@ where } } - /// Run the `GraphQL` over WebSocket protocol loop until the connection ends. - pub async fn serve(mut self) { - let input = self - .stream - .take_while(|res| futures_util::future::ready(res.is_ok())) - .map(Result::unwrap) - .filter_map(|msg| match msg { - tokio_tungstenite::tungstenite::Message::Text(_) - | tokio_tungstenite::tungstenite::Message::Binary(_) => { - futures_util::future::ready(Some(msg)) - } - _ => futures_util::future::ready(None), - }) - .map(tokio_tungstenite::tungstenite::Message::into_data); - - let mut out_stream = GqlWebSocket::new(self.executor, input, self.protocol) - .connection_data(self.data) - .on_connection_init(self.on_connection_init) - .on_ping(self.on_ping.clone()) - .keepalive_timeout(self.keepalive_timeout) - .map(|msg| match msg { - WsMessage::Text(text) => tokio_tungstenite::tungstenite::Message::Text(text.into()), - WsMessage::Close(_code, _status) => tokio_tungstenite::tungstenite::Message::Close(None), + /// Runs the `GraphQL` over WebSocket protocol until the connection ends. + pub async fn serve(self) { + let Self { + sink, + stream, + executor, + data, + on_connection_init, + on_ping, + protocol, + keepalive_timeout, + } = self; + let input = stream + .take_while(|message| futures_util::future::ready(message.is_ok())) + .filter_map(|message| { + futures_util::future::ready(match message { + Ok(message @ (Message::Text(_) | Message::Binary(_))) => Some(message.into_data()), + _ => None, + }) }); - - while let Some(item) = out_stream.next().await { - if self.sink.send(item).await.is_err() { - break; - } - } + let output = GqlWebSocket::new(executor, input, protocol) + .connection_data(data) + .on_connection_init(on_connection_init) + .on_ping(on_ping) + .keepalive_timeout(keepalive_timeout) + .map(|message| { + Ok::<_, SinkT::Error>(match message { + WsMessage::Text(text) => Message::text(text), + WsMessage::Close(code, reason) => Message::Close(Some(CloseFrame { + code: CloseCode::new(code), + reason: reason.into(), + })), + }) + }); + let _ = output.forward(sink).await; } } diff --git a/tako-rs-core/src/lib.rs b/tako-rs-core/src/lib.rs index 5fddad9a..a7d9f046 100644 --- a/tako-rs-core/src/lib.rs +++ b/tako-rs-core/src/lib.rs @@ -108,6 +108,11 @@ pub mod openapi; #[cfg_attr(docsrs, doc(cfg(feature = "grpc")))] pub mod grpc; +/// WebSocket upgrades and connections. +#[cfg(feature = "ws")] +#[cfg_attr(docsrs, doc(cfg(feature = "ws")))] +pub mod ws; + pub use bytes::Bytes; pub use http::Method; pub use http::StatusCode; diff --git a/tako-rs-core/src/ws.rs b/tako-rs-core/src/ws.rs new file mode 100644 index 00000000..6248743f --- /dev/null +++ b/tako-rs-core/src/ws.rs @@ -0,0 +1,63 @@ +//! WebSocket (RFC 6455) server connections. + +mod driver; +mod error; +mod frame; +mod handshake; +mod mask; +mod message; +mod protocol; +mod receiver; +mod runtime; +mod socket; +#[cfg(test)] +mod testing; +mod upgrade; +mod utf8; +mod wake; + +pub use error::Error; +pub use error::ProtocolError; +#[cfg(all(feature = "async-graphql", not(feature = "compio")))] +pub(crate) use handshake::accept; +#[cfg(all(feature = "async-graphql", not(feature = "compio")))] +pub(crate) use handshake::rejection; +pub use message::CloseCode; +pub use message::CloseFrame; +pub use message::Message; +pub use message::Utf8Bytes; +pub use socket::WebSocket; +pub use upgrade::WebSocketUpgrade; +#[cfg(all(feature = "async-graphql", not(feature = "compio")))] +pub(crate) use upgrade::spawn_session; + +/// Feeds `data` to the frame decoder in two pieces, as a fuzz target does. +#[cfg(fuzzing)] +#[doc(hidden)] +pub fn fuzz_decode(data: &[u8]) { + let limits = receiver::Limits { + max_frame_size: 1 << 16, + max_message_size: 1 << 18, + }; + let mut protocol = protocol::Protocol::new(limits); + let mut input = bytes::BytesMut::new(); + let mut output = bytes::BytesMut::new(); + let (head, tail) = data.split_at(data.len() / 2); + for piece in [head, tail] { + input.extend_from_slice(piece); + loop { + match protocol.decode(&mut input) { + Ok(Some(_)) => { + protocol.drain_pong(&mut output); + protocol.drain_close(&mut output); + } + Ok(None) => break, + Err(error) => { + protocol.fail(error); + protocol.drain_close(&mut output); + return; + } + } + } + } +} diff --git a/tako-rs-core/src/ws/driver.rs b/tako-rs-core/src/ws/driver.rs new file mode 100644 index 00000000..88c0e478 --- /dev/null +++ b/tako-rs-core/src/ws/driver.rs @@ -0,0 +1,151 @@ +//! Runs a connection's handler, writes its output once per wake-up and closes the connection. + +use std::future::Future; +use std::panic::AssertUnwindSafe; +use std::pin::Pin; +use std::task::Context; +use std::task::Poll; +use std::time::Duration; + +use futures_util::FutureExt; +use futures_util::future::CatchUnwind; + +use super::CloseCode; +use super::runtime::Shared; +use super::runtime::Sleep; +use super::runtime::lock; +use super::runtime::sleep; +use super::socket::Core; + +// A peer that stops reading cannot keep a closing connection's task alive longer than this. +const CLOSE_TIMEOUT: Duration = Duration::from_secs(5); + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Phase { + Running, + Detached, + Closing, + Done, +} + +pub(crate) struct Driver { + shared: Shared, + handler: Option>>>>, + lifetime: Option, + close_timer: Option, + phase: Phase, +} + +impl> Driver { + pub(crate) fn new(shared: Shared, handler: F, max_lifetime: Option) -> Self { + Self { + shared, + handler: Some(Box::pin(AssertUnwindSafe(handler).catch_unwind())), + lifetime: max_lifetime.map(sleep), + close_timer: None, + phase: Phase::Running, + } + } + + fn close(&mut self, code: CloseCode) { + self.handler = None; + { + let mut core = lock(&self.shared); + core.close_if_open(code); + core.finished = true; + core.wake_waiting(); + } + self.close_timer = Some(sleep(CLOSE_TIMEOUT)); + self.phase = Phase::Closing; + } + + fn lifetime_over(&mut self, cx: &mut Context<'_>) -> bool { + self + .lifetime + .as_mut() + .is_some_and(|lifetime| lifetime.as_mut().poll(cx).is_ready()) + } + + fn poll_handler(&mut self, cx: &mut Context<'_>) -> Poll<()> { + let Some(handler) = self.handler.as_mut() else { + self.phase = Phase::Detached; + return Poll::Ready(()); + }; + lock(&self.shared).owner.enter(); + let result = handler.as_mut().poll(cx); + { + let mut core = lock(&self.shared); + core.owner.leave(); + let _ = core.poll_write_out(cx); + } + match result { + Poll::Pending => return Poll::Pending, + Poll::Ready(Ok(())) => { + self.handler = None; + self.phase = Phase::Detached; + } + Poll::Ready(Err(_)) => self.close(CloseCode::INTERNAL), + } + Poll::Ready(()) + } + + fn poll_detached(&mut self, cx: &mut Context<'_>) -> Poll<()> { + { + let mut core = lock(&self.shared); + if core.handles > 0 { + core.driver = Some(cx.waker().clone()); + return Poll::Pending; + } + } + self.close(CloseCode::NORMAL); + Poll::Ready(()) + } + + fn poll_closing(&mut self, cx: &mut Context<'_>) -> Poll<()> { + if self + .close_timer + .as_mut() + .is_none_or(|timer| timer.as_mut().poll(cx).is_ready()) + { + self.phase = Phase::Done; + return Poll::Ready(()); + } + let mut core = lock(&self.shared); + let done = match core.poll_write_out(cx) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Err(_)) => true, + Poll::Ready(Ok(())) => core.poll_shutdown(cx).is_ready(), + }; + if !done { + return Poll::Pending; + } + drop(core); + self.phase = Phase::Done; + Poll::Ready(()) + } +} + +impl> Future for Driver { + type Output = (); + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> { + let this = &mut *self; + loop { + if matches!(this.phase, Phase::Running | Phase::Detached) && this.lifetime_over(cx) { + this.close(CloseCode::GOING_AWAY); + } + let step = match this.phase { + Phase::Running => this.poll_handler(cx), + Phase::Detached => this.poll_detached(cx), + Phase::Closing => this.poll_closing(cx), + Phase::Done => return Poll::Ready(()), + }; + if step.is_pending() { + return Poll::Pending; + } + } + } +} + +#[cfg(test)] +mod tests; diff --git a/tako-rs-core/src/ws/driver/tests.rs b/tako-rs-core/src/ws/driver/tests.rs new file mode 100644 index 00000000..aa84de08 --- /dev/null +++ b/tako-rs-core/src/ws/driver/tests.rs @@ -0,0 +1,192 @@ +use std::time::Duration; +#[cfg(feature = "compio")] +use std::time::Instant; + +use bytes::Bytes; +use tokio::io::AsyncReadExt; +use tokio::io::AsyncWriteExt; +use tokio::io::DuplexStream; +#[cfg(not(feature = "compio"))] +use tokio::time::Instant; + +use super::Driver; +use crate::ws::Message; +use crate::ws::WebSocket; +use crate::ws::runtime::shared; +use crate::ws::socket::Config; +use crate::ws::socket::Core; +use crate::ws::testing::Counts; +use crate::ws::testing::TEXT; +use crate::ws::testing::client_frame; +use crate::ws::testing::upgraded_pair; + +async fn run( + capacity: usize, + lifetime: Option, + handler: F, +) -> (DuplexStream, Counts) +where + F: FnOnce(WebSocket) -> Fut, + Fut: crate::ws::testing::TaskFuture, +{ + let (upgraded, client, counts) = upgraded_pair(capacity).await; + let core = shared(Core::new(upgraded, Config::default())); + let ws = WebSocket::new(core.clone()); + crate::ws::runtime::spawn(Driver::new(core, handler(ws), lifetime)); + (client, counts) +} + +async fn read_close(client: &mut DuplexStream) -> u16 { + let mut frame = [0; 4]; + client.read_exact(&mut frame).await.unwrap(); + assert_eq!(frame[..2], [0x88, 2]); + u16::from_be_bytes([frame[2], frame[3]]) +} + +async fn assert_eof(client: &mut DuplexStream) { + let mut rest = Vec::new(); + client.read_to_end(&mut rest).await.unwrap(); + assert!(rest.is_empty(), "{rest:?}"); +} + +#[cfg_attr(not(feature = "compio"), tokio::test)] +#[cfg_attr(feature = "compio", compio::test)] +async fn a_finished_handler_closes_with_1000() { + let (mut client, _) = run(1 << 20, None, |ws| async move { drop(ws) }).await; + assert_eq!(read_close(&mut client).await, 1000); + assert_eof(&mut client).await; +} + +#[cfg_attr(not(feature = "compio"), tokio::test)] +#[cfg_attr(feature = "compio", compio::test)] +async fn a_panicking_handler_closes_with_1011() { + let (mut client, _) = run(1 << 20, None, |ws| async move { + let _ws = ws; + panic!("handler failure under test"); + }) + .await; + assert_eq!(read_close(&mut client).await, 1011); + assert_eof(&mut client).await; +} + +#[cfg_attr(not(feature = "compio"), tokio::test(start_paused = true))] +#[cfg_attr(feature = "compio", compio::test)] +async fn max_lifetime_closes_with_1001() { + let lifetime = Some(Duration::from_secs(1)); + let (mut client, _) = run(1 << 20, lifetime, |ws| async move { + let _ws = ws; + std::future::pending::<()>().await; + }) + .await; + assert_eq!(read_close(&mut client).await, 1001); + assert_eof(&mut client).await; +} + +#[cfg_attr(not(feature = "compio"), tokio::test(start_paused = true))] +#[cfg_attr(feature = "compio", compio::test)] +async fn closing_gives_up_on_a_peer_that_never_reads() { + let (upgraded, _client, _) = upgraded_pair(1024).await; + let core = shared(Core::new(upgraded, Config::default())); + let mut ws = WebSocket::new(core.clone()); + let start = Instant::now(); + let handler = async move { + ws.send(Message::Binary(Bytes::from(vec![0; 64 * 1024]))) + .await + .unwrap(); + }; + crate::ws::testing::spawn(Driver::new(core, handler, None)) + .await + .unwrap(); + let elapsed = start.elapsed(); + assert!(elapsed >= Duration::from_secs(5), "{elapsed:?}"); + assert!(elapsed < Duration::from_secs(6), "{elapsed:?}"); +} + +#[cfg_attr(not(feature = "compio"), tokio::test(start_paused = true))] +#[cfg_attr(feature = "compio", compio::test)] +async fn queued_output_leaves_before_the_handler_waits() { + let (mut client, _) = run(1 << 20, None, |mut ws| async move { + ws.send(Message::text("hi")).await.unwrap(); + crate::ws::runtime::sleep(Duration::from_secs(10)).await; + }) + .await; + let mut frame = [0; 4]; + let read = crate::ws::testing::timeout(Duration::from_secs(1), client.read_exact(&mut frame)); + read.await.unwrap().unwrap(); + assert_eq!(frame, [0x81, 2, b'h', b'i']); +} + +#[cfg_attr(not(feature = "compio"), tokio::test)] +#[cfg_attr(feature = "compio", compio::test)] +async fn a_socket_moved_to_another_task_keeps_the_connection() { + let (mut client, _) = run(1 << 20, None, |mut ws| async move { + crate::ws::runtime::spawn(async move { + if let Some(Ok(message)) = ws.recv().await { + ws.send(message).await.unwrap(); + } + }); + }) + .await; + client + .write_all(&client_frame(TEXT, b"echo")) + .await + .unwrap(); + let mut echo = [0; 6]; + client.read_exact(&mut echo).await.unwrap(); + assert_eq!(echo, [0x81, 4, b'e', b'c', b'h', b'o']); + assert_eq!(read_close(&mut client).await, 1000); + assert_eof(&mut client).await; +} + +#[cfg_attr(not(feature = "compio"), tokio::test)] +#[cfg_attr(feature = "compio", compio::test)] +async fn a_send_only_handler_sees_a_dead_peer() { + let (tx, rx) = tokio::sync::oneshot::channel(); + let (client, _) = run(1 << 20, None, |mut ws| async move { + let mut sent = 0u32; + while sent < 1_000 && ws.send(Message::text("tick")).await.is_ok() { + sent += 1; + crate::ws::testing::yield_now().await; + } + let _ = tx.send(sent); + }) + .await; + drop(client); + let sent = rx.await.unwrap(); + assert!(sent < 3, "{sent}"); +} + +#[cfg_attr(not(feature = "compio"), tokio::test(start_paused = true))] +#[cfg_attr(feature = "compio", compio::test)] +async fn max_lifetime_ends_a_recv_parked_in_another_task() { + let (tx, rx) = tokio::sync::oneshot::channel(); + let (_client, _) = run(1 << 20, Some(Duration::from_secs(1)), |mut ws| async move { + crate::ws::runtime::spawn(async move { + let _ = tx.send(ws.recv().await.is_none()); + }); + }) + .await; + let ended = crate::ws::testing::timeout(Duration::from_secs(30), rx) + .await + .expect("the parked recv is woken") + .unwrap(); + assert!(ended); +} + +#[cfg_attr(not(feature = "compio"), tokio::test)] +#[cfg_attr(feature = "compio", compio::test)] +async fn sixteen_pipelined_echoes_leave_in_one_write() { + let (mut client, counts) = run(1 << 20, None, |mut ws| async move { + while let Some(Ok(message)) = ws.recv().await { + if ws.send(message).await.is_err() { + break; + } + } + }) + .await; + let batch: Vec = (0..16).flat_map(|_| client_frame(TEXT, b"hello")).collect(); + client.write_all(&batch).await.unwrap(); + let mut echoes = [0; 16 * 7]; + client.read_exact(&mut echoes).await.unwrap(); + assert_eq!(counts.writes(), 1); +} diff --git a/tako-rs-core/src/ws/error.rs b/tako-rs-core/src/ws/error.rs new file mode 100644 index 00000000..df2611c8 --- /dev/null +++ b/tako-rs-core/src/ws/error.rs @@ -0,0 +1,145 @@ +use std::fmt; +use std::io; + +use super::CloseCode; + +#[derive(Debug)] +pub enum Error { + Io(io::Error), + /// Invalid incoming frames queue a close reply while the connection is open, except at EOF. + /// Invalid outgoing messages are rejected without changing the connection. + Protocol(ProtocolError), + /// The closing handshake has begun; nothing more can be sent. + Closed, +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(error) => write!(f, "WebSocket transport error: {error}"), + Self::Protocol(error) => write!(f, "WebSocket protocol error: {error}"), + Self::Closed => f.write_str("WebSocket connection is closed"), + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Io(error) => Some(error), + Self::Protocol(error) => Some(error), + Self::Closed => None, + } + } +} + +impl From for Error { + fn from(error: io::Error) -> Self { + Self::Io(error) + } +} + +impl From for Error { + fn from(error: ProtocolError) -> Self { + Self::Protocol(error) + } +} + +/// A received frame or outgoing message that violates RFC 6455. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum ProtocolError { + /// A reserved header bit was set; tako negotiates no extensions. + ReservedBits, + UnmaskedFrame, + /// The frame used a reserved opcode. + UnknownOpcode(u8), + /// A control frame had FIN clear. + FragmentedControlFrame, + /// A control frame carried more than 125 bytes. + ControlFrameTooLarge, + /// A continuation frame arrived with no message in progress. + UnexpectedContinuation, + /// A new message started before the fragmented one finished. + ExpectedContinuation, + /// Text or a close reason was not valid UTF-8. + InvalidUtf8, + /// A close frame carried a one-byte payload or an outgoing close code was invalid. + InvalidCloseFrame, + /// A frame announced more than `max_frame_size` bytes. + FrameTooLarge { + size: u64, + max: usize, + }, + /// A message grew past `max_message_size` bytes. + MessageTooLarge { + /// Message length so far. + size: usize, + max: usize, + }, + /// The peer closed the transport without a close frame. + ResetWithoutClosingHandshake, +} + +impl ProtocolError { + pub(crate) fn close_code(self) -> CloseCode { + match self { + Self::InvalidUtf8 => CloseCode::INVALID_PAYLOAD, + Self::FrameTooLarge { .. } | Self::MessageTooLarge { .. } => CloseCode::TOO_BIG, + _ => CloseCode::PROTOCOL_ERROR, + } + } +} + +impl fmt::Display for ProtocolError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ReservedBits => f.write_str("reserved bits set"), + Self::UnmaskedFrame => f.write_str("client frame is not masked"), + Self::UnknownOpcode(opcode) => write!(f, "unknown opcode {opcode}"), + Self::FragmentedControlFrame => f.write_str("fragmented control frame"), + Self::ControlFrameTooLarge => f.write_str("control frame longer than 125 bytes"), + Self::UnexpectedContinuation => f.write_str("continuation frame without a message"), + Self::ExpectedContinuation => f.write_str("new message inside a fragmented one"), + Self::InvalidUtf8 => f.write_str("invalid UTF-8"), + Self::InvalidCloseFrame => f.write_str("invalid close frame payload or status code"), + Self::FrameTooLarge { size, max } => { + write!(f, "frame of {size} bytes over the {max}-byte limit") + } + Self::MessageTooLarge { size, max } => { + write!(f, "message of {size} bytes over the {max}-byte limit") + } + Self::ResetWithoutClosingHandshake => { + f.write_str("connection reset without a closing handshake") + } + } + } +} + +impl std::error::Error for ProtocolError {} + +#[cfg(test)] +mod tests { + use super::CloseCode; + use super::ProtocolError; + + #[test] + fn each_error_maps_to_its_close_code() { + assert_eq!( + ProtocolError::InvalidUtf8.close_code(), + CloseCode::INVALID_PAYLOAD + ); + let frame = ProtocolError::FrameTooLarge { size: 2, max: 1 }; + assert_eq!(frame.close_code(), CloseCode::TOO_BIG); + let message = ProtocolError::MessageTooLarge { size: 2, max: 1 }; + assert_eq!(message.close_code(), CloseCode::TOO_BIG); + assert_eq!( + ProtocolError::ReservedBits.close_code(), + CloseCode::PROTOCOL_ERROR + ); + assert_eq!( + ProtocolError::UnmaskedFrame.close_code(), + CloseCode::PROTOCOL_ERROR + ); + } +} diff --git a/tako-rs-core/src/ws/frame.rs b/tako-rs-core/src/ws/frame.rs new file mode 100644 index 00000000..3ea75509 --- /dev/null +++ b/tako-rs-core/src/ws/frame.rs @@ -0,0 +1,211 @@ +use bytes::BufMut; +use bytes::BytesMut; + +use super::ProtocolError; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum OpCode { + Continuation, + Text, + Binary, + Close, + Ping, + Pong, +} + +impl OpCode { + pub(crate) fn is_control(self) -> bool { + matches!(self, Self::Close | Self::Ping | Self::Pong) + } + + fn from_bits(bits: u8) -> Result { + match bits { + 0 => Ok(Self::Continuation), + 1 => Ok(Self::Text), + 2 => Ok(Self::Binary), + 8 => Ok(Self::Close), + 9 => Ok(Self::Ping), + 10 => Ok(Self::Pong), + other => Err(ProtocolError::UnknownOpcode(other)), + } + } + + fn bits(self) -> u8 { + match self { + Self::Continuation => 0, + Self::Text => 1, + Self::Binary => 2, + Self::Close => 8, + Self::Ping => 9, + Self::Pong => 10, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Header { + pub fin: bool, + pub opcode: OpCode, + pub mask: [u8; 4], + pub len: u64, + pub header_len: usize, +} + +/// Parses a client frame header. `Ok(None)` while `buf` holds less than a whole header. +pub(crate) fn parse_header( + buf: &[u8], + max_frame_size: usize, +) -> Result, ProtocolError> { + let [first, second, ..] = *buf else { + return Ok(None); + }; + if first & 0x70 != 0 { + return Err(ProtocolError::ReservedBits); + } + let fin = first & 0x80 != 0; + let opcode = OpCode::from_bits(first & 0x0F)?; + if second & 0x80 == 0 { + return Err(ProtocolError::UnmaskedFrame); + } + let (len, offset) = match second & 0x7F { + 126 => { + let Some(bytes) = array::<2>(buf, 2) else { + return Ok(None); + }; + (u64::from(u16::from_be_bytes(bytes)), 4) + } + 127 => { + let Some(bytes) = array::<8>(buf, 2) else { + return Ok(None); + }; + (u64::from_be_bytes(bytes), 10) + } + short => (u64::from(short), 2), + }; + if opcode.is_control() { + if !fin { + return Err(ProtocolError::FragmentedControlFrame); + } + if len > 125 { + return Err(ProtocolError::ControlFrameTooLarge); + } + } + if len > max_frame_size as u64 { + return Err(ProtocolError::FrameTooLarge { + size: len, + max: max_frame_size, + }); + } + Ok(array::<4>(buf, offset).map(|mask| Header { + fin, + opcode, + mask, + len, + header_len: offset + 4, + })) +} + +fn array(buf: &[u8], at: usize) -> Option<[u8; N]> { + buf.get(at..at + N)?.try_into().ok() +} + +/// Writes the header of a final, unmasked server frame. +pub(crate) fn encode_header(out: &mut BytesMut, opcode: OpCode, len: usize) { + out.put_u8(0x80 | opcode.bits()); + match len { + 0..=125 => out.put_u8(len as u8), + 126..=0xFFFF => { + out.put_u8(126); + out.put_u16(len as u16); + } + _ => { + out.put_u8(127); + out.put_u64(len as u64); + } + } +} + +#[cfg(test)] +mod tests { + use bytes::BytesMut; + + use super::Header; + use super::OpCode; + use super::ProtocolError; + use super::encode_header; + use super::parse_header; + + const MASK: [u8; 4] = [1, 2, 3, 4]; + const MAX: usize = 1 << 20; + + fn client_header(opcode: OpCode, len: usize) -> Vec { + let mut out = BytesMut::new(); + encode_header(&mut out, opcode, len); + let mut header = out.to_vec(); + header[1] |= 0x80; + header.extend_from_slice(&MASK); + header + } + + #[test] + fn every_length_encoding_parses_back() { + for (len, header_len) in [(0, 6), (125, 6), (126, 8), (65535, 8), (65536, 14)] { + let header = client_header(OpCode::Binary, len); + assert_eq!(header.len(), header_len); + let parsed = parse_header(&header, MAX).unwrap().unwrap(); + assert_eq!( + parsed, + Header { + fin: true, + opcode: OpCode::Binary, + mask: MASK, + len: len as u64, + header_len, + } + ); + } + } + + #[test] + fn a_truncated_header_needs_more_bytes() { + let header = client_header(OpCode::Text, 65536); + for end in 0..header.len() { + assert_eq!(parse_header(&header[..end], MAX), Ok(None), "{end}"); + } + } + + #[test] + fn header_rules_reject_bad_frames() { + let check = |bytes: &[u8], expected: ProtocolError| { + assert_eq!(parse_header(bytes, MAX), Err(expected), "{bytes:?}"); + }; + check(&[0xC2, 0x80, 1, 2, 3, 4], ProtocolError::ReservedBits); + check(&[0x83, 0x80, 1, 2, 3, 4], ProtocolError::UnknownOpcode(3)); + check(&[0x8B, 0x80, 1, 2, 3, 4], ProtocolError::UnknownOpcode(11)); + check(&[0x82, 0x00], ProtocolError::UnmaskedFrame); + check( + &[0x09, 0x80, 1, 2, 3, 4], + ProtocolError::FragmentedControlFrame, + ); + check(&[0x89, 0xFE, 0, 126], ProtocolError::ControlFrameTooLarge); + } + + #[test] + fn an_oversized_frame_fails_on_its_header() { + let header = client_header(OpCode::Binary, MAX + 1); + assert_eq!( + parse_header(&header, MAX), + Err(ProtocolError::FrameTooLarge { + size: MAX as u64 + 1, + max: MAX, + }) + ); + } + + #[test] + fn server_frames_are_unmasked_and_final() { + let mut out = BytesMut::new(); + encode_header(&mut out, OpCode::Pong, 3); + assert_eq!(&out[..], &[0x8A, 0x03]); + } +} diff --git a/tako-rs-core/src/ws/handshake.rs b/tako-rs-core/src/ws/handshake.rs new file mode 100644 index 00000000..6f059205 --- /dev/null +++ b/tako-rs-core/src/ws/handshake.rs @@ -0,0 +1,336 @@ +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD; +use http::HeaderMap; +use http::Method; +use http::StatusCode; +use http::Version; +use http::header; +use sha1::Digest; +use sha1::Sha1; + +use crate::body::TakoBody; +use crate::types::Request; +use crate::types::Response; + +/// Validates an RFC 6455 upgrade request and returns its `Sec-WebSocket-Accept` value. +pub(crate) fn accept(request: &Request) -> Result { + if request.method() != Method::GET { + return Err(StatusCode::METHOD_NOT_ALLOWED); + } + if request.version() != Version::HTTP_11 { + return Err(StatusCode::BAD_REQUEST); + } + let headers = request.headers(); + let host = single(headers, header::HOST).ok_or(StatusCode::BAD_REQUEST)?; + if host.contains('@') || host.parse::().is_err() { + return Err(StatusCode::BAD_REQUEST); + } + if !has_token(headers, header::UPGRADE, "websocket") + || !has_token(headers, header::CONNECTION, "upgrade") + { + return Err(StatusCode::BAD_REQUEST); + } + let version = single(headers, header::SEC_WEBSOCKET_VERSION).ok_or(StatusCode::BAD_REQUEST)?; + if version != "13" { + return Err(StatusCode::UPGRADE_REQUIRED); + } + let key = single(headers, header::SEC_WEBSOCKET_KEY).ok_or(StatusCode::BAD_REQUEST)?; + if key.len() != 24 + || !STANDARD + .decode(key) + .is_ok_and(|decoded| decoded.len() == 16) + { + return Err(StatusCode::BAD_REQUEST); + } + let mut hash = Sha1::new(); + hash.update(key.as_bytes()); + hash.update(b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11"); + Ok(STANDARD.encode(hash.finalize())) +} + +/// The response for a rejected upgrade, naming what the server supports. +pub(crate) fn rejection(status: StatusCode) -> Response { + let mut response = http::Response::builder().status(status); + if status == StatusCode::UPGRADE_REQUIRED { + response = response.header(header::SEC_WEBSOCKET_VERSION, "13"); + } + if status == StatusCode::METHOD_NOT_ALLOWED { + response = response.header(header::ALLOW, "GET"); + } + response + .body(TakoBody::empty()) + .expect("valid handshake rejection") +} + +/// The first of the server's `protocols` that the client offers. +pub(crate) fn negotiate_subprotocol( + headers: &HeaderMap, + protocols: &[&'static str], +) -> Option<&'static str> { + if protocols.is_empty() { + return None; + } + let offered: Vec<&str> = headers + .get_all(header::SEC_WEBSOCKET_PROTOCOL) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(',')) + .map(str::trim) + .collect(); + protocols + .iter() + .copied() + .find(|protocol| offered.contains(protocol)) +} + +/// Whether the request's `Origin` is on the allow-list; without a list every origin passes. +pub(crate) fn origin_allowed(headers: &HeaderMap, allowed: Option<&[String]>) -> bool { + let Some(allowed) = allowed else { + return true; + }; + let Some(origin) = headers + .get(header::ORIGIN) + .and_then(|value| value.to_str().ok()) + else { + return false; + }; + let observed = normalize_origin(origin); + !observed.is_empty() + && allowed + .iter() + .any(|entry| normalize_origin(entry) == observed) +} + +/// Scheme, host and non-default port of an origin, lowercased; empty when it is not one. +fn normalize_origin(raw: &str) -> String { + let raw = raw.trim(); + if raw.is_empty() || raw.eq_ignore_ascii_case("null") { + return String::new(); + } + let Ok(url) = url::Url::parse(raw) else { + return String::new(); + }; + // An Origin never carries credentials. + if !url.username().is_empty() || url.password().is_some() { + return String::new(); + } + let scheme = url.scheme().to_ascii_lowercase(); + let Some(host) = url.host_str() else { + return String::new(); + }; + let host = host.to_ascii_lowercase(); + let port = url.port(); + let default = matches!( + (scheme.as_str(), port), + ("http" | "ws", Some(80)) | ("https" | "wss", Some(443)) + ); + match port { + Some(port) if !default => format!("{scheme}://{host}:{port}"), + _ => format!("{scheme}://{host}"), + } +} + +fn single(headers: &HeaderMap, name: header::HeaderName) -> Option<&str> { + let mut values = headers.get_all(name).iter(); + let value = values.next()?.to_str().ok()?.trim(); + (values.next().is_none() && !value.is_empty()).then_some(value) +} + +fn has_token(headers: &HeaderMap, name: header::HeaderName, token: &str) -> bool { + headers + .get_all(name) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(',')) + .any(|value| value.trim().eq_ignore_ascii_case(token)) +} + +#[cfg(test)] +mod tests { + use http::HeaderMap; + use http::HeaderValue; + use http::Method; + use http::StatusCode; + use http::Version; + use http::header; + + use super::accept; + use super::negotiate_subprotocol; + use super::normalize_origin; + use super::origin_allowed; + use super::rejection; + use crate::body::TakoBody; + use crate::types::Request; + + fn valid() -> Request { + http::Request::builder() + .uri("/ws") + .header(header::HOST, "example.com") + .header(header::UPGRADE, "WebSocket") + .header(header::CONNECTION, "keep-alive, Upgrade") + .header(header::SEC_WEBSOCKET_VERSION, "13") + .header(header::SEC_WEBSOCKET_KEY, "dGhlIHNhbXBsZSBub25jZQ==") + .body(TakoBody::empty()) + .unwrap() + } + + #[test] + fn valid_handshake_matches_the_protocol_example() { + assert_eq!(accept(&valid()).unwrap(), "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="); + let mut request = valid(); + request + .headers_mut() + .insert(header::CONNECTION, "keep-alive".parse().unwrap()); + request + .headers_mut() + .append(header::CONNECTION, "upgrade".parse().unwrap()); + assert!(accept(&request).is_ok()); + } + + #[test] + fn missing_duplicate_and_malformed_headers_are_rejected() { + for name in [ + header::HOST, + header::UPGRADE, + header::CONNECTION, + header::SEC_WEBSOCKET_VERSION, + header::SEC_WEBSOCKET_KEY, + ] { + let mut request = valid(); + request.headers_mut().remove(name); + assert_eq!(accept(&request).unwrap_err(), StatusCode::BAD_REQUEST); + } + for key in [ + "invalid", + "YQ==", + "AAAAAAAAAAAAAAAAAAAAAA=A", + "AAAAAAAAAAAAAAAAAAAAAA==, AAAAAAAAAAAAAAAAAAAAAA==", + ] { + let mut request = valid(); + request + .headers_mut() + .insert(header::SEC_WEBSOCKET_KEY, key.parse().unwrap()); + assert_eq!(accept(&request).unwrap_err(), StatusCode::BAD_REQUEST); + } + let mut request = valid(); + request.headers_mut().append( + header::SEC_WEBSOCKET_KEY, + "AAAAAAAAAAAAAAAAAAAAAA==".parse().unwrap(), + ); + assert_eq!(accept(&request).unwrap_err(), StatusCode::BAD_REQUEST); + } + + #[test] + fn unsupported_versions_and_methods_explain_the_supported_handshake() { + let mut request = valid(); + request + .headers_mut() + .insert(header::SEC_WEBSOCKET_VERSION, "12".parse().unwrap()); + let response = rejection(accept(&request).unwrap_err()); + assert_eq!(response.status(), StatusCode::UPGRADE_REQUIRED); + assert_eq!(response.headers()[header::SEC_WEBSOCKET_VERSION], "13"); + *request.method_mut() = Method::POST; + let response = rejection(accept(&request).unwrap_err()); + assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); + assert_eq!(response.headers()[header::ALLOW], "GET"); + *request.method_mut() = Method::GET; + *request.version_mut() = Version::HTTP_2; + assert_eq!(accept(&request).unwrap_err(), StatusCode::BAD_REQUEST); + } + + #[test] + fn the_first_server_protocol_the_client_offers_wins() { + let mut headers = HeaderMap::new(); + assert_eq!(negotiate_subprotocol(&headers, &["chat"]), None); + headers.insert( + header::SEC_WEBSOCKET_PROTOCOL, + HeaderValue::from_static("superchat, chat"), + ); + assert_eq!( + negotiate_subprotocol(&headers, &["chat", "superchat"]), + Some("chat") + ); + assert_eq!(negotiate_subprotocol(&headers, &["v2"]), None); + assert_eq!(negotiate_subprotocol(&headers, &[]), None); + } + + #[test] + fn repeated_protocol_headers_keep_the_server_preference() { + let mut headers = HeaderMap::new(); + headers.append( + header::SEC_WEBSOCKET_PROTOCOL, + HeaderValue::from_static("other, superchat"), + ); + headers.append( + header::SEC_WEBSOCKET_PROTOCOL, + HeaderValue::from_static(" chat, v2 "), + ); + assert_eq!( + negotiate_subprotocol(&headers, &["chat", "superchat"]), + Some("chat") + ); + assert_eq!(negotiate_subprotocol(&headers, &["v2"]), Some("v2")); + assert_eq!(negotiate_subprotocol(&headers, &["Chat"]), None); + } + + #[test] + fn origins_are_checked_only_against_an_allow_list() { + let allowed = vec!["https://example.com".to_string()]; + let mut headers = HeaderMap::new(); + assert!(origin_allowed(&headers, None)); + assert!(!origin_allowed(&headers, Some(&allowed))); + headers.insert( + header::ORIGIN, + HeaderValue::from_static("HTTPS://Example.com:443"), + ); + assert!(origin_allowed(&headers, Some(&allowed))); + headers.insert( + header::ORIGIN, + HeaderValue::from_static("https://evil.example"), + ); + assert!(!origin_allowed(&headers, Some(&allowed))); + } + + #[test] + fn normalize_origin_lowercases_scheme_and_host() { + assert_eq!( + normalize_origin("HTTPS://Example.COM"), + "https://example.com" + ); + } + + #[test] + fn normalize_origin_strips_default_ports() { + assert_eq!( + normalize_origin("http://example.com:80"), + "http://example.com" + ); + assert_eq!( + normalize_origin("https://example.com:443"), + "https://example.com" + ); + } + + #[test] + fn normalize_origin_keeps_nondefault_ports() { + assert_eq!( + normalize_origin("https://example.com:8443"), + "https://example.com:8443" + ); + } + + #[test] + fn normalize_origin_rejects_malformed_or_null() { + assert_eq!(normalize_origin(""), ""); + assert_eq!(normalize_origin("null"), ""); + assert_eq!(normalize_origin("not-an-origin"), ""); + } + + #[test] + fn normalize_origin_ignores_trailing_path() { + assert_eq!( + normalize_origin("https://example.com/path?x=1"), + "https://example.com" + ); + } +} diff --git a/tako-rs-core/src/ws/mask.rs b/tako-rs-core/src/ws/mask.rs new file mode 100644 index 00000000..3cf19bcc --- /dev/null +++ b/tako-rs-core/src/ws/mask.rs @@ -0,0 +1,43 @@ +/// XORs `data` with a client frame mask, starting at mask offset 0. +pub(crate) fn unmask(data: &mut [u8], mask: [u8; 4]) { + let word = u64::from_ne_bytes([ + mask[0], mask[1], mask[2], mask[3], mask[0], mask[1], mask[2], mask[3], + ]); + let (words, rest) = data.as_chunks_mut::<8>(); + for chunk in words { + *chunk = (u64::from_ne_bytes(*chunk) ^ word).to_ne_bytes(); + } + for (byte, key) in rest.iter_mut().zip(mask.iter().cycle()) { + *byte ^= key; + } +} + +#[cfg(test)] +mod tests { + use super::unmask; + + fn naive(data: &mut [u8], mask: [u8; 4]) { + for (i, byte) in data.iter_mut().enumerate() { + *byte ^= mask[i % 4]; + } + } + + #[test] + fn matches_the_bytewise_xor_at_every_length_and_alignment() { + let mask = [0x37, 0xfa, 0x21, 0x3d]; + let source: Vec = (0..72u8) + .map(|i| i.wrapping_mul(31).wrapping_add(7)) + .collect(); + for offset in 0..8 { + for len in 0..=64 { + let mut fast = source.clone(); + let mut slow = source.clone(); + unmask(&mut fast[offset..offset + len], mask); + naive(&mut slow[offset..offset + len], mask); + assert_eq!(fast, slow, "offset {offset}, len {len}"); + unmask(&mut fast[offset..offset + len], mask); + assert_eq!(fast, source, "offset {offset}, len {len}"); + } + } + } +} diff --git a/tako-rs-core/src/ws/message.rs b/tako-rs-core/src/ws/message.rs new file mode 100644 index 00000000..e38bf5b7 --- /dev/null +++ b/tako-rs-core/src/ws/message.rs @@ -0,0 +1,285 @@ +use std::fmt; +use std::ops::Deref; + +use bytes::Bytes; + +/// UTF-8 text backed by [`Bytes`], cheap to clone. +#[derive(Clone, PartialEq, Eq, Hash, Default)] +pub struct Utf8Bytes(Bytes); + +impl Utf8Bytes { + /// Wraps a static string without copying. + pub const fn from_static(text: &'static str) -> Self { + Self(Bytes::from_static(text.as_bytes())) + } + + pub fn as_str(&self) -> &str { + // SAFETY: every constructor checks or guarantees UTF-8. + unsafe { std::str::from_utf8_unchecked(&self.0) } + } + + /// # Safety + /// `bytes` must be valid UTF-8. + pub(crate) unsafe fn from_bytes_unchecked(bytes: Bytes) -> Self { + Self(bytes) + } +} + +impl Deref for Utf8Bytes { + type Target = str; + + fn deref(&self) -> &str { + self.as_str() + } +} + +impl AsRef for Utf8Bytes { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl AsRef<[u8]> for Utf8Bytes { + fn as_ref(&self) -> &[u8] { + &self.0 + } +} + +impl fmt::Display for Utf8Bytes { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl fmt::Debug for Utf8Bytes { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Debug::fmt(self.as_str(), f) + } +} + +impl From for Utf8Bytes { + fn from(text: String) -> Self { + Self(Bytes::from(text)) + } +} + +impl From<&'static str> for Utf8Bytes { + fn from(text: &'static str) -> Self { + Self::from_static(text) + } +} + +impl TryFrom for Utf8Bytes { + type Error = std::str::Utf8Error; + + fn try_from(bytes: Bytes) -> Result { + std::str::from_utf8(&bytes)?; + Ok(Self(bytes)) + } +} + +impl From for Bytes { + fn from(text: Utf8Bytes) -> Self { + text.0 + } +} + +impl PartialEq for Utf8Bytes { + fn eq(&self, other: &str) -> bool { + self.as_str() == other + } +} + +impl PartialEq<&str> for Utf8Bytes { + fn eq(&self, other: &&str) -> bool { + self.as_str() == *other + } +} + +/// A close status code (RFC 6455, section 7.4). +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub struct CloseCode(u16); + +impl CloseCode { + /// 1000: the purpose of the connection was fulfilled. + pub const NORMAL: Self = Self(1000); + /// 1001: the endpoint is going away. + pub const GOING_AWAY: Self = Self(1001); + /// 1002: the peer broke the protocol. + pub const PROTOCOL_ERROR: Self = Self(1002); + /// 1003: the endpoint cannot accept this kind of data. + pub const UNSUPPORTED: Self = Self(1003); + /// 1007: the payload did not match the message type, such as invalid UTF-8. + pub const INVALID_PAYLOAD: Self = Self(1007); + /// 1008: the message violated a policy. + pub const POLICY: Self = Self(1008); + /// 1009: the message was too big to process. + pub const TOO_BIG: Self = Self(1009); + /// 1010: the client expected an extension the server did not negotiate. + pub const EXTENSION: Self = Self(1010); + /// 1011: the server hit an unexpected condition. + pub const INTERNAL: Self = Self(1011); + + pub const fn new(code: u16) -> Self { + Self(code) + } + + pub const fn as_u16(self) -> u16 { + self.0 + } + + /// Whether a peer may send this code: 1000-1003, 1007-1014 and 3000-4999. + pub const fn is_allowed(self) -> bool { + matches!(self.0, 1000..=1003 | 1007..=1014 | 3000..=4999) + } +} + +impl fmt::Display for CloseCode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CloseFrame { + pub code: CloseCode, + /// UTF-8 reason for closing, at most 123 bytes. + pub reason: Utf8Bytes, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Message { + Text(Utf8Bytes), + Binary(Bytes), + /// tako answers it with a pong while the socket is read. + Ping(Bytes), + Pong(Bytes), + /// A close frame, with the peer's code and reason when it sent them. + Close(Option), +} + +impl Message { + pub fn text(text: impl Into) -> Self { + Self::Text(text.into()) + } + + pub fn binary(data: impl Into) -> Self { + Self::Binary(data.into()) + } + + pub fn is_text(&self) -> bool { + matches!(self, Self::Text(_)) + } + + pub fn is_binary(&self) -> bool { + matches!(self, Self::Binary(_)) + } + + pub fn is_ping(&self) -> bool { + matches!(self, Self::Ping(_)) + } + + pub fn is_pong(&self) -> bool { + matches!(self, Self::Pong(_)) + } + + pub fn is_close(&self) -> bool { + matches!(self, Self::Close(_)) + } + + /// The payload: text as bytes, or the close reason. + pub fn into_data(self) -> Bytes { + match self { + Self::Text(text) => text.into(), + Self::Binary(data) | Self::Ping(data) | Self::Pong(data) => data, + Self::Close(Some(frame)) => frame.reason.into(), + Self::Close(None) => Bytes::new(), + } + } +} + +impl From for Message { + fn from(text: String) -> Self { + Self::Text(text.into()) + } +} + +impl From<&'static str> for Message { + fn from(text: &'static str) -> Self { + Self::Text(text.into()) + } +} + +impl From for Message { + fn from(data: Bytes) -> Self { + Self::Binary(data) + } +} + +impl From> for Message { + fn from(data: Vec) -> Self { + Self::Binary(data.into()) + } +} + +#[cfg(test)] +mod tests { + use bytes::Bytes; + + use super::CloseCode; + use super::CloseFrame; + use super::Message; + use super::Utf8Bytes; + + #[test] + fn utf8_bytes_round_trip_through_bytes() { + let text = Utf8Bytes::from_static("hΓ©llo"); + assert_eq!(text.as_str(), "hΓ©llo"); + let bytes = Bytes::from(text.clone()); + assert_eq!(Utf8Bytes::try_from(bytes).unwrap(), text); + assert_eq!(Utf8Bytes::from(String::from("hΓ©llo")), "hΓ©llo"); + } + + #[test] + fn utf8_bytes_reject_invalid_input() { + assert!(Utf8Bytes::try_from(Bytes::from_static(&[0xff])).is_err()); + } + + #[test] + fn only_codes_a_peer_may_send_are_allowed() { + let allowed = [1000, 1001, 1002, 1003, 1007, 1011, 1013, 1014, 3000, 4999]; + let rejected = [0, 999, 1004, 1005, 1006, 1015, 1016, 2999, 5000]; + for code in allowed { + assert!(CloseCode::new(code).is_allowed(), "{code}"); + } + for code in rejected { + assert!(!CloseCode::new(code).is_allowed(), "{code}"); + } + } + + #[test] + fn into_data_returns_each_payload() { + assert_eq!(Message::text("hi").into_data(), "hi"); + assert_eq!( + Message::binary(vec![1, 2]).into_data(), + Bytes::from_static(&[1, 2]) + ); + assert_eq!(Message::Ping(Bytes::from_static(b"p")).into_data(), "p"); + assert_eq!(Message::Pong(Bytes::from_static(b"q")).into_data(), "q"); + let close = CloseFrame { + code: CloseCode::NORMAL, + reason: Utf8Bytes::from_static("bye"), + }; + assert_eq!(Message::Close(Some(close)).into_data(), "bye"); + assert!(Message::Close(None).into_data().is_empty()); + } + + #[test] + fn kind_predicates_match_the_variant() { + assert!(Message::from("x").is_text()); + assert!(Message::from(vec![0u8]).is_binary()); + assert!(Message::Ping(Bytes::new()).is_ping()); + assert!(Message::Pong(Bytes::new()).is_pong()); + assert!(Message::Close(None).is_close()); + } +} diff --git a/tako-rs-core/src/ws/protocol.rs b/tako-rs-core/src/ws/protocol.rs new file mode 100644 index 00000000..6c0990fc --- /dev/null +++ b/tako-rs-core/src/ws/protocol.rs @@ -0,0 +1,335 @@ +use bytes::BufMut; +use bytes::Bytes; +use bytes::BytesMut; + +use super::CloseCode; +use super::CloseFrame; +use super::Error; +use super::Message; +use super::ProtocolError; +use super::Utf8Bytes; +use super::frame::OpCode; +use super::frame::encode_header; +use super::receiver::Frame; +use super::receiver::Limits; +use super::receiver::Receiver; + +const MAX_CONTROL_PAYLOAD: usize = 125; +const MAX_CLOSE_REASON: usize = MAX_CONTROL_PAYLOAD - 2; + +#[cfg(test)] +mod regression_tests; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum State { + Open, + CloseSent, + CloseReceived, + Closed, +} + +/// Connection-level protocol: close handshake, automatic replies and frame encoding. +pub(crate) struct Protocol { + receiver: Receiver, + state: State, + pending_pong: Option, + pending_close: Option, +} + +impl Protocol { + pub(crate) fn new(limits: Limits) -> Self { + Self { + receiver: Receiver::new(limits), + state: State::Open, + pending_pong: None, + pending_close: None, + } + } + + pub(crate) fn state(&self) -> State { + self.state + } + + pub(crate) fn wanted(&self, buf: &BytesMut) -> usize { + self.receiver.wanted(buf) + } + + /// Next message for the user; queues the automatic Pong and Close replies. + pub(crate) fn decode(&mut self, buf: &mut BytesMut) -> Result, ProtocolError> { + if matches!(self.state, State::CloseReceived | State::Closed) { + return Ok(None); + } + let Some(frame) = self.receiver.decode(buf)? else { + return Ok(None); + }; + Ok(Some(match frame { + Frame::Message(message) => message, + Frame::Ping(data) => { + // A newer ping replaces a waiting pong: while the write buffer is full, one pong waits. + if self.state == State::Open { + self.pending_pong = Some(data.clone()); + } + Message::Ping(data) + } + Frame::Pong(data) => Message::Pong(data), + Frame::Close(close) => self.peer_close(close), + })) + } + + /// Encodes a user message; a Close starts the closing handshake. + pub(crate) fn encode(&mut self, message: Message, out: &mut BytesMut) -> Result<(), Error> { + if self.state != State::Open { + return if message.is_close() { + Ok(()) + } else { + Err(Error::Closed) + }; + } + match message { + Message::Text(text) => put_frame(out, OpCode::Text, text.as_bytes()), + Message::Binary(data) => put_frame(out, OpCode::Binary, &data), + Message::Ping(data) => put_frame(out, OpCode::Ping, control_payload(&data)?), + Message::Pong(data) => put_frame(out, OpCode::Pong, control_payload(&data)?), + Message::Close(frame) => { + if let Some(frame) = &frame { + if !frame.code.is_allowed() { + return Err(ProtocolError::InvalidCloseFrame.into()); + } + if frame.reason.len() > MAX_CLOSE_REASON { + return Err(ProtocolError::ControlFrameTooLarge.into()); + } + } + self.pending_pong = None; + put_close(out, frame.as_ref()); + self.state = State::CloseSent; + } + } + Ok(()) + } + + pub(crate) fn drain_pong(&mut self, out: &mut BytesMut) { + if let Some(data) = self.pending_pong.take() { + put_frame(out, OpCode::Pong, &data); + } + } + + pub(crate) fn drain_close(&mut self, out: &mut BytesMut) { + if let Some(close) = self.pending_close.take() { + out.extend_from_slice(&close); + if self.state == State::CloseReceived { + self.state = State::Closed; + } + } + } + + /// Queues Close with the error's code and stops the connection. + pub(crate) fn fail(&mut self, error: ProtocolError) { + if self.state == State::Open { + self.pending_close = Some(encoded_close(error.close_code())); + } + self.pending_pong = None; + self.state = State::Closed; + } + + /// Errors with `ResetWithoutClosingHandshake` unless the closing handshake finished. + pub(crate) fn eof(&mut self) -> Result<(), ProtocolError> { + let state = std::mem::replace(&mut self.state, State::Closed); + match state { + State::CloseReceived | State::Closed => Ok(()), + State::Open | State::CloseSent => Err(ProtocolError::ResetWithoutClosingHandshake), + } + } + + pub(crate) fn close_if_open(&mut self, code: CloseCode) { + if self.state == State::Open { + self.pending_pong = None; + self.pending_close = Some(encoded_close(code)); + self.state = State::CloseSent; + } + } + + fn peer_close(&mut self, close: Option) -> Message { + self.pending_pong = None; + if self.state == State::CloseSent { + self.state = State::Closed; + return Message::Close(close); + } + let close = close.map(|frame| { + if frame.code.is_allowed() { + frame + } else { + CloseFrame { + code: CloseCode::PROTOCOL_ERROR, + reason: Utf8Bytes::from_static("Protocol violation"), + } + } + }); + let mut reply = BytesMut::new(); + put_close(&mut reply, close.as_ref()); + self.pending_close = Some(reply.freeze()); + self.state = State::CloseReceived; + Message::Close(close) + } +} + +fn put_frame(out: &mut BytesMut, opcode: OpCode, payload: &[u8]) { + encode_header(out, opcode, payload.len()); + out.extend_from_slice(payload); +} + +fn put_close(out: &mut BytesMut, frame: Option<&CloseFrame>) { + let Some(frame) = frame else { + encode_header(out, OpCode::Close, 0); + return; + }; + let reason = frame.reason.as_str(); + encode_header(out, OpCode::Close, 2 + reason.len()); + out.put_u16(frame.code.as_u16()); + out.extend_from_slice(reason.as_bytes()); +} + +fn encoded_close(code: CloseCode) -> Bytes { + let mut out = BytesMut::new(); + put_close( + &mut out, + Some(&CloseFrame { + code, + reason: Utf8Bytes::default(), + }), + ); + out.freeze() +} + +fn control_payload(data: &[u8]) -> Result<&[u8], ProtocolError> { + if data.len() > MAX_CONTROL_PAYLOAD { + return Err(ProtocolError::ControlFrameTooLarge); + } + Ok(data) +} + +#[cfg(test)] +mod tests { + use bytes::Bytes; + use bytes::BytesMut; + + use super::Protocol; + use super::State; + use crate::ws::CloseCode; + use crate::ws::Error; + use crate::ws::Message; + use crate::ws::ProtocolError; + use crate::ws::receiver::Limits; + use crate::ws::testing::CLOSE; + use crate::ws::testing::PING; + use crate::ws::testing::client_frame; + use crate::ws::testing::close_payload; + + const LIMITS: Limits = Limits { + max_frame_size: 1 << 20, + max_message_size: 1 << 20, + }; + + fn drained(protocol: &mut Protocol) -> Vec { + let mut out = BytesMut::new(); + protocol.drain_pong(&mut out); + protocol.drain_close(&mut out); + out.to_vec() + } + + fn server_close(code: u16, reason: &str) -> Vec { + let mut frame = vec![0x88, (2 + reason.len()) as u8]; + frame.extend_from_slice(&code.to_be_bytes()); + frame.extend_from_slice(reason.as_bytes()); + frame + } + + #[test] + fn a_ping_is_answered_with_its_payload() { + let mut protocol = Protocol::new(LIMITS); + let mut buf = BytesMut::from(&client_frame(PING, b"abc")[..]); + let message = protocol.decode(&mut buf).unwrap(); + assert_eq!(message, Some(Message::Ping(Bytes::from_static(b"abc")))); + assert_eq!(drained(&mut protocol), [0x8A, 3, b'a', b'b', b'c']); + } + + #[test] + fn a_ping_flood_leaves_one_pending_pong() { + let mut protocol = Protocol::new(LIMITS); + let mut buf = BytesMut::new(); + for i in 0..1000u16 { + buf.extend(client_frame(PING, &i.to_be_bytes())); + } + while protocol.decode(&mut buf).unwrap().is_some() {} + let last = 999u16.to_be_bytes(); + assert_eq!(drained(&mut protocol), [0x8A, 2, last[0], last[1]]); + } + + #[test] + fn a_peer_close_is_echoed_and_ends_reading() { + let mut protocol = Protocol::new(LIMITS); + let mut buf = BytesMut::from(&client_frame(CLOSE, &close_payload(1001, "bye"))[..]); + buf.extend(client_frame(PING, b"late")); + let Some(Message::Close(Some(frame))) = protocol.decode(&mut buf).unwrap() else { + panic!("expected a close frame"); + }; + assert_eq!(frame.code, CloseCode::GOING_AWAY); + assert_eq!(protocol.state(), State::CloseReceived); + assert_eq!(protocol.decode(&mut buf).unwrap(), None); + assert_eq!(drained(&mut protocol), server_close(1001, "bye")); + assert_eq!(protocol.state(), State::Closed); + } + + #[test] + fn a_close_code_no_peer_may_send_is_answered_with_1002() { + let mut protocol = Protocol::new(LIMITS); + let mut buf = BytesMut::from(&client_frame(CLOSE, &close_payload(1005, ""))[..]); + protocol.decode(&mut buf).unwrap(); + assert_eq!( + drained(&mut protocol), + server_close(1002, "Protocol violation") + ); + } + + #[test] + fn our_close_then_the_reply_closes_the_connection() { + let mut protocol = Protocol::new(LIMITS); + let mut out = BytesMut::new(); + protocol.encode(Message::Close(None), &mut out).unwrap(); + assert_eq!(&out[..], &[0x88, 0]); + assert_eq!(protocol.state(), State::CloseSent); + assert!(matches!( + protocol.encode(Message::text("late"), &mut out), + Err(Error::Closed) + )); + let mut buf = BytesMut::from(&client_frame(CLOSE, &close_payload(1000, ""))[..]); + assert!(protocol.decode(&mut buf).unwrap().unwrap().is_close()); + assert_eq!(protocol.state(), State::Closed); + assert_eq!(drained(&mut protocol), Vec::::new()); + } + + #[test] + fn a_protocol_error_queues_its_close_code() { + let mut protocol = Protocol::new(LIMITS); + protocol.fail(ProtocolError::InvalidUtf8); + assert_eq!(drained(&mut protocol), server_close(1007, "")); + assert_eq!(protocol.state(), State::Closed); + } + + #[test] + fn eof_is_an_error_only_before_a_closing_handshake() { + let mut open = Protocol::new(LIMITS); + assert_eq!(open.eof(), Err(ProtocolError::ResetWithoutClosingHandshake)); + let mut closing = Protocol::new(LIMITS); + let mut buf = BytesMut::from(&client_frame(CLOSE, &[])[..]); + closing.decode(&mut buf).unwrap(); + assert_eq!(closing.eof(), Ok(())); + } + + #[test] + fn close_if_open_only_closes_once() { + let mut protocol = Protocol::new(LIMITS); + protocol.close_if_open(CloseCode::NORMAL); + protocol.close_if_open(CloseCode::INTERNAL); + assert_eq!(drained(&mut protocol), server_close(1000, "")); + } +} diff --git a/tako-rs-core/src/ws/protocol/regression_tests.rs b/tako-rs-core/src/ws/protocol/regression_tests.rs new file mode 100644 index 00000000..d29e9d63 --- /dev/null +++ b/tako-rs-core/src/ws/protocol/regression_tests.rs @@ -0,0 +1,111 @@ +use bytes::Bytes; +use bytes::BytesMut; + +use super::Protocol; +use super::State; +use crate::ws::CloseCode; +use crate::ws::CloseFrame; +use crate::ws::Error; +use crate::ws::Message; +use crate::ws::ProtocolError; +use crate::ws::receiver::Limits; +use crate::ws::testing::PING; +use crate::ws::testing::client_frame; + +fn protocol() -> Protocol { + Protocol::new(Limits { + max_frame_size: 1024, + max_message_size: 1024, + }) +} + +#[test] +fn oversized_user_control_frames_are_rejected_without_changing_output() { + for message in [ + Message::Ping(Bytes::from(vec![1; 126])), + Message::Pong(Bytes::from(vec![2; 126])), + ] { + let mut protocol = protocol(); + let mut output = BytesMut::from(&b"queued"[..]); + assert!(matches!( + protocol.encode(message, &mut output), + Err(Error::Protocol(ProtocolError::ControlFrameTooLarge)) + )); + assert_eq!(&output[..], b"queued"); + assert_eq!(protocol.state(), State::Open); + } +} + +#[test] +fn invalid_user_close_codes_never_reach_the_wire() { + for code in [0, 999, 1004, 1005, 1006, 1015, 5000] { + let mut protocol = protocol(); + let mut output = BytesMut::new(); + let frame = CloseFrame { + code: CloseCode::new(code), + reason: "bye".into(), + }; + assert!( + matches!( + protocol.encode(Message::Close(Some(frame)), &mut output), + Err(Error::Protocol(ProtocolError::InvalidCloseFrame)) + ), + "{code}" + ); + assert!(output.is_empty()); + assert_eq!(protocol.state(), State::Open); + } +} + +#[test] +fn an_oversized_close_reason_is_rejected_instead_of_truncated() { + let mut protocol = protocol(); + let mut output = BytesMut::new(); + let frame = CloseFrame { + code: CloseCode::NORMAL, + reason: "Γ©".repeat(62).into(), + }; + assert!(matches!( + protocol.encode(Message::Close(Some(frame)), &mut output), + Err(Error::Protocol(ProtocolError::ControlFrameTooLarge)) + )); + assert!(output.is_empty()); + assert_eq!(protocol.state(), State::Open); +} + +#[test] +fn a_pending_pong_cannot_follow_a_user_close() { + let mut protocol = protocol(); + let mut input = BytesMut::from(client_frame(PING, b"p").as_slice()); + protocol.decode(&mut input).unwrap(); + let mut output = BytesMut::new(); + protocol.encode(Message::Close(None), &mut output).unwrap(); + protocol.drain_pong(&mut output); + assert_eq!(&output[..], &[0x88, 0]); +} + +#[test] +fn a_pending_pong_cannot_follow_the_drivers_close() { + let mut protocol = protocol(); + let mut input = BytesMut::from(client_frame(PING, b"p").as_slice()); + protocol.decode(&mut input).unwrap(); + let mut output = BytesMut::new(); + protocol.close_if_open(CloseCode::NORMAL); + protocol.drain_close(&mut output); + protocol.drain_pong(&mut output); + assert_eq!(&output[..], &[0x88, 2, 3, 232]); +} + +#[test] +fn registered_gateway_close_code_is_accepted() { + let mut protocol = protocol(); + let mut output = BytesMut::new(); + let frame = CloseFrame { + code: CloseCode::new(1014), + reason: "".into(), + }; + protocol + .encode(Message::Close(Some(frame)), &mut output) + .unwrap(); + assert_eq!(&output[..], &[0x88, 2, 3, 246]); +} diff --git a/tako-rs-core/src/ws/receiver.rs b/tako-rs-core/src/ws/receiver.rs new file mode 100644 index 00000000..3ac26033 --- /dev/null +++ b/tako-rs-core/src/ws/receiver.rs @@ -0,0 +1,404 @@ +use bytes::Buf; +use bytes::Bytes; +use bytes::BytesMut; + +use super::CloseCode; +use super::CloseFrame; +use super::Message; +use super::ProtocolError; +use super::Utf8Bytes; +use super::frame::Header; +use super::frame::OpCode; +use super::frame::parse_header; +use super::mask::unmask; +use super::utf8::Utf8Validator; + +// A frame is read at most this far ahead, so its header alone cannot allocate it. +pub(crate) const MAX_READ_AHEAD: usize = 64 * 1024; + +// Small payloads are copied out so a stored message never pins the read buffer. +const COPY_THRESHOLD: usize = 4 * 1024; + +#[derive(Clone, Copy, Debug)] +pub(crate) struct Limits { + pub max_frame_size: usize, + pub max_message_size: usize, +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum Frame { + Message(Message), + Ping(Bytes), + Pong(Bytes), + Close(Option), +} + +struct Partial { + text: bool, + data: BytesMut, + utf8: Utf8Validator, +} + +/// Turns client frames buffered in a `BytesMut` into messages and control frames. +pub(crate) struct Receiver { + limits: Limits, + header: Option
, + partial: Option, +} + +impl Receiver { + pub(crate) fn new(limits: Limits) -> Self { + Self { + limits, + header: None, + partial: None, + } + } + + /// Next complete frame from `buf`, consuming its bytes. `Ok(None)` when more input is needed. + pub(crate) fn decode(&mut self, buf: &mut BytesMut) -> Result, ProtocolError> { + loop { + let header = if let Some(header) = self.header { + header + } else { + let Some(header) = parse_header(buf, self.limits.max_frame_size)? else { + return Ok(None); + }; + self.check_order(&header)?; + buf.advance(header.header_len); + self.header = Some(header); + header + }; + let len = header.len as usize; + if buf.len() < len { + return Ok(None); + } + self.header = None; + let payload = take_payload(buf, len, header.mask); + if let Some(frame) = self.frame(header, payload)? { + return Ok(Some(frame)); + } + } + } + + /// Payload bytes the frame in progress still needs; 0 when no header is pending. + pub(crate) fn wanted(&self, buf: &BytesMut) -> usize { + self + .header + .map_or(0, |header| (header.len as usize).saturating_sub(buf.len())) + } + + fn check_order(&self, header: &Header) -> Result<(), ProtocolError> { + let max = self.limits.max_message_size; + let len = header.len as usize; + match (header.opcode, &self.partial) { + (OpCode::Continuation, None) => Err(ProtocolError::UnexpectedContinuation), + (OpCode::Text | OpCode::Binary, Some(_)) => Err(ProtocolError::ExpectedContinuation), + (OpCode::Continuation, Some(partial)) + if partial + .data + .len() + .checked_add(len) + .is_none_or(|size| size > max) => + { + Err(ProtocolError::MessageTooLarge { + size: partial.data.len().saturating_add(len), + max, + }) + } + (OpCode::Text | OpCode::Binary, None) if len > max => { + Err(ProtocolError::MessageTooLarge { size: len, max }) + } + _ => Ok(()), + } + } + + fn frame(&mut self, header: Header, payload: Bytes) -> Result, ProtocolError> { + match header.opcode { + OpCode::Ping => Ok(Some(Frame::Ping(payload))), + OpCode::Pong => Ok(Some(Frame::Pong(payload))), + OpCode::Close => close_frame(payload).map(|close| Some(Frame::Close(close))), + OpCode::Text if header.fin => Utf8Bytes::try_from(payload) + .map(|text| Some(Frame::Message(Message::Text(text)))) + .map_err(|_| ProtocolError::InvalidUtf8), + OpCode::Binary if header.fin => Ok(Some(Frame::Message(Message::Binary(payload)))), + OpCode::Text | OpCode::Binary => { + let mut partial = Partial { + text: header.opcode == OpCode::Text, + data: BytesMut::with_capacity(payload.len()), + utf8: Utf8Validator::default(), + }; + partial.push(&payload)?; + self.partial = Some(partial); + Ok(None) + } + OpCode::Continuation => { + let Some(partial) = self.partial.as_mut() else { + return Err(ProtocolError::UnexpectedContinuation); + }; + partial.push(&payload)?; + if !header.fin { + return Ok(None); + } + let Some(partial) = self.partial.take() else { + return Err(ProtocolError::UnexpectedContinuation); + }; + partial + .finish() + .map(|message| Some(Frame::Message(message))) + } + } + } +} + +impl Partial { + fn push(&mut self, payload: &[u8]) -> Result<(), ProtocolError> { + if self.text { + self.utf8.feed(payload)?; + } + self.data.extend_from_slice(payload); + Ok(()) + } + + fn finish(self) -> Result { + if !self.text { + return Ok(Message::Binary(self.data.freeze())); + } + self.utf8.finish()?; + // SAFETY: every fragment went through the validator and `finish` saw no cut character. + Ok(Message::Text(unsafe { + Utf8Bytes::from_bytes_unchecked(self.data.freeze()) + })) + } +} + +fn take_payload(buf: &mut BytesMut, len: usize, mask: [u8; 4]) -> Bytes { + if len <= COPY_THRESHOLD { + let mut payload = buf[..len].to_vec(); + buf.advance(len); + unmask(&mut payload, mask); + Bytes::from(payload) + } else { + let mut payload = buf.split_to(len); + if len > MAX_READ_AHEAD { + // The payload keeps the frame's allocation, so the rest moves out instead of pinning it. + *buf = BytesMut::from(&buf[..]); + } + unmask(&mut payload, mask); + payload.freeze() + } +} + +fn close_frame(payload: Bytes) -> Result, ProtocolError> { + match *payload { + [] => Ok(None), + [_] => Err(ProtocolError::InvalidCloseFrame), + [high, low, ..] => { + let reason = + Utf8Bytes::try_from(payload.slice(2..)).map_err(|_| ProtocolError::InvalidUtf8)?; + Ok(Some(CloseFrame { + code: CloseCode::new(u16::from_be_bytes([high, low])), + reason, + })) + } + } +} + +#[cfg(test)] +mod tests { + use bytes::Bytes; + use bytes::BytesMut; + + use super::Frame; + use super::Limits; + use super::Receiver; + use crate::ws::CloseCode; + use crate::ws::CloseFrame; + use crate::ws::Message; + use crate::ws::ProtocolError; + use crate::ws::Utf8Bytes; + use crate::ws::testing::BINARY; + use crate::ws::testing::CLOSE; + use crate::ws::testing::CONTINUE; + use crate::ws::testing::CONTINUE_END; + use crate::ws::testing::PING; + use crate::ws::testing::PONG; + use crate::ws::testing::TEXT; + use crate::ws::testing::TEXT_START; + use crate::ws::testing::client_frame; + use crate::ws::testing::close_payload; + + const LIMITS: Limits = Limits { + max_frame_size: 16 << 20, + max_message_size: 64 << 20, + }; + + fn decode_all(receiver: &mut Receiver, bytes: &[u8]) -> Result, ProtocolError> { + let mut buf = BytesMut::from(bytes); + let mut frames = Vec::new(); + while let Some(frame) = receiver.decode(&mut buf)? { + frames.push(frame); + } + assert!(buf.is_empty(), "left {} bytes", buf.len()); + Ok(frames) + } + + #[test] + fn decodes_each_kind_of_frame() { + let mut bytes = client_frame(TEXT, b"hello"); + bytes.extend(client_frame(BINARY, &[1, 2, 3])); + bytes.extend(client_frame(PING, b"p")); + bytes.extend(client_frame(PONG, b"q")); + bytes.extend(client_frame(CLOSE, &close_payload(1000, "bye"))); + bytes.extend(client_frame(CLOSE, &[])); + let frames = decode_all(&mut Receiver::new(LIMITS), &bytes).unwrap(); + assert_eq!( + frames, + vec![ + Frame::Message(Message::text("hello")), + Frame::Message(Message::Binary(Bytes::from_static(&[1, 2, 3]))), + Frame::Ping(Bytes::from_static(b"p")), + Frame::Pong(Bytes::from_static(b"q")), + Frame::Close(Some(CloseFrame { + code: CloseCode::NORMAL, + reason: Utf8Bytes::from_static("bye"), + })), + Frame::Close(None), + ] + ); + } + + #[test] + fn sixteen_buffered_frames_decode_one_by_one() { + let bytes: Vec = (0..16).flat_map(|_| client_frame(TEXT, b"hello")).collect(); + let frames = decode_all(&mut Receiver::new(LIMITS), &bytes).unwrap(); + assert_eq!(frames.len(), 16); + } + + #[test] + fn a_frame_decodes_once_its_last_byte_arrives() { + let payload: Vec = (0..300u16).map(|i| i as u8).collect(); + let bytes = client_frame(BINARY, &payload); + for split in 0..bytes.len() { + let mut receiver = Receiver::new(LIMITS); + let mut buf = BytesMut::from(&bytes[..split]); + assert_eq!(receiver.decode(&mut buf), Ok(None), "{split}"); + buf.extend_from_slice(&bytes[split..]); + let frame = receiver.decode(&mut buf).unwrap(); + assert_eq!( + frame, + Some(Frame::Message(Message::binary(payload.clone()))) + ); + } + } + + #[test] + fn one_byte_text_fragments_reassemble_around_a_ping() { + let text = "hΓ©llo".as_bytes(); + let mut bytes = client_frame(TEXT_START, &text[..1]); + for (i, byte) in text.iter().enumerate().skip(1) { + if i == 2 { + bytes.extend(client_frame(PING, b"mid")); + } + let first = if i == text.len() - 1 { + CONTINUE_END + } else { + CONTINUE + }; + bytes.extend(client_frame(first, std::slice::from_ref(byte))); + } + let frames = decode_all(&mut Receiver::new(LIMITS), &bytes).unwrap(); + assert_eq!( + frames, + vec![ + Frame::Ping(Bytes::from_static(b"mid")), + Frame::Message(Message::text("hΓ©llo")), + ] + ); + } + + #[test] + fn invalid_utf8_fails_in_the_fragment_that_carries_it() { + let mut receiver = Receiver::new(LIMITS); + let mut buf = BytesMut::from(&client_frame(TEXT_START, b"ok")[..]); + assert_eq!(receiver.decode(&mut buf), Ok(None)); + buf.extend(client_frame(CONTINUE, &[0xC0, 0x80])); + assert_eq!(receiver.decode(&mut buf), Err(ProtocolError::InvalidUtf8)); + } + + #[test] + fn fragments_out_of_order_are_rejected() { + let lone = client_frame(CONTINUE_END, b"x"); + assert_eq!( + decode_all(&mut Receiver::new(LIMITS), &lone), + Err(ProtocolError::UnexpectedContinuation) + ); + let mut nested = client_frame(TEXT_START, b"a"); + nested.extend(client_frame(TEXT, b"b")); + assert_eq!( + decode_all(&mut Receiver::new(LIMITS), &nested), + Err(ProtocolError::ExpectedContinuation) + ); + } + + #[test] + fn message_size_counts_every_fragment() { + let limits = Limits { + max_frame_size: 16, + max_message_size: 20, + }; + let mut bytes = client_frame(TEXT_START, &[b'a'; 12]); + bytes.extend(client_frame(CONTINUE_END, &[b'b'; 12])); + assert_eq!( + decode_all(&mut Receiver::new(limits), &bytes), + Err(ProtocolError::MessageTooLarge { size: 24, max: 20 }) + ); + } + + #[test] + fn continuation_length_overflow_is_rejected() { + let mut receiver = Receiver::new(Limits { + max_frame_size: usize::MAX, + max_message_size: usize::MAX, + }); + let mut input = BytesMut::from(client_frame(TEXT_START, b"x").as_slice()); + assert_eq!(receiver.decode(&mut input), Ok(None)); + let header = super::Header { + fin: true, + opcode: super::OpCode::Continuation, + mask: [0; 4], + len: usize::MAX as u64, + header_len: 14, + }; + assert_eq!( + receiver.check_order(&header), + Err(ProtocolError::MessageTooLarge { + size: usize::MAX, + max: usize::MAX, + }) + ); + } + + #[test] + fn a_large_announced_frame_allocates_nothing_up_front() { + let mut header = client_frame(BINARY, &[]); + header[1] = 0xFF; + header.splice(2..2, (16u64 << 20).to_be_bytes()); + let mut buf = BytesMut::with_capacity(64); + buf.extend_from_slice(&header); + buf.extend_from_slice(&[0; 10]); + let mut receiver = Receiver::new(LIMITS); + assert_eq!(receiver.decode(&mut buf), Ok(None)); + assert!(buf.capacity() < 128 * 1024, "{}", buf.capacity()); + assert_eq!(receiver.wanted(&buf), (16 << 20) - 10); + } + + #[test] + fn a_one_byte_close_payload_is_rejected() { + let bytes = client_frame(CLOSE, &[3]); + assert_eq!( + decode_all(&mut Receiver::new(LIMITS), &bytes), + Err(ProtocolError::InvalidCloseFrame) + ); + } +} diff --git a/tako-rs-core/src/ws/runtime.rs b/tako-rs-core/src/ws/runtime.rs new file mode 100644 index 00000000..9c281c41 --- /dev/null +++ b/tako-rs-core/src/ws/runtime.rs @@ -0,0 +1,138 @@ +//! The only runtime-specific parts of the WebSocket engine. + +#[cfg(feature = "compio")] +pub(crate) use compio_runtime::Owner; +#[cfg(feature = "compio")] +pub(crate) use compio_runtime::Shared; +#[cfg(feature = "compio")] +pub(crate) use compio_runtime::Sleep; +#[cfg(feature = "compio")] +pub(crate) use compio_runtime::lock; +#[cfg(feature = "compio")] +pub(crate) use compio_runtime::shared; +#[cfg(feature = "compio")] +pub(crate) use compio_runtime::sleep; +#[cfg(feature = "compio")] +pub(crate) use compio_runtime::spawn; +#[cfg(not(feature = "compio"))] +pub(crate) use tokio_runtime::Owner; +#[cfg(not(feature = "compio"))] +pub(crate) use tokio_runtime::Shared; +#[cfg(not(feature = "compio"))] +pub(crate) use tokio_runtime::Sleep; +#[cfg(not(feature = "compio"))] +pub(crate) use tokio_runtime::lock; +#[cfg(not(feature = "compio"))] +pub(crate) use tokio_runtime::shared; +#[cfg(not(feature = "compio"))] +pub(crate) use tokio_runtime::sleep; +#[cfg(not(feature = "compio"))] +pub(crate) use tokio_runtime::spawn; + +#[cfg(not(feature = "compio"))] +mod tokio_runtime { + use std::future::Future; + use std::pin::Pin; + use std::sync::Arc; + use std::sync::Mutex; + use std::sync::MutexGuard; + use std::sync::PoisonError; + use std::time::Duration; + + /// State shared by a connection's `WebSocket` handles and its driver. + pub(crate) type Shared = Arc>; + + pub(crate) fn shared(value: T) -> Shared { + Arc::new(Mutex::new(value)) + } + + pub(crate) fn lock(shared: &Shared) -> MutexGuard<'_, T> { + // A handler panic is caught by the driver; the core stays usable for the close frame. + shared.lock().unwrap_or_else(PoisonError::into_inner) + } + + /// The driver's task while it polls the handler: sends from there are queued until it yields. + #[derive(Default)] + pub(crate) struct Owner { + task: Option, + polling: bool, + } + + impl Owner { + pub(crate) fn enter(&mut self) { + if self.task.is_none() { + self.task = tokio::task::try_id(); + } + self.polling = true; + } + + pub(crate) fn leave(&mut self) { + self.polling = false; + } + + pub(crate) fn is_current(&self) -> bool { + self.polling && self.task.is_some() && tokio::task::try_id() == self.task + } + } + + pub(crate) fn spawn(future: impl Future + Send + 'static) { + tokio::spawn(future); + } + + pub(crate) type Sleep = Pin>; + + pub(crate) fn sleep(duration: Duration) -> Sleep { + Box::pin(tokio::time::sleep(duration)) + } +} + +#[cfg(feature = "compio")] +mod compio_runtime { + use std::cell::RefCell; + use std::cell::RefMut; + use std::future::Future; + use std::pin::Pin; + use std::rc::Rc; + use std::time::Duration; + + /// State shared by a connection's `WebSocket` handles and its driver. + pub(crate) type Shared = Rc>; + + pub(crate) fn shared(value: T) -> Shared { + Rc::new(RefCell::new(value)) + } + + pub(crate) fn lock(shared: &Shared) -> RefMut<'_, T> { + shared.borrow_mut() + } + + /// Set while the driver polls the handler; on one thread that alone identifies its sends. + #[derive(Default)] + pub(crate) struct Owner { + polling: bool, + } + + impl Owner { + pub(crate) fn enter(&mut self) { + self.polling = true; + } + + pub(crate) fn leave(&mut self) { + self.polling = false; + } + + pub(crate) fn is_current(&self) -> bool { + self.polling + } + } + + pub(crate) fn spawn(future: impl Future + 'static) { + compio::runtime::spawn(future).detach(); + } + + pub(crate) type Sleep = Pin>>; + + pub(crate) fn sleep(duration: Duration) -> Sleep { + Box::pin(compio::time::sleep(duration)) + } +} diff --git a/tako-rs-core/src/ws/socket.rs b/tako-rs-core/src/ws/socket.rs new file mode 100644 index 00000000..3517093e --- /dev/null +++ b/tako-rs-core/src/ws/socket.rs @@ -0,0 +1,330 @@ +use std::fmt; +use std::future::poll_fn; +use std::io; +use std::pin::Pin; +use std::task::Context; +use std::task::Poll; +use std::task::Waker; +use std::task::ready; + +use bytes::Buf; +use bytes::BytesMut; +use futures_util::Sink; +use futures_util::Stream; +use hyper::rt::Read; +use hyper::rt::ReadBuf; +use hyper::rt::Write; +use hyper::upgrade::Upgraded; + +use super::CloseCode; +use super::CloseFrame; +use super::Error; +use super::Message; +use super::protocol::Protocol; +use super::protocol::State; +use super::receiver::Limits; +use super::receiver::MAX_READ_AHEAD; +use super::runtime::Owner; +use super::runtime::Shared; +use super::runtime::lock; +use super::wake::Fanout; + +#[derive(Clone, Copy, Debug)] +pub(crate) struct Config { + pub limits: Limits, + pub read_buffer_size: usize, + pub write_buffer_size: usize, +} + +impl Config { + pub(crate) fn is_valid(self) -> bool { + // Leave room for a final read chunk and frame headers within one allocation. + let max = isize::MAX as usize - MAX_READ_AHEAD; + (1..=MAX_READ_AHEAD).contains(&self.read_buffer_size) + && self.write_buffer_size <= max + && self.limits.max_frame_size <= max + && self.limits.max_message_size <= max + } +} + +impl Default for Config { + fn default() -> Self { + Self { + limits: Limits { + max_frame_size: 16 << 20, + max_message_size: 64 << 20, + }, + read_buffer_size: 4 * 1024, + write_buffer_size: 64 * 1024, + } + } +} + +pub(crate) struct Core { + io: Upgraded, + read_buf: BytesMut, + write_buf: BytesMut, + protocol: Protocol, + config: Config, + read_done: bool, + // Sticky, so a handler that only sends learns of a dead peer on its next send. + write_failed: Option, + read_wake: Fanout, + write_wake: Fanout, + pub(crate) owner: Owner, + pub(crate) handles: usize, + pub(crate) driver: Option, + pub(crate) finished: bool, +} + +impl Core { + pub(crate) fn new(io: Upgraded, config: Config) -> Self { + Self { + io, + read_buf: BytesMut::with_capacity(config.read_buffer_size), + write_buf: BytesMut::new(), + protocol: Protocol::new(config.limits), + config, + read_done: false, + write_failed: None, + read_wake: Fanout::new(), + write_wake: Fanout::new(), + owner: Owner::default(), + handles: 0, + driver: None, + finished: false, + } + } + + pub(crate) fn close_if_open(&mut self, code: CloseCode) { + self.protocol.close_if_open(code); + } + + pub(crate) fn wake_waiting(&self) { + self.read_wake.wake_all(); + self.write_wake.wake_all(); + } + + pub(crate) fn poll_recv(&mut self, cx: &mut Context<'_>) -> Poll>> { + if self.read_done || self.finished { + return Poll::Ready(None); + } + loop { + match self.protocol.decode(&mut self.read_buf) { + Ok(Some(message)) => { + // Queued now, a pong keeps its place before later replies; a full buffer coalesces it. + if message.is_ping() + && (self.write_buf.is_empty() || self.write_buf.len() < self.config.write_buffer_size) + { + self.protocol.drain_pong(&mut self.write_buf); + } + return Poll::Ready(Some(Ok(message))); + } + Ok(None) => {} + Err(error) => { + self.protocol.fail(error); + return self.end(Some(Err(Error::Protocol(error)))); + } + } + if matches!(self.protocol.state(), State::CloseReceived | State::Closed) { + let _ = self.poll_write_out(cx); + return self.end(None); + } + if let Poll::Ready(Err(error)) = self.poll_write_out(cx) { + return self.end(Some(Err(error))); + } + match ready!(self.poll_read_more(cx)) { + Ok(0) => { + let reset = self.protocol.eof().err(); + return self.end(reset.map(|error| Err(Error::Protocol(error)))); + } + Ok(_) => {} + Err(error) => return self.end(Some(Err(Error::Io(error)))), + } + } + } + + fn end(&mut self, last: Option>) -> Poll>> { + self.read_done = true; + Poll::Ready(last) + } + + fn poll_read_more(&mut self, cx: &mut Context<'_>) -> Poll> { + let want = self + .protocol + .wanted(&self.read_buf) + .min(MAX_READ_AHEAD) + .max(self.config.read_buffer_size); + if self.read_buf.is_empty() && self.read_buf.capacity() > MAX_READ_AHEAD { + self.read_buf = BytesMut::new(); + } + self.read_buf.reserve(want); + let mut proxy = Context::from_waker(self.read_wake.register(cx)); + let filled = { + // Offering only `want` bytes bounds the work of transports that zero-fill the slice. + let spare = self.read_buf.spare_capacity_mut(); + let len = spare.len().min(want); + let mut buf = ReadBuf::uninit(&mut spare[..len]); + ready!(Pin::new(&mut self.io).poll_read(&mut proxy, buf.unfilled()))?; + buf.filled().len() + }; + // SAFETY: the transport initialized `filled` bytes of the spare capacity. + unsafe { self.read_buf.set_len(self.read_buf.len() + filled) }; + Poll::Ready(Ok(filled)) + } + + pub(crate) fn poll_ready_send(&mut self, cx: &mut Context<'_>) -> Poll> { + if let Some(kind) = self.write_failed { + return Poll::Ready(Err(Error::Io(kind.into()))); + } + if self.finished { + return Poll::Ready(Err(Error::Closed)); + } + if self.write_buf.len() < self.config.write_buffer_size { + return Poll::Ready(Ok(())); + } + match self.poll_write_out(cx) { + Poll::Pending if self.write_buf.len() >= self.config.write_buffer_size => Poll::Pending, + Poll::Pending => Poll::Ready(Ok(())), + ready @ Poll::Ready(_) => ready, + } + } + + pub(crate) fn start_send(&mut self, message: Message) -> Result<(), Error> { + if let Some(kind) = self.write_failed { + return Err(Error::Io(kind.into())); + } + if self.finished { + return Err(Error::Closed); + } + self.protocol.encode(message, &mut self.write_buf) + } + + pub(crate) fn poll_write_out(&mut self, cx: &mut Context<'_>) -> Poll> { + if let Some(kind) = self.write_failed { + return Poll::Ready(Err(Error::Io(kind.into()))); + } + let mut proxy = Context::from_waker(self.write_wake.register(cx)); + let result = loop { + // A peer that never reads leaves one pong waiting instead of growing the buffer. + if self.write_buf.is_empty() || self.write_buf.len() < self.config.write_buffer_size { + self.protocol.drain_pong(&mut self.write_buf); + } + self.protocol.drain_close(&mut self.write_buf); + if self.write_buf.is_empty() { + break ready!(Pin::new(&mut self.io).poll_flush(&mut proxy)); + } + match ready!(Pin::new(&mut self.io).poll_write(&mut proxy, &self.write_buf)) { + Ok(0) => break Err(io::ErrorKind::WriteZero.into()), + Ok(written) => self.write_buf.advance(written), + Err(error) => break Err(error), + } + }; + if let Err(error) = &result { + self.write_failed = Some(error.kind()); + } + Poll::Ready(result.map_err(Error::Io)) + } + + pub(crate) fn poll_shutdown(&mut self, cx: &mut Context<'_>) -> Poll> { + let mut proxy = Context::from_waker(self.write_wake.register(cx)); + ready!(Pin::new(&mut self.io).poll_shutdown(&mut proxy))?; + Poll::Ready(Ok(())) + } +} + +/// An upgraded WebSocket connection. `send` queues frames; they go out together once the +/// handler waits, when `recv` runs out of input, past `write_buffer_size`, or on `flush`. +pub struct WebSocket { + shared: Shared, +} + +impl WebSocket { + pub(crate) fn new(shared: Shared) -> Self { + lock(&shared).handles += 1; + Self { shared } + } + + /// The next message; `None` once the connection is closed. Cancel-safe. + pub async fn recv(&mut self) -> Option> { + poll_fn(|cx| lock(&self.shared).poll_recv(cx)).await + } + + /// Queues a message. Called from a task other than the handler's, it writes at once. + pub async fn send(&mut self, message: Message) -> Result<(), Error> { + poll_fn(|cx| lock(&self.shared).poll_ready_send(cx)).await?; + let write_through = { + let mut core = lock(&self.shared); + core.start_send(message)?; + !core.owner.is_current() + }; + if write_through { + self.flush().await + } else { + Ok(()) + } + } + + /// Writes every queued frame now. + pub async fn flush(&mut self) -> Result<(), Error> { + poll_fn(|cx| lock(&self.shared).poll_write_out(cx)).await + } + + /// Sends a close frame now, or flushes the reply if the peer already closed. + /// Repeated calls only flush; the peer's reply still arrives through `recv`. + pub async fn close(&mut self, frame: Option) -> Result<(), Error> { + lock(&self.shared).start_send(Message::Close(frame))?; + self.flush().await + } +} + +impl fmt::Debug for WebSocket { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("WebSocket").finish_non_exhaustive() + } +} + +impl Drop for WebSocket { + fn drop(&mut self) { + let mut core = lock(&self.shared); + core.handles -= 1; + if core.handles == 0 + && let Some(driver) = core.driver.take() + { + driver.wake(); + } + } +} + +impl Stream for WebSocket { + type Item = Result; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + lock(&self.shared).poll_recv(cx) + } +} + +impl Sink for WebSocket { + type Error = Error; + + fn poll_ready(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + lock(&self.shared).poll_ready_send(cx) + } + + fn start_send(self: Pin<&mut Self>, message: Message) -> Result<(), Error> { + lock(&self.shared).start_send(message) + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + lock(&self.shared).poll_write_out(cx) + } + + fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let mut core = lock(&self.shared); + core.close_if_open(CloseCode::NORMAL); + core.poll_write_out(cx) + } +} + +#[cfg(test)] +mod tests; diff --git a/tako-rs-core/src/ws/socket/tests.rs b/tako-rs-core/src/ws/socket/tests.rs new file mode 100644 index 00000000..cfca2d55 --- /dev/null +++ b/tako-rs-core/src/ws/socket/tests.rs @@ -0,0 +1,293 @@ +use std::time::Duration; + +use bytes::Bytes; +use futures_util::SinkExt; +use futures_util::StreamExt; +use tokio::io::AsyncReadExt; +use tokio::io::AsyncWriteExt; +use tokio::io::DuplexStream; + +use super::Config; +use super::Core; +use super::WebSocket; +use crate::ws::Error; +use crate::ws::Message; +use crate::ws::ProtocolError; +use crate::ws::runtime::Shared; +use crate::ws::runtime::lock; +use crate::ws::runtime::shared; +use crate::ws::testing::BINARY; +use crate::ws::testing::CONTINUE_END; +use crate::ws::testing::Counts; +use crate::ws::testing::PING; +use crate::ws::testing::TEXT; +use crate::ws::testing::TEXT_START; +use crate::ws::testing::client_frame; +use crate::ws::testing::upgraded_pair; + +async fn socket(capacity: usize) -> (WebSocket, DuplexStream, Counts, Shared) { + let (upgraded, client, counts) = upgraded_pair(capacity).await; + let core = shared(Core::new(upgraded, Config::default())); + (WebSocket::new(core.clone()), client, counts, core) +} + +#[cfg_attr(not(feature = "compio"), tokio::test)] +#[cfg_attr(feature = "compio", compio::test)] +async fn buffered_frames_cost_one_read() { + let (mut ws, mut client, counts, _) = socket(1 << 20).await; + let batch: Vec = (0..16).flat_map(|_| client_frame(TEXT, b"hello")).collect(); + client.write_all(&batch).await.unwrap(); + for _ in 0..16 { + assert_eq!(ws.recv().await.unwrap().unwrap(), Message::text("hello")); + } + assert_eq!(counts.reads(), 1); +} + +#[cfg_attr(not(feature = "compio"), tokio::test)] +#[cfg_attr(feature = "compio", compio::test)] +async fn the_owner_task_queues_until_flush() { + let (mut ws, _client, counts, core) = socket(1 << 20).await; + let task = crate::ws::testing::spawn(async move { + lock(&core).owner.enter(); + ws.send(Message::text("a")).await.unwrap(); + ws.send(Message::text("b")).await.unwrap(); + let queued = counts.writes(); + ws.flush().await.unwrap(); + (queued, counts.writes()) + }); + assert_eq!(task.await.unwrap(), (0, 1)); +} + +#[cfg_attr(not(feature = "compio"), tokio::test)] +#[cfg_attr(feature = "compio", compio::test)] +async fn other_tasks_write_through() { + let (mut ws, mut client, _, _) = socket(1 << 20).await; + ws.send(Message::text("hi")).await.unwrap(); + let mut frame = [0; 4]; + client.read_exact(&mut frame).await.unwrap(); + assert_eq!(frame, [0x81, 2, b'h', b'i']); +} + +#[cfg_attr(not(feature = "compio"), tokio::test)] +#[cfg_attr(feature = "compio", compio::test)] +async fn close_writes_at_once() { + let (mut ws, mut client, _, _) = socket(1 << 20).await; + ws.close(None).await.unwrap(); + let mut frame = [0; 2]; + client.read_exact(&mut frame).await.unwrap(); + assert_eq!(frame, [0x88, 0]); + assert!(matches!( + ws.send(Message::text("late")).await, + Err(Error::Closed) + )); +} + +#[cfg_attr(not(feature = "compio"), tokio::test)] +#[cfg_attr(feature = "compio", compio::test)] +async fn close_after_a_peer_close_flushes_the_reply_and_is_idempotent() { + let (mut ws, mut client, counts, _) = socket(1 << 20).await; + client + .write_all(&client_frame(crate::ws::testing::CLOSE, &[3, 232])) + .await + .unwrap(); + assert!(ws.recv().await.unwrap().unwrap().is_close()); + ws.close(None).await.unwrap(); + ws.close(None).await.unwrap(); + let mut reply = [0; 4]; + client.read_exact(&mut reply).await.unwrap(); + assert_eq!(reply, [0x88, 2, 3, 232]); + assert_eq!(counts.writes(), 1); +} + +#[cfg_attr(not(feature = "compio"), tokio::test(start_paused = true))] +#[cfg_attr(feature = "compio", compio::test)] +async fn zero_write_threshold_still_answers_pings() { + let (upgraded, mut client, _) = upgraded_pair(1024).await; + let config = Config { + write_buffer_size: 0, + ..Config::default() + }; + let core = shared(Core::new(upgraded, config)); + let mut ws = WebSocket::new(core); + client.write_all(&client_frame(PING, b"p")).await.unwrap(); + assert!(ws.recv().await.unwrap().unwrap().is_ping()); + ws.flush().await.unwrap(); + let mut pong = [0; 3]; + crate::ws::testing::timeout(Duration::from_millis(100), client.read_exact(&mut pong)) + .await + .expect("automatic pong reaches a peer with a zero write threshold") + .unwrap(); + assert_eq!(pong, [0x8a, 1, b'p']); +} + +#[cfg_attr(not(feature = "compio"), tokio::test)] +#[cfg_attr(feature = "compio", compio::test)] +async fn every_ping_gets_its_pong_in_order() { + let (mut ws, mut client, _, _) = socket(1 << 20).await; + let pings: Vec = (0..10u8).flat_map(|i| client_frame(PING, &[i])).collect(); + client.write_all(&pings).await.unwrap(); + for i in 0..10u8 { + assert_eq!( + ws.recv().await.unwrap().unwrap(), + Message::Ping(Bytes::from(vec![i])) + ); + } + ws.flush().await.unwrap(); + let mut pongs = [0; 30]; + crate::ws::testing::timeout(Duration::from_millis(500), client.read_exact(&mut pongs)) + .await + .expect("a pong for every ping") + .unwrap(); + let expected: Vec = (0..10u8).flat_map(|i| [0x8A, 1, i]).collect(); + assert_eq!(pongs.as_slice(), expected.as_slice()); +} + +#[cfg_attr(not(feature = "compio"), tokio::test)] +#[cfg_attr(feature = "compio", compio::test)] +async fn a_pong_precedes_the_reply_to_the_message_its_ping_interrupted() { + let (mut ws, mut client, _, _) = socket(1 << 20).await; + let mut input = client_frame(TEXT_START, b"ab"); + input.extend(client_frame(PING, b"p")); + input.extend(client_frame(CONTINUE_END, b"cd")); + client.write_all(&input).await.unwrap(); + assert!(ws.recv().await.unwrap().unwrap().is_ping()); + let message = ws.recv().await.unwrap().unwrap(); + ws.send(message).await.unwrap(); + let mut output = [0; 9]; + client.read_exact(&mut output).await.unwrap(); + assert_eq!(output, [0x8A, 1, b'p', 0x81, 4, b'a', b'b', b'c', b'd']); +} + +#[cfg_attr(not(feature = "compio"), tokio::test)] +#[cfg_attr(feature = "compio", compio::test)] +async fn a_ping_flood_from_a_peer_that_never_reads_stays_bounded() { + let (mut ws, mut client, _, core) = socket(1024).await; + let observer = core.clone(); + let reader = crate::ws::testing::spawn(async move { while let Some(Ok(_)) = ws.recv().await {} }); + let ping = client_frame(PING, &[7; 125]); + for _ in 0..2_000 { + client.write_all(&ping).await.unwrap(); + crate::ws::testing::yield_now().await; + } + let queued = lock(&observer).write_buf.len(); + assert!(queued <= 64 * 1024 + 127, "{queued}"); + crate::ws::testing::cancel(reader).await; +} + +#[cfg_attr(not(feature = "compio"), tokio::test(start_paused = true))] +#[cfg_attr(feature = "compio", compio::test)] +async fn a_writer_and_a_reader_on_two_tasks_both_wake() { + let (ws, mut client, _, _) = socket(1024).await; + let (mut sink, mut stream) = ws.split(); + let writer = crate::ws::testing::spawn(async move { + sink + .send(Message::Binary(Bytes::from(vec![1; 60_000]))) + .await + }); + crate::ws::runtime::sleep(Duration::from_millis(10)).await; + let reader = crate::ws::testing::spawn(async move { stream.next().await.is_none() }); + crate::ws::runtime::sleep(Duration::from_millis(10)).await; + let mut frame = vec![0; 60_004]; + client.read_exact(&mut frame).await.unwrap(); + crate::ws::testing::timeout(Duration::from_secs(1), writer) + .await + .expect("the writer is woken") + .unwrap() + .unwrap(); + crate::ws::testing::cancel(reader).await; +} + +#[cfg_attr(not(feature = "compio"), tokio::test)] +#[cfg_attr(feature = "compio", compio::test)] +async fn a_large_frame_does_not_pin_a_large_read_buffer() { + let (mut ws, mut client, _, core) = socket(4 << 20).await; + client + .write_all(&client_frame(BINARY, &vec![7; 1 << 20])) + .await + .unwrap(); + let large = ws.recv().await.unwrap().unwrap(); + assert_eq!(large.into_data().len(), 1 << 20); + let small = client_frame(BINARY, &[1; 1024]); + let writer = crate::ws::testing::spawn(async move { + for _ in 0..2048 { + client.write_all(&small).await.unwrap(); + } + client + }); + for _ in 0..2048 { + ws.recv().await.unwrap().unwrap(); + } + writer.await.unwrap(); + let capacity = lock(&core).read_buf.capacity(); + assert!(capacity <= 64 * 1024, "{capacity}"); +} + +#[cfg_attr(not(feature = "compio"), tokio::test)] +#[cfg_attr(feature = "compio", compio::test)] +async fn a_peer_that_never_reads_stops_the_sender() { + let (mut ws, _client, _, core) = socket(64 * 1024).await; + let observer = core.clone(); + let mut flood = crate::ws::testing::spawn(async move { + lock(&core).owner.enter(); + let payload = Bytes::from(vec![0; 1024]); + for _ in 0..10_000 { + ws.send(Message::Binary(payload.clone())).await.unwrap(); + } + }); + assert!( + crate::ws::testing::timeout(Duration::from_millis(500), &mut flood) + .await + .is_err() + ); + let capacity = lock(&observer).write_buf.capacity(); + assert!(capacity < 256 * 1024, "{capacity}"); + crate::ws::testing::cancel(flood).await; +} + +#[cfg_attr(not(feature = "compio"), tokio::test)] +#[cfg_attr(feature = "compio", compio::test)] +async fn a_protocol_error_is_reported_once_and_answered() { + let (mut ws, mut client, _, _) = socket(1 << 20).await; + client.write_all(&[0x82, 0x00]).await.unwrap(); + assert!(matches!( + ws.recv().await, + Some(Err(Error::Protocol(ProtocolError::UnmaskedFrame))) + )); + assert!(ws.recv().await.is_none()); + ws.flush().await.unwrap(); + let mut close = [0; 4]; + client.read_exact(&mut close).await.unwrap(); + assert_eq!(close, [0x88, 2, 0x03, 0xEA]); +} + +#[cfg_attr(not(feature = "compio"), tokio::test)] +#[cfg_attr(feature = "compio", compio::test)] +async fn eof_without_a_close_frame_is_a_reset() { + let (mut ws, client, _, _) = socket(1 << 20).await; + drop(client); + assert!(matches!( + ws.recv().await, + Some(Err(Error::Protocol( + ProtocolError::ResetWithoutClosingHandshake + ))) + )); + assert!(ws.recv().await.is_none()); +} + +#[cfg_attr(not(feature = "compio"), tokio::test)] +#[cfg_attr(feature = "compio", compio::test)] +async fn a_large_announced_frame_grows_the_buffer_with_its_bytes() { + let (mut ws, mut client, _, core) = socket(1 << 20).await; + let mut header = client_frame(BINARY, &[]); + header[1] = 0xFF; + header.splice(2..2, (16u64 << 20).to_be_bytes()); + client.write_all(&header).await.unwrap(); + client.write_all(&[0; 10]).await.unwrap(); + assert!( + crate::ws::testing::timeout(Duration::from_millis(100), ws.recv()) + .await + .is_err() + ); + let capacity = lock(&core).read_buf.capacity(); + assert!(capacity <= 128 * 1024, "{capacity}"); +} diff --git a/tako-rs-core/src/ws/testing.rs b/tako-rs-core/src/ws/testing.rs new file mode 100644 index 00000000..7f343ae4 --- /dev/null +++ b/tako-rs-core/src/ws/testing.rs @@ -0,0 +1,222 @@ +//! Client-side frame builders for tests. + +pub(crate) const MASK: [u8; 4] = [0x37, 0xfa, 0x21, 0x3d]; +pub(crate) const TEXT: u8 = 0x81; +pub(crate) const BINARY: u8 = 0x82; +pub(crate) const CLOSE: u8 = 0x88; +pub(crate) const PING: u8 = 0x89; +pub(crate) const PONG: u8 = 0x8A; +pub(crate) const TEXT_START: u8 = 0x01; +pub(crate) const CONTINUE: u8 = 0x00; +pub(crate) const CONTINUE_END: u8 = 0x80; + +/// A masked client frame whose first byte (FIN and opcode) is `first`. +pub(crate) fn client_frame(first: u8, payload: &[u8]) -> Vec { + let mut frame = vec![first]; + match payload.len() { + len @ 0..=125 => frame.push(0x80 | len as u8), + len @ 126..=0xFFFF => { + frame.push(0xFE); + frame.extend_from_slice(&(len as u16).to_be_bytes()); + } + len => { + frame.push(0xFF); + frame.extend_from_slice(&(len as u64).to_be_bytes()); + } + } + frame.extend_from_slice(&MASK); + frame.extend( + payload + .iter() + .zip(MASK.iter().cycle()) + .map(|(byte, key)| byte ^ key), + ); + frame +} + +/// The payload of a close frame with `code` and `reason`. +pub(crate) fn close_payload(code: u16, reason: &str) -> Vec { + let mut payload = code.to_be_bytes().to_vec(); + payload.extend_from_slice(reason.as_bytes()); + payload +} + +pub(crate) use pipe::Counts; +pub(crate) use pipe::upgraded_pair; + +mod pipe { + use std::convert::Infallible; + use std::io; + use std::pin::Pin; + use std::sync::Arc; + use std::sync::atomic::AtomicUsize; + use std::sync::atomic::Ordering; + use std::task::Context; + use std::task::Poll; + + use bytes::Bytes; + use http_body_util::Empty; + use hyper::upgrade::Upgraded; + use hyper_util::rt::TokioIo; + use tokio::io::AsyncRead; + use tokio::io::AsyncReadExt; + use tokio::io::AsyncWrite; + use tokio::io::AsyncWriteExt; + use tokio::io::DuplexStream; + use tokio::io::ReadBuf; + + /// Successful reads and writes on the server end of a test pipe. + #[derive(Clone, Default)] + pub(crate) struct Counts { + reads: Arc, + writes: Arc, + } + + impl Counts { + pub(crate) fn reads(&self) -> usize { + self.reads.load(Ordering::SeqCst) + } + + pub(crate) fn writes(&self) -> usize { + self.writes.load(Ordering::SeqCst) + } + } + + struct Counting { + inner: DuplexStream, + counts: Counts, + } + + impl AsyncRead for Counting { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + let before = buf.filled().len(); + let result = Pin::new(&mut self.inner).poll_read(cx, buf); + if matches!(result, Poll::Ready(Ok(()))) && buf.filled().len() > before { + self.counts.reads.fetch_add(1, Ordering::SeqCst); + } + result + } + } + + impl AsyncWrite for Counting { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + let result = Pin::new(&mut self.inner).poll_write(cx, buf); + if matches!(result, Poll::Ready(Ok(written)) if written > 0) { + self.counts.writes.fetch_add(1, Ordering::SeqCst); + } + result + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_flush(cx) + } + + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_shutdown(cx) + } + } + + /// The server end of an upgraded connection over a pipe of `capacity` bytes, the client end, + /// and counters for the server end that start at zero after the upgrade. + pub(crate) async fn upgraded_pair(capacity: usize) -> (Upgraded, DuplexStream, Counts) { + let (mut client, server) = tokio::io::duplex(capacity); + let counts = Counts::default(); + let io = Counting { + inner: server, + counts: counts.clone(), + }; + let (upgrades, mut upgrade) = tokio::sync::mpsc::unbounded_channel(); + let service = hyper::service::service_fn(move |mut request| { + let _ = upgrades.send(hyper::upgrade::on(&mut request)); + async { + Ok::<_, Infallible>( + hyper::Response::builder() + .status(hyper::StatusCode::SWITCHING_PROTOCOLS) + .header(hyper::header::UPGRADE, "websocket") + .header(hyper::header::CONNECTION, "upgrade") + .body(Empty::::new()) + .unwrap(), + ) + } + }); + crate::ws::runtime::spawn(async move { + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection(TokioIo::new(io), service) + .with_upgrades() + .await; + }); + client + .write_all( + b"GET / HTTP/1.1\r\nHost: localhost\r\nUpgrade: websocket\r\nConnection: upgrade\r\n\r\n", + ) + .await + .unwrap(); + let mut head = Vec::new(); + while !head.ends_with(b"\r\n\r\n") { + head.push(client.read_u8().await.unwrap()); + } + let upgraded = upgrade.recv().await.unwrap().await.unwrap(); + counts.reads.store(0, Ordering::SeqCst); + counts.writes.store(0, Ordering::SeqCst); + (upgraded, client, counts) + } +} + +#[cfg(feature = "compio")] +pub(crate) use compio::runtime::spawn; +#[cfg(not(feature = "compio"))] +pub(crate) use tokio::spawn; + +#[cfg(not(feature = "compio"))] +pub(crate) trait TaskFuture: std::future::Future + Send + 'static {} +#[cfg(not(feature = "compio"))] +impl TaskFuture for F {} +#[cfg(feature = "compio")] +pub(crate) trait TaskFuture: std::future::Future + 'static {} +#[cfg(feature = "compio")] +impl TaskFuture for F {} + +pub(crate) async fn timeout( + duration: std::time::Duration, + future: F, +) -> Result { + let timer = crate::ws::runtime::sleep(duration); + futures_util::pin_mut!(future); + match futures_util::future::select(future, timer).await { + futures_util::future::Either::Left((output, _)) => Ok(output), + futures_util::future::Either::Right(_) => Err(()), + } +} + +pub(crate) async fn yield_now() { + let mut yielded = false; + std::future::poll_fn(|cx| { + if yielded { + std::task::Poll::Ready(()) + } else { + yielded = true; + cx.waker().wake_by_ref(); + std::task::Poll::Pending + } + }) + .await; +} + +#[cfg(not(feature = "compio"))] +pub(crate) async fn cancel(task: tokio::task::JoinHandle) { + task.abort(); + let _ = task.await; +} + +#[cfg(feature = "compio")] +pub(crate) async fn cancel(task: compio::runtime::JoinHandle) { + let _ = task.cancel().await; +} diff --git a/tako-rs-core/src/ws/upgrade.rs b/tako-rs-core/src/ws/upgrade.rs new file mode 100644 index 00000000..d3060fe1 --- /dev/null +++ b/tako-rs-core/src/ws/upgrade.rs @@ -0,0 +1,247 @@ +use std::future::Future; +use std::marker::PhantomData; +use std::time::Duration; + +use futures_util::future::Either; +use http::HeaderValue; +use http::StatusCode; +use http::header; +use hyper::upgrade::OnUpgrade; + +use super::WebSocket; +use super::driver::Driver; +use super::handshake; +use super::runtime; +use super::socket::Config; +use super::socket::Core; +use crate::body::TakoBody; +use crate::responder::Responder; +use crate::types::Request; +use crate::types::Response; + +#[cfg(test)] +mod tests; + +/// Answers an HTTP/1.1 upgrade request and runs `handler` on the WebSocket connection. +/// Invalid buffer sizes or limits that exceed addressable allocation sizes return HTTP 500. +pub struct WebSocketUpgrade { + request: Request, + handler: H, + protocols: Vec<&'static str>, + allowed_origins: Option>, + config: Config, + upgrade_timeout: Option, + max_lifetime: Option, + _future: PhantomData Fut>, +} + +impl WebSocketUpgrade +where + H: FnOnce(WebSocket) -> Fut, + Fut: Future, +{ + pub fn new(request: Request, handler: H) -> Self { + Self { + request, + handler, + protocols: Vec::new(), + allowed_origins: None, + config: Config::default(), + upgrade_timeout: None, + max_lifetime: None, + _future: PhantomData, + } + } + + /// Subprotocols the server speaks, in order of preference. + pub fn protocols(mut self, list: I) -> Self + where + I: IntoIterator, + S: Into<&'static str>, + { + self.protocols = list.into_iter().map(Into::into).collect(); + self + } + + /// Accepts only these `Origin` values; without the list every origin passes. + pub fn allowed_origins(mut self, origins: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.allowed_origins = Some(origins.into_iter().map(Into::into).collect()); + self + } + + /// Largest frame payload accepted, 16 MiB by default. + pub fn max_frame_size(mut self, n: usize) -> Self { + self.config.limits.max_frame_size = n; + self + } + + /// Largest message accepted, 64 MiB by default. + pub fn max_message_size(mut self, n: usize) -> Self { + self.config.limits.max_message_size = n; + self + } + + /// Initial read buffer capacity, 4 KiB by default; the buffer grows to fit larger frames. + /// Must be between 1 byte and 64 KiB. + pub fn read_buffer_size(mut self, n: usize) -> Self { + self.config.read_buffer_size = n; + self + } + + /// Queued bytes past which `send` writes at once, 64 KiB by default. + pub fn write_buffer_size(mut self, n: usize) -> Self { + self.config.write_buffer_size = n; + self + } + + /// How long to wait for the server to hand over the upgraded connection. + pub fn upgrade_timeout(mut self, d: Duration) -> Self { + self.upgrade_timeout = Some(d); + self + } + + /// Closes the connection with 1001 once it has been open this long. + pub fn max_lifetime(mut self, d: Duration) -> Self { + self.max_lifetime = Some(d); + self + } + + fn accept(self) -> (Response, Option>) { + if !self.config.is_valid() { + tracing::error!(config = ?self.config, "invalid WebSocket buffer configuration"); + return ( + handshake::rejection(StatusCode::INTERNAL_SERVER_ERROR), + None, + ); + } + let accept = match handshake::accept(&self.request) { + Ok(accept) => accept, + Err(status) => return (handshake::rejection(status), None), + }; + if !handshake::origin_allowed(self.request.headers(), self.allowed_origins.as_deref()) { + let forbidden = http::Response::builder() + .status(StatusCode::FORBIDDEN) + .body(TakoBody::from("origin not allowed")) + .expect("valid forbidden response"); + return (forbidden, None); + } + let mut response = http::Response::builder() + .status(StatusCode::SWITCHING_PROTOCOLS) + .header(header::UPGRADE, "websocket") + .header(header::CONNECTION, "Upgrade") + .header(header::SEC_WEBSOCKET_ACCEPT, accept); + if let Some(protocol) = + handshake::negotiate_subprotocol(self.request.headers(), &self.protocols) + { + response = response.header( + header::SEC_WEBSOCKET_PROTOCOL, + HeaderValue::from_static(protocol), + ); + } + let response = response + .body(TakoBody::empty()) + .expect("valid upgrade response"); + let Self { + mut request, + handler, + config, + upgrade_timeout, + max_lifetime, + .. + } = self; + let session = request + .extensions_mut() + .remove::() + .map(|on_upgrade| Session { + on_upgrade, + handler, + config, + upgrade_timeout, + max_lifetime, + }); + (response, session) + } +} + +struct Session { + on_upgrade: OnUpgrade, + handler: H, + config: Config, + upgrade_timeout: Option, + max_lifetime: Option, +} + +impl Session { + async fn run(self) + where + H: FnOnce(WebSocket) -> Fut, + Fut: Future, + { + let upgraded = match self.upgrade_timeout { + Some(limit) => { + match futures_util::future::select(self.on_upgrade, runtime::sleep(limit)).await { + Either::Left((Ok(upgraded), _)) => upgraded, + _ => return, + } + } + None => match self.on_upgrade.await { + Ok(upgraded) => upgraded, + Err(_) => return, + }, + }; + let core = runtime::shared(Core::new(upgraded, self.config)); + let socket = WebSocket::new(core.clone()); + Driver::new(core, (self.handler)(socket), self.max_lifetime).await; + } +} + +/// Runs `handler` on the connection once the server hands it over, with default settings. +#[cfg(all(feature = "async-graphql", not(feature = "compio")))] +pub(crate) fn spawn_session(on_upgrade: OnUpgrade, handler: H) +where + H: FnOnce(WebSocket) -> Fut + Send + 'static, + Fut: Future + Send + 'static, +{ + let session = Session { + on_upgrade, + handler, + config: Config::default(), + upgrade_timeout: None, + max_lifetime: None, + }; + runtime::spawn(session.run()); +} + +#[cfg(not(feature = "compio"))] +impl Responder for WebSocketUpgrade +where + H: FnOnce(WebSocket) -> Fut + Send + 'static, + Fut: Future + Send + 'static, +{ + fn into_response(self) -> Response { + let (response, session) = self.accept(); + if let Some(session) = session { + runtime::spawn(session.run()); + } + response + } +} + +#[cfg(feature = "compio")] +impl Responder for WebSocketUpgrade +where + H: FnOnce(WebSocket) -> Fut + 'static, + Fut: Future + 'static, +{ + fn into_response(self) -> Response { + let (response, session) = self.accept(); + if let Some(session) = session { + runtime::spawn(session.run()); + } + response + } +} diff --git a/tako-rs-core/src/ws/upgrade/tests.rs b/tako-rs-core/src/ws/upgrade/tests.rs new file mode 100644 index 00000000..8ccfd0e0 --- /dev/null +++ b/tako-rs-core/src/ws/upgrade/tests.rs @@ -0,0 +1,77 @@ +use super::WebSocketUpgrade; +use crate::body::TakoBody; +use crate::types::Request; + +fn request() -> Request { + http::Request::builder() + .header("host", "localhost") + .header("connection", "upgrade") + .header("upgrade", "websocket") + .header("sec-websocket-version", "13") + .header("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ==") + .body(TakoBody::empty()) + .unwrap() +} + +#[test] +fn invalid_buffer_sizes_are_rejected_before_upgrading() { + for size in [0, 65_537, usize::MAX] { + let (response, session) = WebSocketUpgrade::new(request(), |_| async {}) + .read_buffer_size(size) + .accept(); + assert_eq!( + response.status(), + http::StatusCode::INTERNAL_SERVER_ERROR, + "{size}" + ); + assert!(session.is_none()); + } +} + +#[test] +fn unrepresentable_limits_are_rejected_before_upgrading() { + let upgrades = [ + WebSocketUpgrade::new(request(), handler).max_frame_size(usize::MAX), + WebSocketUpgrade::new(request(), handler).max_message_size(usize::MAX), + WebSocketUpgrade::new(request(), handler).write_buffer_size(usize::MAX), + ]; + for upgrade in upgrades { + let (response, session) = upgrade.accept(); + assert_eq!(response.status(), http::StatusCode::INTERNAL_SERVER_ERROR); + assert!(session.is_none()); + } +} + +async fn handler(_: crate::ws::WebSocket) {} + +#[test] +fn a_protocol_from_a_later_header_is_returned_in_the_upgrade() { + let mut request = request(); + request.headers_mut().append( + http::header::SEC_WEBSOCKET_PROTOCOL, + http::HeaderValue::from_static("other"), + ); + request.headers_mut().append( + http::header::SEC_WEBSOCKET_PROTOCOL, + http::HeaderValue::from_static("chat"), + ); + let (response, _) = WebSocketUpgrade::new(request, handler) + .protocols(["chat"]) + .accept(); + assert_eq!(response.status(), http::StatusCode::SWITCHING_PROTOCOLS); + assert_eq!( + response.headers().get(http::header::SEC_WEBSOCKET_PROTOCOL), + Some(&http::HeaderValue::from_static("chat")) + ); +} + +#[test] +fn zero_payload_limits_and_write_through_are_valid() { + let (response, _) = WebSocketUpgrade::new(request(), handler) + .max_frame_size(0) + .max_message_size(0) + .read_buffer_size(1) + .write_buffer_size(0) + .accept(); + assert_eq!(response.status(), http::StatusCode::SWITCHING_PROTOCOLS); +} diff --git a/tako-rs-core/src/ws/utf8.rs b/tako-rs-core/src/ws/utf8.rs new file mode 100644 index 00000000..1c197e0e --- /dev/null +++ b/tako-rs-core/src/ws/utf8.rs @@ -0,0 +1,98 @@ +use super::ProtocolError; + +/// Validates UTF-8 across message fragments, carrying a character cut at a fragment end. +#[derive(Default)] +pub(crate) struct Utf8Validator { + tail: [u8; 4], + tail_len: u8, +} + +impl Utf8Validator { + pub(crate) fn feed(&mut self, mut chunk: &[u8]) -> Result<(), ProtocolError> { + if self.tail_len > 0 { + let held = usize::from(self.tail_len); + let width = char_width(self.tail[0]).ok_or(ProtocolError::InvalidUtf8)?; + let take = (width - held).min(chunk.len()); + self.tail[held..held + take].copy_from_slice(&chunk[..take]); + match std::str::from_utf8(&self.tail[..held + take]) { + Ok(_) => self.tail_len = 0, + Err(error) if error.error_len().is_none() => { + self.tail_len = (held + take) as u8; + return Ok(()); + } + Err(_) => return Err(ProtocolError::InvalidUtf8), + } + chunk = &chunk[take..]; + } + match std::str::from_utf8(chunk) { + Ok(_) => Ok(()), + Err(error) if error.error_len().is_none() => { + let rest = &chunk[error.valid_up_to()..]; + self.tail[..rest.len()].copy_from_slice(rest); + self.tail_len = rest.len() as u8; + Ok(()) + } + Err(_) => Err(ProtocolError::InvalidUtf8), + } + } + + pub(crate) fn finish(&self) -> Result<(), ProtocolError> { + if self.tail_len == 0 { + Ok(()) + } else { + Err(ProtocolError::InvalidUtf8) + } + } +} + +fn char_width(lead: u8) -> Option { + match lead { + 0xC2..=0xDF => Some(2), + 0xE0..=0xEF => Some(3), + 0xF0..=0xF4 => Some(4), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::Utf8Validator; + + const TEXT: &str = "hΓ©llo wΓΆrld β‚¬π„ž"; + + #[test] + fn valid_text_passes_in_one_byte_pieces() { + let mut validator = Utf8Validator::default(); + for byte in TEXT.as_bytes() { + validator.feed(std::slice::from_ref(byte)).unwrap(); + } + validator.finish().unwrap(); + } + + #[test] + fn valid_text_passes_at_every_split_point() { + let bytes = TEXT.as_bytes(); + for split in 0..=bytes.len() { + let mut validator = Utf8Validator::default(); + validator.feed(&bytes[..split]).unwrap(); + validator.feed(&bytes[split..]).unwrap(); + validator.finish().unwrap(); + } + } + + #[test] + fn a_truncated_character_fails_at_the_end() { + let mut validator = Utf8Validator::default(); + validator.feed(&[0xE2, 0x82]).unwrap(); + assert!(validator.finish().is_err()); + } + + #[test] + fn invalid_sequences_fail_in_the_piece_that_carries_them() { + assert!(Utf8Validator::default().feed(&[0xC0, 0x80]).is_err()); + assert!(Utf8Validator::default().feed(&[0xF4, 0x90]).is_err()); + let mut surrogate = Utf8Validator::default(); + surrogate.feed(&[0xED]).unwrap(); + assert!(surrogate.feed(&[0xA0, 0x80]).is_err()); + } +} diff --git a/tako-rs-core/src/ws/wake.rs b/tako-rs-core/src/ws/wake.rs new file mode 100644 index 00000000..6fae7035 --- /dev/null +++ b/tako-rs-core/src/ws/wake.rs @@ -0,0 +1,56 @@ +//! Fans one transport wake-up out to every task waiting on it; a transport keeps a single waker. + +use std::sync::Arc; +use std::sync::Mutex; +use std::sync::PoisonError; +use std::task::Context; +use std::task::Wake; +use std::task::Waker; + +use smallvec::SmallVec; + +#[derive(Default)] +struct Waiting(Mutex>); + +impl Wake for Waiting { + fn wake(self: Arc) { + self.wake_by_ref(); + } + + fn wake_by_ref(self: &Arc) { + let waiting = std::mem::take(&mut *self.0.lock().unwrap_or_else(PoisonError::into_inner)); + for waker in waiting { + waker.wake(); + } + } +} + +pub(crate) struct Fanout { + waiting: Arc, + waker: Waker, +} + +impl Fanout { + pub(crate) fn new() -> Self { + let waiting = Arc::new(Waiting::default()); + let waker = Waker::from(Arc::clone(&waiting)); + Self { waiting, waker } + } + + /// Adds the task of `cx` to the waiters; poll the transport with the returned waker. + pub(crate) fn register(&self, cx: &Context<'_>) -> &Waker { + let mut waiting = self + .waiting + .0 + .lock() + .unwrap_or_else(PoisonError::into_inner); + if !waiting.iter().any(|waker| waker.will_wake(cx.waker())) { + waiting.push(cx.waker().clone()); + } + &self.waker + } + + pub(crate) fn wake_all(&self) { + self.waker.wake_by_ref(); + } +} diff --git a/tako-rs-core/tests/graphql_ws.rs b/tako-rs-core/tests/graphql_ws.rs new file mode 100644 index 00000000..15607168 --- /dev/null +++ b/tako-rs-core/tests/graphql_ws.rs @@ -0,0 +1,117 @@ +#![cfg(all(feature = "ws", feature = "async-graphql", not(feature = "compio")))] + +use std::net::SocketAddr; +use std::time::Duration; + +use async_graphql::EmptyMutation; +use async_graphql::Object; +use async_graphql::Schema; +use async_graphql::Subscription; +use futures_util::Stream; +use tako::Server; +use tako_rs_core::graphql::GraphQLSubscription; +use tako_rs_core::router::Router; +use tako_rs_core::types::Request; +use tokio::io::AsyncReadExt; +use tokio::io::AsyncWriteExt; +use tokio::net::TcpListener; +use tokio::net::TcpStream; +use tokio::time::timeout; + +const UPGRADE: &[u8] = b"GET /ws HTTP/1.1\r\nHost: localhost\r\nUpgrade: websocket\r\n\ +Connection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\ +Sec-WebSocket-Version: 13\r\nSec-WebSocket-Protocol: graphql-transport-ws\r\n\r\n"; +const MASK: [u8; 4] = [5, 6, 7, 8]; + +struct Query; + +#[Object] +impl Query { + async fn value(&self) -> i32 { + 1 + } +} + +struct Ticks; + +#[Subscription] +impl Ticks { + async fn ticks(&self) -> impl Stream { + futures_util::stream::iter([1, 2]) + } +} + +fn text_frame(payload: &str) -> Vec { + let mut frame = vec![0x81, 0x80 | payload.len() as u8]; + frame.extend_from_slice(&MASK); + frame.extend( + payload + .bytes() + .zip(MASK.iter().cycle()) + .map(|(byte, key)| byte ^ key), + ); + frame +} + +async fn read_text(stream: &mut TcpStream) -> String { + let mut header = [0; 2]; + timeout(Duration::from_secs(2), stream.read_exact(&mut header)) + .await + .unwrap() + .unwrap(); + assert_eq!(header[0], 0x81, "expected a text frame"); + let mut payload = vec![0; usize::from(header[1])]; + stream.read_exact(&mut payload).await.unwrap(); + String::from_utf8(payload).unwrap() +} + +async fn connect(address: SocketAddr) -> TcpStream { + let mut stream = TcpStream::connect(address).await.unwrap(); + stream.write_all(UPGRADE).await.unwrap(); + let mut head = Vec::new(); + while !head.ends_with(b"\r\n\r\n") { + head.push(stream.read_u8().await.unwrap()); + } + let head = String::from_utf8(head).unwrap(); + assert!(head.starts_with("HTTP/1.1 101"), "{head}"); + assert!( + head + .to_ascii_lowercase() + .contains("sec-websocket-protocol: graphql-transport-ws"), + "{head}" + ); + stream +} + +#[tokio::test] +async fn subscriptions_run_over_the_websocket() { + let schema = Schema::new(Query, EmptyMutation, Ticks); + let mut router = Router::new(); + router.get("/ws", move |req: Request| { + let schema = schema.clone(); + async move { GraphQLSubscription::new(req, schema) } + }); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let handle = Server::builder().build().spawn_http(listener, router); + + let mut stream = connect(address).await; + stream + .write_all(&text_frame(r#"{"type":"connection_init"}"#)) + .await + .unwrap(); + assert!(read_text(&mut stream).await.contains("connection_ack")); + stream + .write_all(&text_frame( + r#"{"id":"1","type":"subscribe","payload":{"query":"subscription { ticks }"}}"#, + )) + .await + .unwrap(); + let first = read_text(&mut stream).await; + let second = read_text(&mut stream).await; + assert!(first.contains(r#""ticks":1"#), "{first}"); + assert!(second.contains(r#""ticks":2"#), "{second}"); + assert!(read_text(&mut stream).await.contains("complete")); + + handle.shutdown(Duration::from_secs(1)).await; +} diff --git a/tako-rs-streams/Cargo.toml b/tako-rs-streams/Cargo.toml index e1c76ec4..dfed6453 100644 --- a/tako-rs-streams/Cargo.toml +++ b/tako-rs-streams/Cargo.toml @@ -17,7 +17,6 @@ path = "src/lib.rs" [dependencies] anyhow = { workspace = true } -base64 = { workspace = true, optional = true } bytes = { workspace = true } compio = { workspace = true, optional = true } futures-util = { workspace = true } @@ -34,23 +33,18 @@ pin-project-lite = { workspace = true } quinn = { workspace = true, optional = true } rustls = { workspace = true, optional = true } send_wrapper = { workspace = true, optional = true } -sha1 = { workspace = true, optional = true } tako-rs-core = { workspace = true } tokio = { workspace = true } -tokio-tungstenite = { workspace = true, optional = true } tokio-util = { workspace = true, features = ["io"] } tracing = { workspace = true } -url = { workspace = true } [features] default = [] sse = ["tako-rs/sse"] -ws = ["tako-rs/ws", "dep:tokio-tungstenite", "dep:sha1", "dep:base64", "hyper/http1", "hyper/server", "tako-rs-core/ws"] file-stream = ["tako-rs/file-stream"] plugins = ["tako-rs/plugins"] signals = ["tako-rs/signals", "tako-rs-core/signals"] compio = ["tako-rs/compio", "dep:compio", "dep:send_wrapper", "tako-rs-core/compio"] -compio-ws = ["tako-rs/compio-ws", "dep:sha1", "dep:base64", "compio", "compio/io", "compio/ws"] http3 = ["tako-rs/http3", "dep:quinn", "dep:rustls", "tako-rs-core/http3"] webtransport = ["tako-rs/webtransport", "http3"] diff --git a/tako-rs-streams/src/lib.rs b/tako-rs-streams/src/lib.rs index 234f8625..8da10c21 100644 --- a/tako-rs-streams/src/lib.rs +++ b/tako-rs-streams/src/lib.rs @@ -7,8 +7,6 @@ //! via the umbrella crate. mod file_io; -#[cfg(any(all(feature = "ws", not(feature = "compio")), feature = "compio-ws"))] -mod ws_handshake; /// Server-Sent Events (SSE) support for real-time communication. #[cfg(feature = "sse")] @@ -23,15 +21,6 @@ pub mod file_stream; /// Static file serving utilities. pub mod r#static; -/// WebSocket connection handling and message processing. -#[cfg(all(feature = "ws", not(feature = "compio")))] -pub mod ws; - -/// WebSocket connection handling for compio runtime. -#[cfg(feature = "compio-ws")] -#[cfg_attr(docsrs, doc(cfg(feature = "compio-ws")))] -pub mod ws_compio; - /// WebTransport server support over QUIC. #[cfg(all(feature = "webtransport", not(feature = "compio")))] #[cfg_attr(docsrs, doc(cfg(feature = "webtransport")))] diff --git a/tako-rs-streams/src/ws.rs b/tako-rs-streams/src/ws.rs deleted file mode 100644 index bfd17c7e..00000000 --- a/tako-rs-streams/src/ws.rs +++ /dev/null @@ -1,360 +0,0 @@ -//! WebSocket connection handling and message processing utilities. -//! -//! `TakoWs` performs the RFC-6455 server-side handshake and hands the -//! upgraded stream to a handler. The builder configures subprotocols, size limits, -//! read buffer size, allowed origins, upgrade timeout, and total connection lifetime. -//! -//! Handlers own the stream and must implement application ping/pong loops themselves. - -use std::future::Future; -use std::time::Duration; - -use futures_util::FutureExt; -use http::HeaderValue; -use http::StatusCode; -use http::header; -use hyper::upgrade::Upgraded; -use hyper_util::rt::TokioIo; -use tako_rs_core::body::TakoBody; -use tako_rs_core::responder::Responder; -use tako_rs_core::types::Request; -use tako_rs_core::types::Response; -use tokio_tungstenite::WebSocketStream; -use tokio_tungstenite::tungstenite::protocol::Role; -use tokio_tungstenite::tungstenite::protocol::WebSocketConfig; - -// tungstenite zero-fills the spare read buffer before every socket read, so its -// 128 KiB default costs a large memset per message. -const DEFAULT_READ_BUFFER_SIZE: usize = 4 * 1024; - -/// Legacy configuration accepted by the deprecated, ineffective `keep_alive` method. -/// Applications should capture their timing configuration in the handler closure. -#[derive(Debug, Clone, Copy, Default)] -pub struct WsKeepAlive { - /// Period between server-initiated pings; `None` disables. - pub ping_interval: Option, - /// Maximum time to wait for a pong reply before treating the connection as dead. - pub pong_timeout: Option, -} - -/// WebSocket connection handler with upgrade protocol support. -#[doc(alias = "websocket")] -#[doc(alias = "ws")] -pub struct TakoWs -where - H: FnOnce(WebSocketStream>) -> Fut + Send + 'static, - Fut: Future + Send + 'static, -{ - request: Request, - handler: H, - protocols: Vec<&'static str>, - max_frame_size: Option, - max_message_size: Option, - read_buffer_size: usize, - allowed_origins: Option>, - upgrade_timeout: Option, - /// Hard cap on how long a single WebSocket conversation may live after a - /// successful upgrade. When set, the handler future is wrapped in - /// `tokio::time::timeout(max_lifetime, …)`; expiry drops the connection. - /// Defends against slowloris-style holders that never send data after - /// upgrade. - max_lifetime: Option, -} - -impl TakoWs -where - H: FnOnce(WebSocketStream>) -> Fut + Send + 'static, - Fut: Future + Send + 'static, -{ - /// Creates a new WebSocket handler with the given request and handler function. - pub fn new(request: Request, handler: H) -> Self { - Self { - request, - handler, - protocols: Vec::new(), - max_frame_size: None, - max_message_size: None, - read_buffer_size: DEFAULT_READ_BUFFER_SIZE, - allowed_origins: None, - upgrade_timeout: None, - max_lifetime: None, - } - } - - /// Hard-cap on total connection lifetime after upgrade. See - /// [`Self::max_lifetime`]-field docs. - pub fn max_lifetime(mut self, d: Duration) -> Self { - self.max_lifetime = Some(d); - self - } - - /// Configure accepted subprotocols. - pub fn protocols(mut self, list: I) -> Self - where - I: IntoIterator, - S: Into<&'static str>, - { - self.protocols = list.into_iter().map(Into::into).collect(); - self - } - - /// Limit the maximum WebSocket frame size in bytes. - pub fn max_frame_size(mut self, n: usize) -> Self { - self.max_frame_size = Some(n); - self - } - - /// Limit the maximum WebSocket message size in bytes. - pub fn max_message_size(mut self, n: usize) -> Self { - self.max_message_size = Some(n); - self - } - - /// Set the per-connection read buffer capacity, 4 KiB by default. - /// - /// Each socket read fills at most this many bytes; raise it for large messages. - pub fn read_buffer_size(mut self, n: usize) -> Self { - self.read_buffer_size = n; - self - } - - /// Restrict the upgrade to clients whose `Origin` header matches the allow-list. - pub fn allowed_origins(mut self, origins: I) -> Self - where - I: IntoIterator, - S: Into, - { - self.allowed_origins = Some(origins.into_iter().map(Into::into).collect()); - self - } - - /// Cap how long the framework waits for `hyper::upgrade::OnUpgrade` to resolve. - pub fn upgrade_timeout(mut self, d: Duration) -> Self { - self.upgrade_timeout = Some(d); - self - } - - /// This legacy method has no effect. Implement ping/pong timing inside the handler. - #[deprecated(note = "no effect; implement ping/pong in the handler or use max_lifetime")] - pub fn keep_alive(self, _config: WsKeepAlive) -> Self { - self - } - - fn websocket_config(&self) -> WebSocketConfig { - let mut cfg = WebSocketConfig::default().read_buffer_size(self.read_buffer_size); - if let Some(n) = self.max_frame_size { - cfg.max_frame_size = Some(n); - } - if let Some(n) = self.max_message_size { - cfg.max_message_size = Some(n); - } - cfg - } - - fn negotiate_subprotocol(&self, headers: &http::HeaderMap) -> Option<&'static str> { - if self.protocols.is_empty() { - return None; - } - let header = headers - .get(header::SEC_WEBSOCKET_PROTOCOL) - .and_then(|v| v.to_str().ok())?; - let offered: Vec<&str> = header.split(',').map(str::trim).collect(); - self - .protocols - .iter() - .copied() - .find(|server_pref| offered.contains(server_pref)) - } - - fn origin_allowed(&self, headers: &http::HeaderMap) -> bool { - let Some(allowed) = self.allowed_origins.as_ref() else { - return true; - }; - let Some(origin) = headers.get(header::ORIGIN).and_then(|v| v.to_str().ok()) else { - return false; - }; - let observed = normalize_origin(origin); - allowed - .iter() - .any(|a| normalize_origin(a) == observed && !observed.is_empty()) - } -} - -/// Normalize scheme, host, and port for the origin allow-list. -fn normalize_origin(raw: &str) -> String { - let raw = raw.trim(); - if raw.is_empty() || raw.eq_ignore_ascii_case("null") { - return String::new(); - } - let Ok(url) = url::Url::parse(raw) else { - return String::new(); - }; - // Reject userinfo β€” an Origin must not carry credentials. - if !url.username().is_empty() || url.password().is_some() { - return String::new(); - } - let scheme = url.scheme().to_ascii_lowercase(); - let Some(host) = url.host_str() else { - return String::new(); - }; - let host = host.to_ascii_lowercase(); - let port = url.port(); - let default = matches!( - (scheme.as_str(), port), - ("http" | "ws", Some(80)) | ("https" | "wss", Some(443)) - ); - match port { - Some(p) if !default => format!("{scheme}://{host}:{p}"), - _ => format!("{scheme}://{host}"), - } -} - -impl Responder for TakoWs -where - H: FnOnce(WebSocketStream>) -> Fut + Send + 'static, - Fut: Future + Send + 'static, -{ - fn into_response(self) -> Response { - let accept = match crate::ws_handshake::accept(&self.request) { - Ok(accept) => accept, - Err(status) => return crate::ws_handshake::rejection(status), - }; - let ws_config = self.websocket_config(); - if !self.origin_allowed(self.request.headers()) { - return http::Response::builder() - .status(StatusCode::FORBIDDEN) - .body(TakoBody::from("origin not allowed")) - .expect("valid forbidden response"); - } - let selected_proto = self.negotiate_subprotocol(self.request.headers()); - let upgrade_timeout = self.upgrade_timeout; - let max_lifetime = self.max_lifetime; - - let TakoWs { - request, handler, .. - } = self; - let (parts, body) = request.into_parts(); - let req = http::Request::from_parts(parts, body); - - let mut builder = http::Response::builder() - .status(StatusCode::SWITCHING_PROTOCOLS) - .header(header::UPGRADE, "websocket") - .header(header::CONNECTION, "Upgrade") - .header("Sec-WebSocket-Accept", accept); - if let Some(p) = selected_proto { - builder = builder.header(header::SEC_WEBSOCKET_PROTOCOL, HeaderValue::from_static(p)); - } - - let response = builder - .body(TakoBody::empty()) - .expect("valid WebSocket upgrade response"); - - if let Some(on_upgrade) = req.extensions().get::().cloned() { - tokio::spawn(async move { - let upgraded = match upgrade_timeout { - Some(d) => match tokio::time::timeout(d, on_upgrade).await { - Ok(Ok(u)) => u, - _ => return, - }, - None => match on_upgrade.await { - Ok(u) => u, - Err(_) => return, - }, - }; - let upgraded = TokioIo::new(upgraded); - let ws = WebSocketStream::from_raw_socket(upgraded, Role::Server, Some(ws_config)).await; - let handler_fut = - std::panic::AssertUnwindSafe(async move { handler(ws).await }).catch_unwind(); - match max_lifetime { - Some(d) => { - let _ = tokio::time::timeout(d, handler_fut).await; - } - None => { - let _ = handler_fut.await; - } - } - }); - } - - response - } -} - -#[cfg(test)] -mod tests { - use tako_rs_core::body::TakoBody; - use tako_rs_core::types::Request; - - use super::TakoWs; - use super::normalize_origin; - - fn request() -> Request { - http::Request::builder() - .uri("/ws") - .body(TakoBody::empty()) - .unwrap() - } - - #[test] - fn reads_into_a_small_buffer_by_default() { - let ws = TakoWs::new(request(), |_| async {}); - assert_eq!(ws.websocket_config().read_buffer_size, 4 * 1024); - } - - #[test] - fn read_buffer_size_is_configurable() { - let ws = TakoWs::new(request(), |_| async {}).read_buffer_size(64 * 1024); - assert_eq!(ws.websocket_config().read_buffer_size, 64 * 1024); - } - - #[test] - fn frame_limits_keep_the_small_read_buffer() { - let ws = TakoWs::new(request(), |_| async {}).max_frame_size(1024); - let config = ws.websocket_config(); - assert_eq!(config.max_frame_size, Some(1024)); - assert_eq!(config.read_buffer_size, 4 * 1024); - } - - #[test] - fn normalize_origin_lowercases_scheme_and_host() { - assert_eq!( - normalize_origin("HTTPS://Example.COM"), - "https://example.com" - ); - } - - #[test] - fn normalize_origin_strips_default_ports() { - assert_eq!( - normalize_origin("http://example.com:80"), - "http://example.com" - ); - assert_eq!( - normalize_origin("https://example.com:443"), - "https://example.com" - ); - } - - #[test] - fn normalize_origin_keeps_nondefault_ports() { - assert_eq!( - normalize_origin("https://example.com:8443"), - "https://example.com:8443" - ); - } - - #[test] - fn normalize_origin_rejects_malformed_or_null() { - assert_eq!(normalize_origin(""), ""); - assert_eq!(normalize_origin("null"), ""); - assert_eq!(normalize_origin("not-an-origin"), ""); - } - - #[test] - fn normalize_origin_ignores_trailing_path() { - assert_eq!( - normalize_origin("https://example.com/path?x=1"), - "https://example.com" - ); - } -} diff --git a/tako-rs-streams/src/ws_compio.rs b/tako-rs-streams/src/ws_compio.rs deleted file mode 100644 index fd78c2c9..00000000 --- a/tako-rs-streams/src/ws_compio.rs +++ /dev/null @@ -1,329 +0,0 @@ -//! WebSocket connection handling for compio runtime. -//! -//! This module provides `TakoWsCompio` for handling WebSocket upgrade requests -//! when using the compio async runtime. It implements the WebSocket handshake -//! protocol according to RFC 6455 and integrates with compio-ws for message handling. - -use std::future::Future; -use std::io::ErrorKind; - -use compio::io::compat::SyncStream; -use compio::ws::tungstenite; -pub use compio::ws::tungstenite::Message; -use compio::ws::tungstenite::protocol::CloseFrame; -use compio::ws::tungstenite::protocol::Role; -use compio::ws::tungstenite::protocol::WebSocketConfig; -use futures_util::FutureExt; -use http::StatusCode; -use http::header; -use hyper::upgrade::Upgraded; -use tako_rs_core::body::TakoBody; -use tako_rs_core::responder::Responder; -use tako_rs_core::types::Request; -use tako_rs_core::types::Response; - -/// Wrapper to adapt hyper's Upgraded connection for compio-ws. -/// -/// This struct wraps `hyper::upgrade::Upgraded` and implements the traits -/// needed by compio-ws to create a WebSocket stream. -pub struct UpgradedStream { - inner: Upgraded, -} - -impl UpgradedStream { - /// Creates a new `UpgradedStream` from a hyper Upgraded connection. - pub fn new(upgraded: Upgraded) -> Self { - Self { inner: upgraded } - } -} - -impl compio::io::AsyncRead for UpgradedStream { - async fn read( - &mut self, - mut buf: B, - ) -> compio::buf::BufResult { - use std::pin::Pin; - use std::task::Context; - use std::task::Poll; - - use hyper::rt::Read; - - let result = std::future::poll_fn(|cx: &mut Context<'_>| -> Poll> { - let uninit_slice = buf.as_uninit(); - let mut read_buf = hyper::rt::ReadBuf::uninit(uninit_slice); - match Pin::new(&mut self.inner).poll_read(cx, read_buf.unfilled()) { - Poll::Ready(Ok(())) => Poll::Ready(Ok(read_buf.filled().len())), - Poll::Ready(Err(e)) => Poll::Ready(Err(e)), - Poll::Pending => Poll::Pending, - } - }) - .await; - - match result { - Ok(filled_len) => { - // SAFETY: hyper initialized exactly `filled_len` bytes in this buffer's - // writable slice, so publishing that length stays within the same allocation. - unsafe { buf.set_len(filled_len) }; - (Ok(filled_len), buf).into() - } - Err(e) => (Err(e), buf).into(), - } - } -} - -impl compio::io::AsyncWrite for UpgradedStream { - async fn write(&mut self, buf: T) -> compio::buf::BufResult { - use std::pin::Pin; - use std::task::Context; - - use hyper::rt::Write; - - let slice = buf.as_init(); - - let result = - std::future::poll_fn(|cx: &mut Context<'_>| Pin::new(&mut self.inner).poll_write(cx, slice)) - .await; - - match result { - Ok(n) => (Ok(n), buf).into(), - Err(e) => (Err(e), buf).into(), - } - } - - async fn flush(&mut self) -> std::io::Result<()> { - use std::pin::Pin; - use std::task::Context; - - use hyper::rt::Write; - - std::future::poll_fn(|cx: &mut Context<'_>| Pin::new(&mut self.inner).poll_flush(cx)).await - } - - async fn shutdown(&mut self) -> std::io::Result<()> { - use std::pin::Pin; - use std::task::Context; - - use hyper::rt::Write; - - std::future::poll_fn(|cx: &mut Context<'_>| Pin::new(&mut self.inner).poll_shutdown(cx)).await - } -} - -/// A WebSocket stream wrapper for compio that wraps tungstenite directly. -/// -/// This type provides async WebSocket functionality by wrapping a tungstenite -/// WebSocket with a `SyncStream` adapter. -pub struct CompioWebSocket { - inner: tungstenite::WebSocket>, -} - -impl CompioWebSocket -where - S: compio::io::AsyncRead + compio::io::AsyncWrite, -{ - /// Default buffer size (128 KiB). - const DEFAULT_BUF_SIZE: usize = 128 * 1024; - /// Default maximum buffer size (64 MiB). - const DEFAULT_MAX_BUFFER: usize = 64 * 1024 * 1024; - - /// Creates a WebSocket stream from a raw socket without performing handshake. - /// - /// This is used after the HTTP upgrade handshake has already been completed. - pub fn from_raw_socket(stream: S, role: Role, config: Option) -> Self { - let sync_stream = - SyncStream::with_limits(Self::DEFAULT_BUF_SIZE, Self::DEFAULT_MAX_BUFFER, stream); - let ws = tungstenite::WebSocket::from_raw_socket(sync_stream, role, config); - Self { inner: ws } - } - - /// Sends a WebSocket message. - pub async fn send(&mut self, message: Message) -> Result<(), tungstenite::Error> { - self.inner.send(message)?; - self.flush().await - } - - /// Reads the next WebSocket message. - pub async fn read(&mut self) -> Result { - loop { - match self.inner.read() { - Ok(msg) => { - let _ = self.flush().await; - return Ok(msg); - } - Err(tungstenite::Error::Io(ref e)) if e.kind() == ErrorKind::WouldBlock => { - self - .inner - .get_mut() - .fill_read_buf() - .await - .map_err(tungstenite::Error::Io)?; - } - Err(e) => { - let _ = self.flush().await; - return Err(e); - } - } - } - } - - /// Flushes pending messages. - pub async fn flush(&mut self) -> Result<(), tungstenite::Error> { - loop { - match self.inner.flush() { - Ok(()) => break, - Err(tungstenite::Error::Io(ref e)) if e.kind() == ErrorKind::WouldBlock => { - self - .inner - .get_mut() - .flush_write_buf() - .await - .map_err(tungstenite::Error::Io)?; - } - Err(tungstenite::Error::ConnectionClosed) => break, - Err(e) => return Err(e), - } - } - self - .inner - .get_mut() - .flush_write_buf() - .await - .map_err(tungstenite::Error::Io)?; - Ok(()) - } - - /// Closes the WebSocket connection. - pub async fn close(&mut self, close_frame: Option) -> Result<(), tungstenite::Error> { - loop { - match self.inner.close(close_frame.clone()) { - Ok(()) => break, - Err(tungstenite::Error::Io(ref e)) if e.kind() == ErrorKind::WouldBlock => { - let sync_stream = self.inner.get_mut(); - let flushed = sync_stream - .flush_write_buf() - .await - .map_err(tungstenite::Error::Io)?; - if flushed == 0 { - sync_stream - .fill_read_buf() - .await - .map_err(tungstenite::Error::Io)?; - } - } - Err(tungstenite::Error::ConnectionClosed) => break, - Err(e) => return Err(e), - } - } - self.flush().await - } - - /// Returns a reference to the underlying stream. - pub fn get_ref(&self) -> &S { - self.inner.get_ref().get_ref() - } - - /// Returns a mutable reference to the underlying stream. - pub fn get_mut(&mut self) -> &mut S { - self.inner.get_mut().get_mut() - } -} - -/// WebSocket connection handler for compio runtime. -/// -/// `TakoWsCompio` manages the WebSocket handshake process and connection upgrade -/// when using the compio async runtime. It validates the WebSocket upgrade request, -/// performs the RFC 6455 handshake, and spawns a task to handle the WebSocket -/// connection using the provided handler function. -/// -/// # Type Parameters -/// -/// * `H` - Handler function type that processes the WebSocket connection -/// * `Fut` - Future type returned by the handler function -/// -/// # Examples -/// -/// ```rust,ignore -/// use tako::ws_compio::{TakoWsCompio, CompioWebSocket, UpgradedStream}; -/// use tako::types::Request; -/// use tako::body::TakoBody; -/// use tako::responder::Responder; -/// use compio::ws::tungstenite::Message; -/// -/// async fn echo_handler(mut ws: CompioWebSocket) { -/// loop { -/// match ws.read().await { -/// Ok(Message::Text(text)) => { -/// let _ = ws.send(Message::Text(format!("Echo: {}", text).into())).await; -/// } -/// Ok(Message::Close(_)) | Err(_) => break, -/// _ => {} -/// } -/// } -/// } -/// -/// async fn handler(req: Request) -> impl Responder { -/// TakoWsCompio::new(req, echo_handler) -/// } -/// ``` -#[doc(alias = "websocket")] -#[doc(alias = "ws")] -pub struct TakoWsCompio -where - H: FnOnce(CompioWebSocket) -> Fut + 'static, - Fut: Future + 'static, -{ - request: Request, - handler: H, -} - -impl TakoWsCompio -where - H: FnOnce(CompioWebSocket) -> Fut + 'static, - Fut: Future + 'static, -{ - /// Creates a new WebSocket handler with the given request and handler function. - pub fn new(request: Request, handler: H) -> Self { - Self { request, handler } - } -} - -impl Responder for TakoWsCompio -where - H: FnOnce(CompioWebSocket) -> Fut + 'static, - Fut: Future + 'static, -{ - /// Converts the WebSocket handler into an HTTP response with upgrade protocol. - fn into_response(self) -> Response { - let (parts, body) = self.request.into_parts(); - let req = http::Request::from_parts(parts, body); - - let accept = match crate::ws_handshake::accept(&req) { - Ok(accept) => accept, - Err(status) => return crate::ws_handshake::rejection(status), - }; - - let response = http::Response::builder() - .status(StatusCode::SWITCHING_PROTOCOLS) - .header(header::UPGRADE, "websocket") - .header(header::CONNECTION, "Upgrade") - .header("Sec-WebSocket-Accept", accept) - .body(TakoBody::empty()) - .expect("valid WebSocket upgrade response"); - - if let Some(on_upgrade) = req.extensions().get::().cloned() { - let handler = self.handler; - compio::runtime::spawn(async move { - if let Ok(upgraded) = on_upgrade.await { - let stream = UpgradedStream::new(upgraded); - let ws = CompioWebSocket::from_raw_socket(stream, Role::Server, None); - let _ = std::panic::AssertUnwindSafe(async move { handler(ws).await }) - .catch_unwind() - .await; - } - }) - .detach(); - } - - response - } -} diff --git a/tako-rs-streams/src/ws_handshake.rs b/tako-rs-streams/src/ws_handshake.rs deleted file mode 100644 index d82e6f73..00000000 --- a/tako-rs-streams/src/ws_handshake.rs +++ /dev/null @@ -1,156 +0,0 @@ -use base64::Engine as _; -use base64::engine::general_purpose::STANDARD; -use http::HeaderMap; -use http::Method; -use http::StatusCode; -use http::Version; -use http::header; -use sha1::Digest; -use sha1::Sha1; -use tako_rs_core::body::TakoBody; -use tako_rs_core::types::Request; -use tako_rs_core::types::Response; - -pub(crate) fn accept(request: &Request) -> Result { - if request.method() != Method::GET { - return Err(StatusCode::METHOD_NOT_ALLOWED); - } - if request.version() != Version::HTTP_11 { - return Err(StatusCode::BAD_REQUEST); - } - let headers = request.headers(); - let host = single(headers, header::HOST).ok_or(StatusCode::BAD_REQUEST)?; - if host.contains('@') || host.parse::().is_err() { - return Err(StatusCode::BAD_REQUEST); - } - if !has_token(headers, header::UPGRADE, "websocket") - || !has_token(headers, header::CONNECTION, "upgrade") - { - return Err(StatusCode::BAD_REQUEST); - } - let version = single(headers, header::SEC_WEBSOCKET_VERSION).ok_or(StatusCode::BAD_REQUEST)?; - if version != "13" { - return Err(StatusCode::UPGRADE_REQUIRED); - } - let key = single(headers, header::SEC_WEBSOCKET_KEY).ok_or(StatusCode::BAD_REQUEST)?; - if key.len() != 24 - || !STANDARD - .decode(key) - .is_ok_and(|decoded| decoded.len() == 16) - { - return Err(StatusCode::BAD_REQUEST); - } - let mut hash = Sha1::new(); - hash.update(key.as_bytes()); - hash.update(b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11"); - Ok(STANDARD.encode(hash.finalize())) -} - -pub(crate) fn rejection(status: StatusCode) -> Response { - let mut response = http::Response::builder().status(status); - if status == StatusCode::UPGRADE_REQUIRED { - response = response.header(header::SEC_WEBSOCKET_VERSION, "13"); - } - if status == StatusCode::METHOD_NOT_ALLOWED { - response = response.header(header::ALLOW, "GET"); - } - response - .body(TakoBody::empty()) - .expect("valid handshake rejection") -} - -fn single(headers: &HeaderMap, name: header::HeaderName) -> Option<&str> { - let mut values = headers.get_all(name).iter(); - let value = values.next()?.to_str().ok()?.trim(); - (values.next().is_none() && !value.is_empty()).then_some(value) -} - -fn has_token(headers: &HeaderMap, name: header::HeaderName, token: &str) -> bool { - headers - .get_all(name) - .iter() - .filter_map(|value| value.to_str().ok()) - .flat_map(|value| value.split(',')) - .any(|value| value.trim().eq_ignore_ascii_case(token)) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn valid() -> Request { - http::Request::builder() - .uri("/ws") - .header(header::HOST, "example.com") - .header(header::UPGRADE, "WebSocket") - .header(header::CONNECTION, "keep-alive, Upgrade") - .header(header::SEC_WEBSOCKET_VERSION, "13") - .header(header::SEC_WEBSOCKET_KEY, "dGhlIHNhbXBsZSBub25jZQ==") - .body(TakoBody::empty()) - .unwrap() - } - - #[test] - fn valid_handshake_matches_the_protocol_example() { - assert_eq!(accept(&valid()).unwrap(), "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="); - let mut request = valid(); - request - .headers_mut() - .insert(header::CONNECTION, "keep-alive".parse().unwrap()); - request - .headers_mut() - .append(header::CONNECTION, "upgrade".parse().unwrap()); - assert!(accept(&request).is_ok()); - } - - #[test] - fn missing_duplicate_and_malformed_headers_are_rejected() { - for name in [ - header::HOST, - header::UPGRADE, - header::CONNECTION, - header::SEC_WEBSOCKET_VERSION, - header::SEC_WEBSOCKET_KEY, - ] { - let mut request = valid(); - request.headers_mut().remove(name); - assert_eq!(accept(&request).unwrap_err(), StatusCode::BAD_REQUEST); - } - for key in [ - "invalid", - "YQ==", - "AAAAAAAAAAAAAAAAAAAAAA=A", - "AAAAAAAAAAAAAAAAAAAAAA==, AAAAAAAAAAAAAAAAAAAAAA==", - ] { - let mut request = valid(); - request - .headers_mut() - .insert(header::SEC_WEBSOCKET_KEY, key.parse().unwrap()); - assert_eq!(accept(&request).unwrap_err(), StatusCode::BAD_REQUEST); - } - let mut request = valid(); - request.headers_mut().append( - header::SEC_WEBSOCKET_KEY, - "AAAAAAAAAAAAAAAAAAAAAA==".parse().unwrap(), - ); - assert_eq!(accept(&request).unwrap_err(), StatusCode::BAD_REQUEST); - } - - #[test] - fn unsupported_versions_and_methods_explain_the_supported_handshake() { - let mut request = valid(); - request - .headers_mut() - .insert(header::SEC_WEBSOCKET_VERSION, "12".parse().unwrap()); - let response = rejection(accept(&request).unwrap_err()); - assert_eq!(response.status(), StatusCode::UPGRADE_REQUIRED); - assert_eq!(response.headers()[header::SEC_WEBSOCKET_VERSION], "13"); - *request.method_mut() = Method::POST; - let response = rejection(accept(&request).unwrap_err()); - assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); - assert_eq!(response.headers()[header::ALLOW], "GET"); - *request.method_mut() = Method::GET; - *request.version_mut() = Version::HTTP_2; - assert_eq!(accept(&request).unwrap_err(), StatusCode::BAD_REQUEST); - } -} diff --git a/tako-rs/Cargo.toml b/tako-rs/Cargo.toml index 25c3cede..cfa2acf2 100644 --- a/tako-rs/Cargo.toml +++ b/tako-rs/Cargo.toml @@ -30,7 +30,7 @@ tikv-jemallocator = { workspace = true, optional = true } [features] default = [] -ws = ["tako-rs-core/ws", "tako-rs-streams/ws"] +ws = ["tako-rs-core/ws"] sse = ["tako-rs-streams/sse"] proxy-protocol = ["tako-rs-server/proxy-protocol"] udp = ["tako-rs-server/udp"] @@ -50,13 +50,12 @@ webtransport = ["http3", "tako-rs-server/webtransport", "tako-rs-streams/webtran # Compio runtime compio = ["tako-rs-core/compio", "tako-rs-server/compio", "tako-rs-streams/compio", "tako-rs-plugins/compio", "tako-rs-server-pt?/compio"] -# compio-tls / compio-ws imply `compio`: tako-rs-server's `compio-tls` already +# compio-tls implies `compio`: tako-rs-server's `compio-tls` already # pulls in `tako-rs-server/compio`, which turns off the tokio-based `Server` / # `ServerBuilder` re-exports. If we didn't also flip `tako-rs/compio`, the # `#[cfg(not(feature = "compio"))]` gates in `src/lib.rs` would still try to # re-export items that are no longer present. compio-tls = ["compio", "tako-rs-server/compio-tls", "tako-rs-core/compio-tls"] -compio-ws = ["compio", "tako-rs-streams/compio-ws", "tako-rs-core/compio-ws"] # Body/streaming features file-stream = ["tako-rs-streams/file-stream", "tako-rs-core/file-stream"] diff --git a/tako-rs/src/lib.rs b/tako-rs/src/lib.rs index 07f8cdc0..5f1e9e60 100644 --- a/tako-rs/src/lib.rs +++ b/tako-rs/src/lib.rs @@ -104,36 +104,24 @@ pub use tako_rs_server::serve_h3_with_shutdown; #[allow(deprecated)] pub use tako_rs_server::serve_h3_with_shutdown_and_config; #[cfg(any( - all( - feature = "tls", - not(any(feature = "compio", feature = "compio-tls", feature = "compio-ws")) - ), + all(feature = "tls", not(any(feature = "compio", feature = "compio-tls"))), feature = "compio-tls" ))] #[cfg_attr(docsrs, doc(cfg(any(feature = "tls", feature = "compio-tls"))))] #[allow(deprecated)] pub use tako_rs_server::serve_tls; -#[cfg(all( - feature = "tls", - not(any(feature = "compio", feature = "compio-tls", feature = "compio-ws")) -))] +#[cfg(all(feature = "tls", not(any(feature = "compio", feature = "compio-tls"))))] #[cfg_attr(docsrs, doc(cfg(feature = "tls")))] #[allow(deprecated)] pub use tako_rs_server::serve_tls_with_config; #[cfg(any( - all( - feature = "tls", - not(any(feature = "compio", feature = "compio-tls", feature = "compio-ws")) - ), + all(feature = "tls", not(any(feature = "compio", feature = "compio-tls"))), feature = "compio-tls" ))] #[cfg_attr(docsrs, doc(cfg(any(feature = "tls", feature = "compio-tls"))))] #[allow(deprecated)] pub use tako_rs_server::serve_tls_with_shutdown; -#[cfg(all( - feature = "tls", - not(any(feature = "compio", feature = "compio-tls", feature = "compio-ws")) -))] +#[cfg(all(feature = "tls", not(any(feature = "compio", feature = "compio-tls"))))] #[cfg_attr(docsrs, doc(cfg(feature = "tls")))] #[allow(deprecated)] pub use tako_rs_server::serve_tls_with_shutdown_and_config; @@ -183,12 +171,9 @@ pub mod webtransport { #[cfg(not(feature = "compio"))] pub use tako_rs_streams::webtransport::*; } -#[cfg(not(any(feature = "compio", feature = "compio-ws")))] #[cfg(feature = "ws")] -pub use tako_rs_streams::ws; -#[cfg(feature = "compio-ws")] -#[cfg_attr(docsrs, doc(cfg(feature = "compio-ws")))] -pub use tako_rs_streams::ws_compio; +#[cfg_attr(docsrs, doc(cfg(feature = "ws")))] +pub use tako_rs_core::ws; /// Request data extraction utilities. pub mod extractors { diff --git a/tako-rs/tests/websocket_connection_lease.rs b/tako-rs/tests/websocket_connection_lease.rs index 03f93dfb..9d6844af 100644 --- a/tako-rs/tests/websocket_connection_lease.rs +++ b/tako-rs/tests/websocket_connection_lease.rs @@ -3,13 +3,12 @@ use std::net::SocketAddr; use std::time::Duration; -use futures_util::SinkExt; -use futures_util::StreamExt; use tako::Server; use tako::ServerConfig; use tako::router::Router; use tako::types::Request; -use tako::ws::TakoWs; +use tako::ws::Message; +use tako::ws::WebSocketUpgrade; use tokio::io::AsyncReadExt; use tokio::io::AsyncWriteExt; use tokio::net::TcpListener; @@ -24,13 +23,13 @@ fn router() -> Router { let mut router = Router::new(); router.get("/", || async { "ok" }); router.get("/ws", |req: Request| async move { - TakoWs::new(req, |mut ws| async move { + WebSocketUpgrade::new(req, |mut ws| async move { let worker = std::thread::current() .name() .unwrap_or_default() .to_string(); - if ws.send(worker.into()).await.is_ok() { - while let Some(Ok(_)) = ws.next().await {} + if ws.send(Message::text(worker)).await.is_ok() { + while let Some(Ok(_)) = ws.recv().await {} } }) }); diff --git a/tako-rs/tests/websocket_echo.rs b/tako-rs/tests/websocket_echo.rs new file mode 100644 index 00000000..efd7a105 --- /dev/null +++ b/tako-rs/tests/websocket_echo.rs @@ -0,0 +1,137 @@ +#![cfg(all(feature = "ws", not(feature = "compio")))] + +use std::net::SocketAddr; +use std::time::Duration; + +use tako::Server; +use tako::router::Router; +use tako::types::Request; +use tako::ws::WebSocketUpgrade; +use tokio::io::AsyncReadExt; +use tokio::io::AsyncWriteExt; +use tokio::net::TcpListener; +use tokio::net::TcpStream; +use tokio::time::timeout; + +const UPGRADE: &[u8] = b"GET /ws HTTP/1.1\r\nHost: localhost\r\nUpgrade: websocket\r\n\ +Connection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\ +Sec-WebSocket-Version: 13\r\n\r\n"; +const MASK: [u8; 4] = [9, 8, 7, 6]; + +fn router() -> Router { + let mut router = Router::new(); + router.get("/ws", |req: Request| async move { + WebSocketUpgrade::new(req, |mut ws| async move { + while let Some(Ok(message)) = ws.recv().await { + if (message.is_text() || message.is_binary()) && ws.send(message).await.is_err() { + break; + } + } + }) + }); + router +} + +/// A masked client frame with a payload shorter than 126 bytes. +fn frame(first: u8, payload: &[u8]) -> Vec { + let mut frame = vec![first, 0x80 | payload.len() as u8]; + frame.extend_from_slice(&MASK); + frame.extend( + payload + .iter() + .zip(MASK.iter().cycle()) + .map(|(byte, key)| byte ^ key), + ); + frame +} + +async fn read_exact(stream: &mut TcpStream, len: usize) -> Vec { + let mut buf = vec![0; len]; + timeout(Duration::from_secs(2), stream.read_exact(&mut buf)) + .await + .unwrap() + .unwrap(); + buf +} + +async fn connect(address: SocketAddr) -> TcpStream { + let mut stream = TcpStream::connect(address).await.unwrap(); + stream.write_all(UPGRADE).await.unwrap(); + let mut head = Vec::new(); + while !head.ends_with(b"\r\n\r\n") { + head.push(read_exact(&mut stream, 1).await[0]); + } + assert!( + head.starts_with(b"HTTP/1.1 101"), + "{}", + String::from_utf8_lossy(&head) + ); + stream +} + +async fn exercise(address: SocketAddr) { + let mut stream = connect(address).await; + stream.write_all(&frame(0x81, b"hello")).await.unwrap(); + assert_eq!(read_exact(&mut stream, 7).await, b"\x81\x05hello"); + stream.write_all(&frame(0x82, &[1, 2, 3])).await.unwrap(); + assert_eq!(read_exact(&mut stream, 5).await, [0x82, 3, 1, 2, 3]); + stream.write_all(&frame(0x89, b"p")).await.unwrap(); + assert_eq!(read_exact(&mut stream, 3).await, [0x8A, 1, b'p']); + + let batch: Vec = (0..16u8).flat_map(|i| frame(0x82, &[i])).collect(); + stream.write_all(&batch).await.unwrap(); + let expected: Vec = (0..16u8).flat_map(|i| [0x82, 1, i]).collect(); + assert_eq!(read_exact(&mut stream, expected.len()).await, expected); + + stream + .write_all(&frame(0x88, &[0x03, 0xE8, b'b', b'y', b'e'])) + .await + .unwrap(); + assert_eq!( + read_exact(&mut stream, 7).await, + [0x88, 5, 0x03, 0xE8, b'b', b'y', b'e'] + ); + let mut rest = Vec::new(); + timeout(Duration::from_secs(2), stream.read_to_end(&mut rest)) + .await + .unwrap() + .unwrap(); + assert!(rest.is_empty(), "{rest:?}"); +} + +#[tokio::test] +async fn echoes_on_the_multi_thread_server() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let handle = Server::builder().build().spawn_http(listener, router()); + exercise(address).await; + handle.shutdown(Duration::from_secs(1)).await; +} + +#[cfg(all(unix, feature = "per-thread"))] +#[tokio::test] +async fn echoes_on_the_per_thread_server() { + let address = std::net::TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap(); + let (threads, shutdown) = tako::spawn_per_thread( + &address.to_string(), + router(), + tako::PerThreadConfig { + workers: 2, + pin_to_core: false, + ..tako::PerThreadConfig::default() + }, + ) + .unwrap(); + timeout(Duration::from_secs(2), shutdown.wait_for_bind_outcome(2)) + .await + .unwrap() + .unwrap(); + exercise(address).await; + shutdown.trigger(); + tokio::task::spawn_blocking(move || threads.into_iter().for_each(|t| t.join().unwrap())) + .await + .unwrap(); +} diff --git a/tako-rs/tests/websocket_echo_compio.rs b/tako-rs/tests/websocket_echo_compio.rs new file mode 100644 index 00000000..7b594f82 --- /dev/null +++ b/tako-rs/tests/websocket_echo_compio.rs @@ -0,0 +1,160 @@ +#![cfg(all(feature = "ws", feature = "compio"))] + +use std::io::Read; +use std::io::Write; +use std::net::SocketAddr; +use std::net::TcpStream; +use std::time::Duration; + +use tako::CompioServer; +use tako::router::Router; +use tako::types::Request; +use tako::ws::WebSocketUpgrade; + +const UPGRADE: &[u8] = b"GET /ws HTTP/1.1\r\nHost: localhost\r\nUpgrade: websocket\r\n\ +Connection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\ +Sec-WebSocket-Version: 13\r\n\r\n"; +const MASK: [u8; 4] = [9, 8, 7, 6]; + +fn router() -> Router { + let mut router = Router::new(); + router.get("/ws", |req: Request| async move { + WebSocketUpgrade::new(req, |mut ws| async move { + while let Some(Ok(message)) = ws.recv().await { + if (message.is_text() || message.is_binary()) && ws.send(message).await.is_err() { + break; + } + } + }) + }); + router.get("/done", |req: Request| async move { + WebSocketUpgrade::new(req, |ws| async move { drop(ws) }) + }); + router.get("/panic", |req: Request| async move { + WebSocketUpgrade::new(req, |ws| async move { + let _ws = ws; + panic!("handler failure under test"); + }) + }); + router.get("/lifetime", |req: Request| async move { + WebSocketUpgrade::new(req, |ws| async move { + let _ws = ws; + std::future::pending::<()>().await; + }) + .max_lifetime(Duration::from_millis(30)) + }); + router.get("/moved", |req: Request| async move { + WebSocketUpgrade::new(req, |mut ws| async move { + compio::runtime::spawn(async move { + if let Some(Ok(message)) = ws.recv().await { + ws.send(message).await.unwrap(); + } + }) + .detach(); + }) + }); + router +} + +/// A masked client frame with a payload shorter than 126 bytes. +fn frame(first: u8, payload: &[u8]) -> Vec { + let mut frame = vec![first, 0x80 | payload.len() as u8]; + frame.extend_from_slice(&MASK); + frame.extend( + payload + .iter() + .zip(MASK.iter().cycle()) + .map(|(byte, key)| byte ^ key), + ); + frame +} + +fn read_exact(stream: &mut TcpStream, len: usize) -> Vec { + let mut buf = vec![0; len]; + stream.read_exact(&mut buf).unwrap(); + buf +} + +fn connect(address: SocketAddr, path: &str) -> TcpStream { + let mut stream = TcpStream::connect(address).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let upgrade = String::from_utf8(UPGRADE.to_vec()) + .unwrap() + .replace("/ws", path); + stream.write_all(upgrade.as_bytes()).unwrap(); + let mut head = Vec::new(); + while !head.ends_with(b"\r\n\r\n") { + head.push(read_exact(&mut stream, 1)[0]); + } + assert!( + head.starts_with(b"HTTP/1.1 101"), + "{}", + String::from_utf8_lossy(&head) + ); + stream +} + +fn exercise(address: SocketAddr) { + let mut stream = connect(address, "/ws"); + stream.write_all(&frame(0x81, b"hello")).unwrap(); + assert_eq!(read_exact(&mut stream, 7), b"\x81\x05hello"); + stream.write_all(&frame(0x89, b"p")).unwrap(); + assert_eq!(read_exact(&mut stream, 3), [0x8A, 1, b'p']); + let batch: Vec = (0..16u8).flat_map(|i| frame(0x82, &[i])).collect(); + stream.write_all(&batch).unwrap(); + let expected: Vec = (0..16u8).flat_map(|i| [0x82, 1, i]).collect(); + assert_eq!(read_exact(&mut stream, expected.len()), expected); + + stream + .write_all(&frame(0x88, &[0x03, 0xE8, b'b', b'y', b'e'])) + .unwrap(); + assert_eq!( + read_exact(&mut stream, 7), + [0x88, 5, 0x03, 0xE8, b'b', b'y', b'e'] + ); + let mut rest = Vec::new(); + stream.read_to_end(&mut rest).unwrap(); + assert!(rest.is_empty(), "{rest:?}"); +} + +fn exercise_lifecycle(address: SocketAddr) { + for (path, code) in [ + ("/done", 1000u16), + ("/panic", 1011), + ("/lifetime", 1001), + ("/moved", 1000), + ] { + let mut stream = connect(address, path); + if path == "/moved" { + stream.write_all(&frame(0x81, b"hello")).unwrap(); + assert_eq!(read_exact(&mut stream, 7), b"\x81\x05hello"); + } + let [high, low] = code.to_be_bytes(); + assert_eq!(read_exact(&mut stream, 4), [0x88, 2, high, low], "{path}"); + let mut rest = Vec::new(); + stream.read_to_end(&mut rest).unwrap(); + assert!(rest.is_empty(), "{path}: {rest:?}"); + } +} + +#[test] +fn echoes_on_the_compio_server() { + compio::runtime::Runtime::new().unwrap().block_on(async { + let listener = compio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let handle = CompioServer::builder() + .build() + .spawn_http(listener, router()); + let client = std::thread::spawn(move || { + exercise(address); + exercise_lifecycle(address); + }); + while !client.is_finished() { + compio::time::sleep(Duration::from_millis(10)).await; + } + client.join().unwrap(); + handle.shutdown(Duration::from_secs(1)).await; + }); +} diff --git a/tako-rs/tests/websocket_handshake.rs b/tako-rs/tests/websocket_handshake.rs index 27be44ac..6b9b4706 100644 --- a/tako-rs/tests/websocket_handshake.rs +++ b/tako-rs/tests/websocket_handshake.rs @@ -1,4 +1,4 @@ -#![cfg(any(all(feature = "ws", not(feature = "compio")), feature = "compio-ws"))] +#![cfg(feature = "ws")] use http::StatusCode; use http::header; @@ -6,16 +6,10 @@ use tako::body::TakoBody; use tako::responder::Responder; use tako::types::Request; use tako::types::Response; +use tako::ws::WebSocketUpgrade; fn respond(request: Request) -> Response { - #[cfg(not(feature = "compio"))] - { - tako::ws::TakoWs::new(request, |_| async {}).into_response() - } - #[cfg(feature = "compio-ws")] - { - tako::ws_compio::TakoWsCompio::new(request, |_| async {}).into_response() - } + WebSocketUpgrade::new(request, |_| async {}).into_response() } fn request(version: &str) -> Request { diff --git a/website/content/docs/concepts/runtimes.mdx b/website/content/docs/concepts/runtimes.mdx index e8d4a776..b20fe867 100644 --- a/website/content/docs/concepts/runtimes.mdx +++ b/website/content/docs/concepts/runtimes.mdx @@ -32,8 +32,8 @@ Tokio feature set without `compio`. ## Switching runtimes By default you get Tokio. To build on Compio, disable defaults and enable the -`compio` feature; add `compio-tls` and `compio-ws` for TLS and WebSocket -support on that runtime. +`compio` feature; add `compio-tls` for TLS on that runtime. Transport features +such as `ws` work on either runtime. @@ -51,7 +51,7 @@ tako-rs = "2" tako-rs = { version = "2", default-features = false, features = [ "compio", "compio-tls", - "compio-ws", + "ws", ] } ``` @@ -73,7 +73,7 @@ Every transport runs on both runtimes: | HTTP/2 | yes | yes | `http2` | | HTTP/3 (QUIC) | yes | yes | `http3` | | TLS (rustls) | yes | yes | `tls` / `compio-tls` | -| WebSocket | yes | yes | `ws` / `compio-ws` | +| WebSocket | yes | yes | `ws` | | WebTransport | yes | yes | `webtransport` | | SSE | yes | yes | `sse` | | gRPC (unary and streaming) | yes | yes | `grpc` | diff --git a/website/content/docs/getting-started/coming-from-axum.mdx b/website/content/docs/getting-started/coming-from-axum.mdx index 5b6b2682..d7f7c194 100644 --- a/website/content/docs/getting-started/coming-from-axum.mdx +++ b/website/content/docs/getting-started/coming-from-axum.mdx @@ -32,7 +32,7 @@ places where the two differ. | `nest`, `fallback` | `router.nest`, `router.fallback` | | `axum::serve` | `Server::builder()` | | `with_graceful_shutdown` | `handle.shutdown_on_signal()` | -| `WebSocketUpgrade` | `TakoWs::new(req, handler)` | +| `WebSocketUpgrade` | `WebSocketUpgrade::new(req, handler)` | | `sse::Sse`, `Event` | `Sse::events`, `SseEvent` | | `ServiceExt::oneshot` | `router.dispatch(request)` | @@ -230,16 +230,15 @@ TLS and has no HTTP/3 server. ## WebSocket and SSE A Tako WebSocket handler receives the whole request instead of a -`WebSocketUpgrade` extractor, and the socket is a `tokio-tungstenite` -`WebSocketStream`: +`WebSocketUpgrade` extractor. The socket's `recv` and `send` work like those of +axum's `WebSocket`: ```rust -use futures_util::{SinkExt, StreamExt}; -use tako::ws::TakoWs; +use tako::ws::WebSocketUpgrade; async fn echo(req: Request) -> impl Responder { - TakoWs::new(req, |mut ws| async move { - while let Some(Ok(msg)) = ws.next().await { + WebSocketUpgrade::new(req, |mut ws| async move { + while let Some(Ok(msg)) = ws.recv().await { if (msg.is_text() || msg.is_binary()) && ws.send(msg).await.is_err() { break; } diff --git a/website/content/docs/getting-started/installation.mdx b/website/content/docs/getting-started/installation.mdx index 6d65abd2..7290b6fc 100644 --- a/website/content/docs/getting-started/installation.mdx +++ b/website/content/docs/getting-started/installation.mdx @@ -76,6 +76,7 @@ for most often: | `http2` | HTTP/2 (h2c cleartext and ALPN-negotiated h2 over TLS) | | `http3` | HTTP/3 over QUIC | | `tls` | Server-side HTTPS via rustls | +| `ws` | WebSocket upgrades, on either runtime | | `grpc` | gRPC unary and streaming RPCs with protobuf | | `simd` | SIMD JSON parsing (`sonic-rs` + `simd-json`) | | `multipart` | `Multipart` / typed multipart body extractors | @@ -84,14 +85,13 @@ for most often: | `file-stream` | File streaming, range requests, conditional GET | | `jemalloc` | Re-export `tako::Jemalloc`; your binary sets it as the global allocator | -Compio has its own TLS and WebSocket flags, since those features compile -differently on the two runtimes: +Compio has its own TLS flag, since TLS compiles differently on the two +runtimes: | Flag | Turns on | |---|---| | `compio` | The Compio runtime (mutually exclusive with the Tokio path) | | `compio-tls` | TLS on Compio (implies `compio`) | -| `compio-ws` | WebSocket on Compio (implies `compio`) | Enable several at once: diff --git a/website/content/docs/reference/features.mdx b/website/content/docs/reference/features.mdx index d6072c40..5851f1ab 100644 --- a/website/content/docs/reference/features.mdx +++ b/website/content/docs/reference/features.mdx @@ -28,7 +28,7 @@ cargo metadata --format-version 1 --no-deps \ | Feature | Description | Gates | |---|---|---| | *(default)* | HTTP/1.1, raw TCP, Unix sockets, static files, core extractors and middleware. No optional flags are enabled. | always on | -| `ws` | WebSocket upgrades over HTTP/1.1 on Tokio; GraphQL subscriptions also require `async-graphql`. | `tako-rs-core/ws`, `tako-rs-streams/ws` | +| `ws` | WebSocket upgrades over HTTP/1.1 on either runtime; GraphQL subscriptions also require `async-graphql` and run on Tokio. | `tako-rs-core/ws` | | `sse` | Server-Sent Events on either runtime. | `tako-rs-streams/sse` | | `file-stream` | `FileStream` responder and range helpers; static file serving is available without this flag. | `tako-rs-streams/file-stream`, `tako-rs-core/file-stream` | | `proxy-protocol` | Trusted PROXY v1/v2 listener on either runtime. | `tako-rs-server/proxy-protocol` | @@ -46,7 +46,6 @@ cargo metadata --format-version 1 --no-deps \ |---|---|---| | `compio` | Switch the server to the `compio` runtime (io_uring / IOCP / kqueue). Mutually exclusive with the tokio path at build time. | `tako-rs-core/compio`, `tako-rs-server/compio`, `tako-rs-streams/compio`, `tako-rs-plugins/compio`, `tako-rs-server-pt?/compio` | | `compio-tls` | TLS on compio. Implies `compio`. | `compio`, `tako-rs-server/compio-tls`, `tako-rs-core/compio-tls` | -| `compio-ws` | WebSocket on compio. Implies `compio`. | `compio`, `tako-rs-streams/compio-ws`, `tako-rs-core/compio-ws` | | `per-thread` | Thread-per-core HTTP/1; Tokio by default, Compio when `compio` is selected. | `dep:tako-rs-server-pt` | | `per-thread-compio` | Thread-per-core on Compio. Implies `per-thread` and `compio` throughout the workspace. | `per-thread`, `compio`, `tako-rs-server-pt/compio` | diff --git a/website/content/docs/reference/meta.json b/website/content/docs/reference/meta.json index 4f94cfed..42a8e1ea 100644 --- a/website/content/docs/reference/meta.json +++ b/website/content/docs/reference/meta.json @@ -1,4 +1,4 @@ { "title": "Reference", - "pages": ["migration-2-4", "migration-2-3", "migration-2-2", "migration-2-1", "migration", "features", "api"] + "pages": ["migration-2-5", "migration-2-4", "migration-2-3", "migration-2-2", "migration-2-1", "migration", "features", "api"] } diff --git a/website/content/docs/reference/migration-2-5.mdx b/website/content/docs/reference/migration-2-5.mdx new file mode 100644 index 00000000..f80870de --- /dev/null +++ b/website/content/docs/reference/migration-2-5.mdx @@ -0,0 +1,169 @@ +--- +title: Migrating to 2.5 +description: Tako 2.5 replaces tokio-tungstenite with its own WebSocket engine; move handlers from TakoWs to WebSocketUpgrade and compio-ws to compio plus ws. +category: reference +since: 2.5.0 +status: stable +crate: tako-rs +runtime: both +--- + +# Migrating to 2.5 + +Tako 2.5.0 ships its own WebSocket engine and drops `tokio-tungstenite`, +`tungstenite` and `compio-ws`. WebSocket handlers need code changes; nothing +else does. The engine and its API are the same on Tokio and Compio. See the +[release notes](https://github.com/rust-dd/tako/releases/tag/v2.5.0) for the +complete list. + +## Replace `TakoWs` with `WebSocketUpgrade` + +`tako::ws::TakoWs` is now `tako::ws::WebSocketUpgrade`, and the handler receives +a `tako::ws::WebSocket` instead of a `tokio-tungstenite` `WebSocketStream`. Read +with `recv()`, write with `send()`, and take `Message` from `tako::ws`. + +Before (2.4): + +```rust +use futures_util::SinkExt; +use futures_util::StreamExt; +use tako::responder::Responder; +use tako::types::Request; +use tako::ws::TakoWs; +use tokio_tungstenite::tungstenite::Message; + +async fn echo(req: Request) -> impl Responder { + TakoWs::new(req, |mut ws| async move { + while let Some(Ok(msg)) = ws.next().await { + match msg { + Message::Text(_) | Message::Binary(_) => { + if ws.send(msg).await.is_err() { + break; + } + } + Message::Ping(payload) => { + let _ = ws.send(Message::Pong(payload)).await; + } + Message::Close(_) => break, + _ => {} + } + } + }) +} +``` + +After (2.5): + +```rust +use tako::responder::Responder; +use tako::types::Request; +use tako::ws::WebSocketUpgrade; + +async fn echo(req: Request) -> impl Responder { + WebSocketUpgrade::new(req, |mut ws| async move { + while let Some(Ok(msg)) = ws.recv().await { + if (msg.is_text() || msg.is_binary()) && ws.send(msg).await.is_err() { + break; + } + } + }) +} +``` + +Tako answers pings and the peer's close frame itself, so the `Ping` and `Close` +arms can go. `WebSocket` still implements `Stream` and `Sink`, so code built on +`StreamExt::next`, `split` or `forward` keeps working once `Message` comes from +`tako::ws`. + +## The message types + +| 2.4 (`tungstenite`) | 2.5 (`tako::ws`) | +|---|---| +| `Message::Text(Utf8Bytes)`, `Message::text(..)` | The same, with tako's `Utf8Bytes` | +| `Message::Binary(Bytes)`, `Message::binary(..)` | The same | +| `Message::Frame(_)` | Removed; raw frames are never yielded | +| `CloseCode::Normal`, `CloseCode::from(1000)` | `CloseCode::NORMAL`, `CloseCode::new(1000)` | +| `tungstenite::Error` | `tako::ws::Error`: `Io`, `Protocol(ProtocolError)` or `Closed` | + +## `send` queues frames + +In 2.4 every `send` wrote its frame before it returned. In 2.5 `send` queues the +frame, and queued frames go out together when the handler waits for anything +(the next `recv()`, a timer, a channel), when more than `write_buffer_size` bytes +are queued (64 KiB by default), or on `flush()`. An echo loop that receives 16 +pipelined messages answers them with one write instead of 16. Call `flush()` +where the handler must know a frame has left before it continues. + +A `send` from another task, for example through the write half of `split()`, +writes at once, as before. + +Ping and Pong payloads over 125 bytes, close reasons over 123 bytes, and +reserved close codes return `Error::Protocol` without sending a frame. Payloads +are never silently shortened. Calling `close()` after receiving the peer's +Close flushes the queued reply; repeated calls only flush. + +## The connection closes with a close frame + +In 2.4 the socket was dropped without a close frame when the handler returned, +panicked, or ran past `max_lifetime`. In 2.5 the connection closes with code 1000 +when the handler returns and no `WebSocket` handle is left, with 1011 when the +handler panics, and with 1001 when `max_lifetime` runs out. A `WebSocket` moved +to another task keeps the connection open until it is dropped. + +The driver flushes its Close and shuts down the transport, with a five-second +limit for a peer that stops reading. It does not wait for the peer's Close +reply; a peer with unread data in flight can observe a transport reset. + +## Builder methods + +`keep_alive(WsKeepAlive)` never had an effect; it is removed together with +`WsKeepAlive`. `write_buffer_size` is new. The other methods keep their names +and arguments: `protocols`, `allowed_origins`, `max_frame_size`, +`max_message_size`, `read_buffer_size`, `upgrade_timeout` and `max_lifetime`. + +`read_buffer_size` accepts 1 byte through 64 KiB and controls the initial read +allocation, which grows with incoming frame data. Invalid buffer sizes and +limits beyond addressable allocation sizes reject the upgrade with HTTP 500. +Zero frame/message limits permit empty payloads; a zero write threshold flushes +queued output before each send. + +## Compio + +The `compio-ws` feature and the `tako::ws_compio` module are gone, together with +`TakoWsCompio`, `CompioWebSocket` and `UpgradedStream`. Enable `compio` and `ws` +and use the same `tako::ws` API as on Tokio: + +```toml +[dependencies] +tako-rs = { version = "2.5", default-features = false, features = ["compio", "ws"] } +``` + +Compio's own `ws` feature is no longer needed. + +## Performance changes + +These need no code changes. Against 2.4.2 in the same benchmark run, on a +per-thread server with 4 workers echoing 5-byte text messages, 2.5: + +- answers 16 pipelined messages per connection with one write instead of 16, + serving 8 times as many messages at 512 connections and 7 times at 4,096; +- serves 2 to 19% more messages with one message in flight, from 512 to 16,384 + connections, and 3 to 6% more when each connection closes after 10 messages; +- holds slightly less heap per open connection with small messages, about + 5.6 KiB against 5.9 KiB. + +On Tokio, reads go into uninitialized buffer space. Payloads up to 4 KiB are +copied out, and larger ones are handed over without a copy. All 247 cases tako +runs from the Autobahn suite pass on both runtimes (its performance and +compression groups 9, 12 and 13 are excluded), with no unclean close where 2.4.2 +had 127. + +## Dependencies and GraphQL + +Remove `tokio-tungstenite` from your `Cargo.toml`, and `futures-util` if the +socket was its only user. + +`GraphQLSubscription` needs no changes. `GraphQLWebSocket` now runs over tako's +`Message` and `Error`, so a handler that drives it directly hands it the +`WebSocket` from `WebSocketUpgrade`; see +[`examples/graphql-generic-ws`](https://github.com/rust-dd/tako/tree/main/examples/graphql-generic-ws). diff --git a/website/content/docs/streams.mdx b/website/content/docs/streams.mdx index 091d6b82..dc06475d 100644 --- a/website/content/docs/streams.mdx +++ b/website/content/docs/streams.mdx @@ -61,33 +61,29 @@ SSE requires the `sse` feature. ## WebSocket -`TakoWs::new(req, fut)` performs the upgrade from inside an HTTP -handler. The closure receives a `WebSocket` half-duplex pair -(`tokio_tungstenite` on tokio, `compio_ws` on compio) that you drive -to completion: +`WebSocketUpgrade::new(req, handler)` performs the upgrade from inside an HTTP +handler and runs `handler` on the upgraded `WebSocket`: ```rust -use futures_util::{SinkExt, StreamExt}; -use tako::Method; use tako::responder::Responder; use tako::types::Request; -use tako::ws::TakoWs; -use tokio_tungstenite::tungstenite::Message; +use tako::ws::WebSocketUpgrade; async fn ws_echo(req: Request) -> impl Responder { - TakoWs::new(req, |mut ws| async move { - while let Some(Ok(msg)) = ws.next().await { - if let Message::Text(t) = msg { - let _ = ws.send(Message::Text(t)).await; + WebSocketUpgrade::new(req, |mut ws| async move { + while let Some(Ok(message)) = ws.recv().await { + if message.is_text() && ws.send(message).await.is_err() { + break; } } }) } ``` -Enable `ws` for Tokio or `compio-ws` for Compio. Configure subprotocols, -frame/message limits, allowed origins, and upgrade timeout on the responder. -The handler implements its own ping/pong policy; permessage-deflate is not provided. +Enable `ws`; together with `compio` it runs on Compio. Configure subprotocols, +frame and message limits, buffer sizes, allowed origins, and the upgrade timeout +on the builder. tako answers pings and the closing handshake; permessage-deflate +is not provided. See [WebSocket transport](/docs/transports/websocket) for the full transport reference. @@ -116,7 +112,7 @@ See also: and [`examples/http3-sse`](https://github.com/rust-dd/tako/tree/main/examples/http3-sse) for SSE, - [`examples/websocket`](https://github.com/rust-dd/tako/tree/main/examples/websocket), - [`examples/websocket-http2`](https://github.com/rust-dd/tako/tree/main/examples/websocket-http2), + [`examples/websocket-tls`](https://github.com/rust-dd/tako/tree/main/examples/websocket-tls), and [`examples/websocket-compio`](https://github.com/rust-dd/tako/tree/main/examples/websocket-compio) for WebSocket, - [`examples/file-stream`](https://github.com/rust-dd/tako/tree/main/examples/file-stream) diff --git a/website/content/docs/transports/index.mdx b/website/content/docs/transports/index.mdx index c117302e..cd5c138b 100644 --- a/website/content/docs/transports/index.mdx +++ b/website/content/docs/transports/index.mdx @@ -22,7 +22,7 @@ time β€” the routing, middleware, and observability model does not change. | HTTP/2 | yes | yes | `http2` | | HTTP/3 (QUIC) | yes | yes | `http3` | | TLS (rustls) | yes | yes | `tls` / `compio-tls` | -| WebSocket | yes | yes | `ws` / `compio-ws` | +| WebSocket | yes | yes | `ws` | | WebTransport | yes | yes | `webtransport` | | SSE | yes | yes | `sse` | | gRPC (unary and streaming) | yes | yes | `grpc` | @@ -92,8 +92,8 @@ The handle is runtime-agnostic β€” both `Server` (tokio) and `CompioServer` ## WebSocket, SSE, and the HTTP handler -WebSocket and SSE live inside HTTP handlers. `TakoWs::new(req, fut)` performs -an HTTP/1.1 Upgrade; it works over plain HTTP/1.1 and TLS negotiated as HTTP/1.1. +WebSocket and SSE live inside HTTP handlers. `WebSocketUpgrade::new(req, handler)` +performs an HTTP/1.1 Upgrade; it works over plain HTTP/1.1 and TLS negotiated as HTTP/1.1. HTTP/2 Extended CONNECT is not implemented. `tako::sse::Sse` wraps a stream in a `text/event-stream` response and can use any supported HTTP transport. diff --git a/website/content/docs/transports/websocket.mdx b/website/content/docs/transports/websocket.mdx index 2c0d0a14..62467097 100644 --- a/website/content/docs/transports/websocket.mdx +++ b/website/content/docs/transports/websocket.mdx @@ -1,61 +1,32 @@ --- title: WebSocket -description: Upgrade an HTTP request to a WebSocket inside a handler, then run the send/receive message loop on either runtime. +description: Upgrade an HTTP request to a WebSocket inside a handler and run the message loop, on either runtime. category: transport since: 2.0.0 status: stable -crate: tako-rs-streams +crate: tako-rs-core runtime: both -features: [ws, compio-ws] +features: [ws] --- # WebSocket -WebSocket is not a separate listener β€” it lives inside an HTTP handler on -an HTTP/1.1 listener, optionally over TLS. `TakoWs::new(req, fut)` performs the -RFC-6455 server-side handshake and hands the upgraded stream to your closure. -The handler owns the stream and drives the message loop. - -WebSocket is available on both runtimes: enable `ws` for Tokio -(`tako::ws`), and behind the `compio-ws` feature for Compio (`tako::ws_compio`). - -## Tokio - -On Tokio, `TakoWs` returns a `tokio_tungstenite` `WebSocketStream`. It is a -`Sink` + `Stream` of `Message`, so you use `SinkExt::send` and `StreamExt::next`. +WebSocket is not a separate listener: it lives inside an HTTP handler on an +HTTP/1.1 listener, optionally over TLS. `WebSocketUpgrade::new(req, handler)` +answers the RFC 6455 handshake and runs `handler` on the upgraded connection. +Enable the `ws` feature; together with `compio` the same API runs on Compio. ```rust -use futures_util::SinkExt; -use futures_util::StreamExt; use tako::Method; use tako::responder::Responder; use tako::types::Request; -use tako::ws::TakoWs; -use tokio_tungstenite::tungstenite::Message; -use tokio_tungstenite::tungstenite::Utf8Bytes; +use tako::ws::WebSocketUpgrade; async fn ws_echo(req: Request) -> impl Responder { - TakoWs::new(req, |mut ws| async move { - let _ = ws.send(Message::Text("Welcome to Tako WS!".into())).await; - - while let Some(Ok(msg)) = ws.next().await { - match msg { - Message::Text(txt) => { - let _ = ws - .send(Message::Text(Utf8Bytes::from(format!("Echo: {txt}")))) - .await; - } - Message::Binary(bin) => { - let _ = ws.send(Message::Binary(bin)).await; - } - Message::Ping(p) => { - let _ = ws.send(Message::Pong(p)).await; - } - Message::Close(_) => { - let _ = ws.send(Message::Close(None)).await; - break; - } - _ => {} + WebSocketUpgrade::new(req, |mut ws| async move { + while let Some(Ok(message)) = ws.recv().await { + if (message.is_text() || message.is_binary()) && ws.send(message).await.is_err() { + break; } } }) @@ -72,49 +43,51 @@ async fn main() { } ``` -The handler is just another route β€” register it with `router.route` and serve -it on an HTTP/1.1 transport. Because the closure returns a `Responder`, the upgrade -response (`101 Switching Protocols`) is produced for you; the upgraded socket is -driven on a spawned task once the handshake completes. +The handler is an ordinary route. The `101 Switching Protocols` response is +produced for you, and the connection runs on its own task once the handshake +completes. +## Messages + +`recv()` returns `Option>`, and `None` once the +connection is closed. It is cancel-safe, so it can race other futures in +`tokio::select!`. A `Message` is `Text`, `Binary`, `Ping`, `Pong` or `Close`. +tako answers pings and the closing handshake itself, so a handler only handles +the messages it cares about. Extensions such as permessage-deflate are not +negotiated. + +## Sending and batching + +`send()` queues a message. Queued messages go out together, in one write, when +the handler waits for anything (the next `recv()`, a timer, a channel), when +more than `write_buffer_size` bytes are queued, or on `flush()`. An echo loop +that receives 16 pipelined messages answers them with a single write. Called +from another task, for example after `split()`, `send()` writes at once. + +`WebSocket` also implements `Stream` and `Sink`, so `split`, `forward` and the +other combinators work. Through `Sink`, `poll_flush` always writes. + +When the handler returns and no `WebSocket` handle is left, tako closes the +connection with code 1000. A handler that panics closes it with 1011. + ## Compio -On Compio, enable the `compio-ws` feature and use `TakoWsCompio`. The upgraded -type is `CompioWebSocket`, which exposes an explicit `read()` -method rather than the `Stream` interface. +With the `compio` and `ws` features the same code runs on Compio. `WebSocket` is +then `!Send`, and the handler runs on the connection's thread. ```rust -use compio::ws::tungstenite::Message; use tako::Method; use tako::responder::Responder; use tako::types::Request; -use tako::ws_compio::CompioWebSocket; -use tako::ws_compio::TakoWsCompio; -use tako::ws_compio::UpgradedStream; +use tako::ws::WebSocketUpgrade; async fn ws_echo(req: Request) -> impl Responder { - TakoWsCompio::new(req, |mut ws: CompioWebSocket| async move { - let _ = ws.send(Message::Text("Welcome to Tako WS (compio)!".into())).await; - - loop { - match ws.read().await { - Ok(Message::Text(txt)) => { - let _ = ws.send(Message::Text(format!("Echo: {txt}").into())).await; - } - Ok(Message::Binary(bin)) => { - let _ = ws.send(Message::Binary(bin)).await; - } - Ok(Message::Ping(p)) => { - let _ = ws.send(Message::Pong(p)).await; - } - Ok(Message::Close(_)) => { - let _ = ws.close(None).await; - break; - } - Ok(_) => {} - Err(_) => break, + WebSocketUpgrade::new(req, |mut ws| async move { + while let Some(Ok(message)) = ws.recv().await { + if (message.is_text() || message.is_binary()) && ws.send(message).await.is_err() { + break; } } }) @@ -135,25 +108,26 @@ async fn main() { ## Configuration -On Tokio, `TakoWs` is a builder. Each option is a chained method before the +`WebSocketUpgrade` is a builder. Each option is a chained method before the handler runs: -| Method | Effect | -|---|---| -| `.protocols(list)` | Subprotocol allow-list; the first server-preferred match the client offers is echoed back. | -| `.max_frame_size(n)` | Cap a single WebSocket frame, in bytes. | -| `.max_message_size(n)` | Cap a reassembled message, in bytes. | -| `.allowed_origins(list)` | Reject upgrades whose `Origin` is not on the list with `403`. | -| `.upgrade_timeout(d)` | Drop the task if the client never completes the upgrade. | -| `.max_lifetime(d)` | Hard cap on total conversation lifetime after upgrade. | -| `.keep_alive(WsKeepAlive { .. })` | Deprecated; has no effect. Implement keep-alive in the handler. | +| Method | Default | Effect | +|---|---|---| +| `.protocols(list)` | none | Subprotocol allow-list; the first server-preferred match the client offers is echoed back. | +| `.allowed_origins(list)` | any origin | Reject upgrades whose `Origin` is not on the list with `403`. | +| `.max_frame_size(n)` | 16 MiB | Largest frame payload; a larger frame closes the connection with 1009. | +| `.max_message_size(n)` | 64 MiB | Largest reassembled message; a larger one closes the connection with 1009. | +| `.read_buffer_size(n)` | 4 KiB | Initial capacity, from 1 byte to 64 KiB; the buffer grows with incoming frame data. | +| `.write_buffer_size(n)` | 64 KiB | Queued bytes past which `send()` writes at once. | +| `.upgrade_timeout(d)` | none | Give up if the server never hands over the upgraded connection. | +| `.max_lifetime(d)` | none | Close with 1001 once the connection has been open this long. | ```rust use std::time::Duration; -use tako::ws::TakoWs; +use tako::ws::WebSocketUpgrade; # fn _doc(req: tako::types::Request) -> impl tako::responder::Responder { -TakoWs::new(req, |ws| async move { /* ... */ }) +WebSocketUpgrade::new(req, |ws| async move { /* ... */ }) .protocols(["chat", "superchat"]) .max_message_size(1 << 20) .allowed_origins(["https://app.example.com"]) @@ -162,20 +136,20 @@ TakoWs::new(req, |ws| async move { /* ... */ }) # } ``` -The handler owns the socket and must implement ping intervals and pong deadlines. -`max_lifetime` caps total conversation lifetime; it does not detect idle peers. +Pings are answered while the handler reads. To detect idle peers, send your own +pings from the handler; `max_lifetime` caps the total lifetime and does not +detect idle peers. -## TLS and HTTP/2 listeners +## TLS listeners -The `examples/websocket-http2` TLS listener supports HTTP/2 for ordinary HTTP -requests. WebSocket upgrades on that listener still negotiate HTTP/1.1. -HTTP/2 Extended CONNECT is not supported. Invalid upgrade methods return 405, -unsupported WebSocket versions return 426 with `Sec-WebSocket-Version: 13`, -and malformed handshake headers or keys return 400. +`examples/websocket-tls` serves wss on a TLS listener. Upgrades negotiate +HTTP/1.1; HTTP/2 Extended CONNECT (RFC 8441) is not supported. Invalid upgrade +methods return 405, unsupported WebSocket versions return 426 with +`Sec-WebSocket-Version: 13`, and malformed handshake headers or keys return 400. ## Related - [Routing](/docs/routing) β€” registering the upgrade handler as a route. - [HTTP](/docs/transports/http) β€” the listeners WebSocket rides on. - [Streams](/docs/streams) β€” SSE and other streaming responses. -- [Feature flags](/docs/reference/features) β€” the `compio-ws` feature. +- [Feature flags](/docs/reference/features) β€” the `ws` feature. diff --git a/website/content/docs/tutorials/realtime-chat.mdx b/website/content/docs/tutorials/realtime-chat.mdx index 1046750c..d4146fbb 100644 --- a/website/content/docs/tutorials/realtime-chat.mdx +++ b/website/content/docs/tutorials/realtime-chat.mdx @@ -4,7 +4,7 @@ description: Build a full-duplex WebSocket feature with an echo handler and a se category: tutorial since: 2.0.0 status: stable -crate: tako-rs-streams +crate: tako-rs-core runtime: tokio --- @@ -19,8 +19,8 @@ crate, which serves two endpoints: - `GET /ws/tick` β€” pushes a `tick #N` message to the client once per second These are the two halves of any chat-style feature: receiving messages from a -client, and pushing messages to it on a schedule or from another source. WebSocket -requires the `ws` feature on Tokio. +client, and pushing messages to it on a schedule or from another source. +WebSocket requires the `ws` feature. If you have not served a handler before, read the [Quickstart](/docs/getting-started/quickstart) first. @@ -28,62 +28,67 @@ If you have not served a handler before, read the ## 1. The full example A WebSocket endpoint in Tako is an ordinary HTTP handler that returns -`TakoWs::new(req, closure)`. The closure receives an upgraded socket and drives -the conversation. This is the whole file: +`WebSocketUpgrade::new(req, handler)`. The handler receives the upgraded +`WebSocket` and drives the conversation. This is the whole file: ## 2. How the upgrade works -`TakoWs::new(req, |ws| async move { … })` is a [`Responder`](/docs/routing): when -the handler returns it, Tako performs the WebSocket handshake on the incoming -`Request` and then runs your closure with the upgraded connection. Inside the -closure, `ws` is both a `Stream` of inbound `Message`s and a `Sink` you can -`send` outbound `Message`s into β€” the `futures_util::{StreamExt, SinkExt}` traits -provide `.next()` and `.send()`. +`WebSocketUpgrade::new(req, |ws| async move { … })` is a [`Responder`](/docs/routing): +when the handler returns it, Tako answers the WebSocket handshake on the incoming +`Request` and then runs your closure on the upgraded connection. Inside the +closure, `ws.recv()` returns the next inbound `Message` and `ws.send()` queues an +outbound one. Queued messages go out together once the closure waits for the next +message, so a burst of replies leaves in one write. The echo handler is a straight read-respond loop: ```rust -while let Some(Ok(msg)) = ws.next().await { - match msg { - Message::Text(txt) => { - let _ = ws.send(Message::Text(format!("Echo: {txt}").into())).await; - } - Message::Binary(bin) => { let _ = ws.send(Message::Binary(bin)).await; } - Message::Ping(p) => { let _ = ws.send(Message::Pong(p)).await; } - Message::Close(_) => { let _ = ws.send(Message::Close(None)).await; break; } - _ => {} +while let Some(Ok(message)) = ws.recv().await { + let reply = match message { + Message::Text(text) => Message::text(format!("Echo: {text}")), + Message::Binary(data) => Message::Binary(data), + _ => continue, + }; + if ws.send(reply).await.is_err() { + break; } } ``` -The `Message` type comes from `tokio-tungstenite`. Handling `Ping`/`Close` -explicitly keeps the connection healthy and shuts it down cleanly. +Tako answers pings and the closing handshake itself, so the loop only handles +text and binary messages. `recv()` returns `None` once the connection has closed. ## 3. Server-initiated pushes A chat feed has to push messages the client did not ask for β€” a new message from another user, a periodic heartbeat. The `ws_tick` handler shows the pattern: -`tokio::select!` races the inbound stream against a timer, so the task both -reacts to the client (closing when it disconnects) and pushes on its own +`tokio::select!` races the next inbound message against a timer, so the task both +reacts to the client (stopping when it disconnects) and pushes on its own schedule: ```rust +let mut ticker = tokio::time::interval(Duration::from_secs(1)); +let mut tick = 0u64; loop { tokio::select! { - msg = ws.next() => match msg { - Some(Ok(Message::Close(_))) | None => break, - _ => {} + message = ws.recv() => match message { + Some(Ok(_)) => {} + _ => break, }, - Some((i, _)) = ticker.next() => { - let _ = ws.send(Message::Text(format!("tick #{i}").into())).await; + _ = ticker.tick() => { + if ws.send(Message::text(format!("tick #{tick}"))).await.is_err() { + break; + } + tick += 1; } } } ``` -To turn this into a real chat fan-out, replace the `ticker` arm with a +`recv()` is cancel-safe: when the timer wins, no inbound data is lost. To turn +this into a real chat fan-out, replace the `ticker` arm with a `tokio::sync::broadcast::Receiver`: every connected socket subscribes to a shared `broadcast::Sender`, and posting a message sends it to every subscriber. The `select!` skeleton stays identical β€” one arm reads the client, the other reads @@ -91,21 +96,14 @@ the broadcast channel. ## 4. The Cargo.toml -The example pulls in the umbrella crate plus the stream/codec helpers it uses in -the closures: - ```toml [dependencies] -futures-util = "0.3" -tako-rs = { version = "2", features = ["ws"] } +tako-rs = { version = "2.5", features = ["ws"] } tokio = { version = "1", features = ["full"] } -tokio-stream = "0.1" -tokio-tungstenite = "0.30" ``` -The `ws` feature enables `tako::ws::TakoWs`. `futures-util` -provides the `Stream`/`Sink` extension traits, and `tokio-tungstenite` provides -the `Message` enum used on the wire. +The `ws` feature enables `tako::ws`, which also provides the `Message` type, so +no other WebSocket crate is needed. ## 5. Running it @@ -135,15 +133,16 @@ ws.onopen = () => ws.send("hello"); ## Tuning the connection -`TakoWs` is a builder. Before returning it you can constrain the handshake and -the frames β€” `protocols`, `max_frame_size`, `max_message_size`, -`allowed_origins`, `upgrade_timeout`, and `keep_alive(WsKeepAlive)`. These guard -against oversized frames and slow-loris upgrades on a public endpoint. See the -[WebSocket transport reference](/docs/transports/websocket) for the full builder. +`WebSocketUpgrade` is a builder. Before returning it you can constrain the +handshake and the frames β€” `protocols`, `allowed_origins`, `max_frame_size`, +`max_message_size`, `read_buffer_size`, `write_buffer_size`, `upgrade_timeout` +and `max_lifetime`. These guard against oversized frames and stalled upgrades on +a public endpoint. See the [WebSocket transport reference](/docs/transports/websocket) +for the defaults. -WebSocket uses `ws` on Tokio and `compio-ws` on Compio. -HTTP/2 WebSocket (RFC 8441) and the deflate extension are covered in the +The same handler runs on Compio when you enable `compio` next to `ws`. +HTTP/2 WebSocket (RFC 8441) and the deflate extension are not supported; see the [transport reference](/docs/transports/websocket). @@ -175,8 +174,8 @@ collaborative editing, game state. Reach for SSE when it only needs to listen. ## Next steps -- [WebSocket transport](/docs/transports/websocket) β€” the full `TakoWs` builder - and configuration. +- [WebSocket transport](/docs/transports/websocket) β€” the full + `WebSocketUpgrade` builder and configuration. - [Server-Sent Events](/docs/transports/sse) β€” the one-way streaming alternative. - [Building a REST API](/docs/tutorials/rest-api) β€” the companion tutorial.