From 302858e655e9c72ce21adef13237af1ed0b33dc4 Mon Sep 17 00:00:00 2001 From: Daniel Boros Date: Mon, 19 Oct 2026 09:46:34 +0200 Subject: [PATCH 1/2] feat: replace tungstenite with a shared websocket engine Use the same bounded, buffered WebSocket protocol engine on Tokio and Compio, including GraphQL integration, examples, migration docs and Autobahn CI. Resolve review M1-M7: idempotent close replies, no deferred pong after close, validated allocation limits, rejected oversized controls, accurate docs, socket and driver tests on both runtimes, and concise Autobahn script docs. Also cover zero write thresholds and valid gateway close code 1014. Validation: standalone workspace tests with ws/async-graphql and all features; all-features Clippy; 77 WebSocket tests on each runtime; Autobahn 247/247 on Tokio and Compio; 1,648,034 fuzz inputs without a crash. --- .github/workflows/ci.yml | 18 +- Cargo.lock | 58 +- Cargo.toml | 7 +- ci/autobahn/README.md | 18 + ci/autobahn/run.sh | 26 +- .../plans/2026-10-18-websocket-engine.md | 340 +++ .../2026-10-18-websocket-engine-design.md | 254 ++ examples/autobahn-echo-compio/Cargo.lock | 2676 +++++++++++++++++ examples/autobahn-echo-compio/Cargo.toml | 16 + examples/autobahn-echo-compio/src/main.rs | 35 + examples/autobahn-echo/Cargo.lock | 115 +- examples/autobahn-echo/Cargo.toml | 4 - examples/autobahn-echo/src/main.rs | 33 +- examples/graphql-generic-ws/Cargo.lock | 135 +- examples/graphql-generic-ws/Cargo.toml | 12 - examples/graphql-generic-ws/src/main.rs | 74 +- examples/websocket-compio/Cargo.lock | 3 - examples/websocket-compio/Cargo.toml | 4 +- examples/websocket-compio/src/main.rs | 75 +- examples/websocket-http2/Cargo.toml | 21 - examples/websocket-http2/src/main.rs | 78 - .../Cargo.lock | 144 +- examples/websocket-tls/Cargo.toml | 15 + examples/websocket-tls/src/main.rs | 67 + examples/websocket/Cargo.lock | 116 +- examples/websocket/Cargo.toml | 6 - examples/websocket/src/main.rs | 65 +- fuzz/Cargo.lock | 2 +- fuzz/Cargo.toml | 10 +- fuzz/fuzz_targets/ws_frame.rs | 7 + tako-rs-core/Cargo.toml | 4 +- tako-rs-core/src/graphql/subscription.rs | 145 +- tako-rs-core/src/graphql/websocket.rs | 101 +- tako-rs-core/src/lib.rs | 5 + tako-rs-core/src/ws.rs | 63 + tako-rs-core/src/ws/driver.rs | 151 + tako-rs-core/src/ws/driver/tests.rs | 192 ++ tako-rs-core/src/ws/error.rs | 145 + tako-rs-core/src/ws/frame.rs | 211 ++ .../src/ws/handshake.rs | 169 +- tako-rs-core/src/ws/mask.rs | 43 + tako-rs-core/src/ws/message.rs | 285 ++ tako-rs-core/src/ws/protocol.rs | 335 +++ .../src/ws/protocol/regression_tests.rs | 111 + tako-rs-core/src/ws/receiver.rs | 404 +++ tako-rs-core/src/ws/runtime.rs | 138 + tako-rs-core/src/ws/socket.rs | 330 ++ tako-rs-core/src/ws/socket/tests.rs | 293 ++ tako-rs-core/src/ws/testing.rs | 222 ++ tako-rs-core/src/ws/upgrade.rs | 247 ++ tako-rs-core/src/ws/upgrade/tests.rs | 56 + tako-rs-core/src/ws/utf8.rs | 98 + tako-rs-core/src/ws/wake.rs | 56 + tako-rs-core/tests/graphql_ws.rs | 117 + tako-rs-streams/Cargo.toml | 6 - tako-rs-streams/src/lib.rs | 11 - tako-rs-streams/src/ws.rs | 360 --- tako-rs-streams/src/ws_compio.rs | 329 -- tako-rs/Cargo.toml | 5 +- tako-rs/src/lib.rs | 27 +- tako-rs/tests/websocket_connection_lease.rs | 11 +- tako-rs/tests/websocket_echo.rs | 137 + tako-rs/tests/websocket_echo_compio.rs | 160 + tako-rs/tests/websocket_handshake.rs | 12 +- website/content/docs/concepts/runtimes.mdx | 8 +- .../docs/getting-started/coming-from-axum.mdx | 13 +- .../docs/getting-started/installation.mdx | 6 +- website/content/docs/reference/features.mdx | 3 +- website/content/docs/reference/meta.json | 2 +- .../content/docs/reference/migration-2-5.mdx | 169 ++ website/content/docs/streams.mdx | 28 +- website/content/docs/transports/index.mdx | 6 +- website/content/docs/transports/websocket.mdx | 160 +- .../content/docs/tutorials/realtime-chat.mdx | 97 +- 74 files changed, 7946 insertions(+), 1959 deletions(-) create mode 100644 ci/autobahn/README.md create mode 100644 docs/superpowers/plans/2026-10-18-websocket-engine.md create mode 100644 docs/superpowers/specs/2026-10-18-websocket-engine-design.md create mode 100644 examples/autobahn-echo-compio/Cargo.lock create mode 100644 examples/autobahn-echo-compio/Cargo.toml create mode 100644 examples/autobahn-echo-compio/src/main.rs delete mode 100644 examples/websocket-http2/Cargo.toml delete mode 100644 examples/websocket-http2/src/main.rs rename examples/{websocket-http2 => websocket-tls}/Cargo.lock (94%) create mode 100644 examples/websocket-tls/Cargo.toml create mode 100644 examples/websocket-tls/src/main.rs create mode 100644 fuzz/fuzz_targets/ws_frame.rs create mode 100644 tako-rs-core/src/ws.rs create mode 100644 tako-rs-core/src/ws/driver.rs create mode 100644 tako-rs-core/src/ws/driver/tests.rs create mode 100644 tako-rs-core/src/ws/error.rs create mode 100644 tako-rs-core/src/ws/frame.rs rename tako-rs-streams/src/ws_handshake.rs => tako-rs-core/src/ws/handshake.rs (52%) create mode 100644 tako-rs-core/src/ws/mask.rs create mode 100644 tako-rs-core/src/ws/message.rs create mode 100644 tako-rs-core/src/ws/protocol.rs create mode 100644 tako-rs-core/src/ws/protocol/regression_tests.rs create mode 100644 tako-rs-core/src/ws/receiver.rs create mode 100644 tako-rs-core/src/ws/runtime.rs create mode 100644 tako-rs-core/src/ws/socket.rs create mode 100644 tako-rs-core/src/ws/socket/tests.rs create mode 100644 tako-rs-core/src/ws/testing.rs create mode 100644 tako-rs-core/src/ws/upgrade.rs create mode 100644 tako-rs-core/src/ws/upgrade/tests.rs create mode 100644 tako-rs-core/src/ws/utf8.rs create mode 100644 tako-rs-core/src/ws/wake.rs create mode 100644 tako-rs-core/tests/graphql_ws.rs delete mode 100644 tako-rs-streams/src/ws.rs delete mode 100644 tako-rs-streams/src/ws_compio.rs create mode 100644 tako-rs/tests/websocket_echo.rs create mode 100644 tako-rs/tests/websocket_echo_compio.rs create mode 100644 website/content/docs/reference/migration-2-5.mdx diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 62f8f5c..713fb59 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 7ae39c0..3c15e1c 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 708f797..e6ddab6 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 0000000..4e3dffb --- /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 d99292c..9915623 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 0000000..c152d2b --- /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 0000000..9fc0d11 --- /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 0000000..8a76e44 --- /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 0000000..0142c30 --- /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 0000000..a4bc28a --- /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 a700b7f..fcb3485 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 bd62cc0..e3aa9e2 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 4f2a2f9..6a531f3 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 3aa4ac0..327befa 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 e780951..2532d38 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 b74c599..f846e6a 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 b862207..111a96f 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 143cfa6..828c52c 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 61d2da8..a80a9ef 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 f96915e..0000000 --- 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 356c52a..0000000 --- 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 cc5adab..d8ea4c9 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 0000000..751cc21 --- /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 0000000..a9ffa00 --- /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 ae9502f..1308dd7 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 174a952..1541d8f 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 7a7a3e7..240de92 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 02d99a8..1545908 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 cbae30b..f9c302a 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 0000000..e6f6eb8 --- /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 2e29ded..161718c 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 d3cc85d..f51f83f 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 abec4f1..3f504ff 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 5fddad9..a7d9f04 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 0000000..6248743 --- /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 0000000..88c0e47 --- /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 0000000..aa84de0 --- /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 0000000..df2611c --- /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 0000000..3ea7550 --- /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-streams/src/ws_handshake.rs b/tako-rs-core/src/ws/handshake.rs similarity index 52% rename from tako-rs-streams/src/ws_handshake.rs rename to tako-rs-core/src/ws/handshake.rs index d82e6f7..66e3797 100644 --- a/tako-rs-streams/src/ws_handshake.rs +++ b/tako-rs-core/src/ws/handshake.rs @@ -7,10 +7,12 @@ 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; +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); @@ -46,6 +48,7 @@ pub(crate) fn accept(request: &Request) -> Result { 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 { @@ -59,6 +62,74 @@ pub(crate) fn rejection(status: StatusCode) -> Response { .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(header::SEC_WEBSOCKET_PROTOCOL)? + .to_str() + .ok()? + .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(); @@ -76,7 +147,20 @@ fn has_token(headers: &HeaderMap, name: header::HeaderName, token: &str) -> bool #[cfg(test)] mod tests { - use super::*; + 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() @@ -153,4 +237,81 @@ mod tests { *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 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 0000000..3cf19bc --- /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 0000000..e38bf5b --- /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 0000000..6c0990f --- /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 0000000..d29e9d6 --- /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 0000000..3ac2603 --- /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 0000000..9c281c4 --- /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 0000000..3517093 --- /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 0000000..cfca2d5 --- /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 0000000..7f343ae --- /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 0000000..d3060fe --- /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 0000000..02c863e --- /dev/null +++ b/tako-rs-core/src/ws/upgrade/tests.rs @@ -0,0 +1,56 @@ +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 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 0000000..1c197e0 --- /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 0000000..6fae703 --- /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 0000000..1560716 --- /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 e1c76ec..dfed645 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 234f862..8da10c2 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 bfd17c7..0000000 --- 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 fd78c2c..0000000 --- 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/Cargo.toml b/tako-rs/Cargo.toml index 25c3ced..cfa2acf 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 07f8cdc..5f1e9e6 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 03f93df..9d6844a 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 0000000..efd7a10 --- /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 0000000..7b594f8 --- /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 27be44a..6b9b470 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 e8d4a77..b20fe86 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 5b6b268..d7f7c19 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 6d65abd..7290b6f 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 d6072c4..5851f1a 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 4f94cfe..42a8e1e 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 0000000..f80870d --- /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 091d6b8..dc06475 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 c117302..cd5c138 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 2c0d0a1..6246709 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 1046750..d4146fb 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. From b55f77f8b6254e7cd936fdcd142470f47da4189a Mon Sep 17 00:00:00 2001 From: Daniel Boros Date: Mon, 19 Oct 2026 10:33:09 +0200 Subject: [PATCH 2/2] fix: negotiate repeated WebSocket protocol headers --- tako-rs-core/src/ws/handshake.rs | 27 +++++++++++++++++++++++---- tako-rs-core/src/ws/upgrade/tests.rs | 21 +++++++++++++++++++++ 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/tako-rs-core/src/ws/handshake.rs b/tako-rs-core/src/ws/handshake.rs index 66e3797..6f05920 100644 --- a/tako-rs-core/src/ws/handshake.rs +++ b/tako-rs-core/src/ws/handshake.rs @@ -71,10 +71,10 @@ pub(crate) fn negotiate_subprotocol( return None; } let offered: Vec<&str> = headers - .get(header::SEC_WEBSOCKET_PROTOCOL)? - .to_str() - .ok()? - .split(',') + .get_all(header::SEC_WEBSOCKET_PROTOCOL) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(',')) .map(str::trim) .collect(); protocols @@ -254,6 +254,25 @@ mod tests { 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()]; diff --git a/tako-rs-core/src/ws/upgrade/tests.rs b/tako-rs-core/src/ws/upgrade/tests.rs index 02c863e..8ccfd0e 100644 --- a/tako-rs-core/src/ws/upgrade/tests.rs +++ b/tako-rs-core/src/ws/upgrade/tests.rs @@ -44,6 +44,27 @@ fn unrepresentable_limits_are_rejected_before_upgrading() { 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)